diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index 5044dd4415555..296490511cbea 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -4,6 +4,7 @@ import dataclasses from datetime import datetime +import logging from typing import Final from aioecowitt import EcoWittSensor, EcoWittSensorTypes @@ -39,6 +40,9 @@ from . import EcowittConfigEntry from .entity import EcowittEntity +_LOGGER = logging.getLogger(__name__) + + _METRIC: Final = ( EcoWittSensorTypes.TEMPERATURE_C, EcoWittSensorTypes.RAIN_COUNT_MM, @@ -57,6 +61,40 @@ ) +_RAIN_COUNT_SENSORS_STATE_CLASS_MAPPING: Final = { + "eventrainin": SensorStateClass.TOTAL_INCREASING, + "hourlyrainin": None, + "totalrainin": SensorStateClass.TOTAL_INCREASING, + "dailyrainin": SensorStateClass.TOTAL_INCREASING, + "weeklyrainin": SensorStateClass.TOTAL_INCREASING, + "monthlyrainin": SensorStateClass.TOTAL_INCREASING, + "yearlyrainin": SensorStateClass.TOTAL_INCREASING, + "last24hrainin": None, + "eventrainmm": SensorStateClass.TOTAL_INCREASING, + "hourlyrainmm": None, + "totalrainmm": SensorStateClass.TOTAL_INCREASING, + "dailyrainmm": SensorStateClass.TOTAL_INCREASING, + "weeklyrainmm": SensorStateClass.TOTAL_INCREASING, + "monthlyrainmm": SensorStateClass.TOTAL_INCREASING, + "yearlyrainmm": SensorStateClass.TOTAL_INCREASING, + "last24hrainmm": None, + "erain_piezo": SensorStateClass.TOTAL_INCREASING, + "hrain_piezo": None, + "drain_piezo": SensorStateClass.TOTAL_INCREASING, + "wrain_piezo": SensorStateClass.TOTAL_INCREASING, + "mrain_piezo": SensorStateClass.TOTAL_INCREASING, + "yrain_piezo": SensorStateClass.TOTAL_INCREASING, + "last24hrain_piezo": None, + "erain_piezomm": SensorStateClass.TOTAL_INCREASING, + "hrain_piezomm": None, + "drain_piezomm": SensorStateClass.TOTAL_INCREASING, + "wrain_piezomm": SensorStateClass.TOTAL_INCREASING, + "mrain_piezomm": SensorStateClass.TOTAL_INCREASING, + "yrain_piezomm": SensorStateClass.TOTAL_INCREASING, + "last24hrain_piezomm": None, +} + + ECOWITT_SENSORS_MAPPING: Final = { EcoWittSensorTypes.HUMIDITY: SensorEntityDescription( key="HUMIDITY", @@ -285,15 +323,15 @@ def _new_sensor(sensor: EcoWittSensor) -> None: name=sensor.name, ) - # Only total rain needs state class for long-term statistics - if sensor.key in ( - "totalrainin", - "totalrainmm", + if sensor.stype in ( + EcoWittSensorTypes.RAIN_COUNT_INCHES, + EcoWittSensorTypes.RAIN_COUNT_MM, ): - description = dataclasses.replace( - description, - state_class=SensorStateClass.TOTAL_INCREASING, - ) + if sensor.key not in _RAIN_COUNT_SENSORS_STATE_CLASS_MAPPING: + _LOGGER.warning("Unknown rain count sensor: %s", sensor.key) + return + state_class = _RAIN_COUNT_SENSORS_STATE_CLASS_MAPPING[sensor.key] + description = dataclasses.replace(description, state_class=state_class) async_add_entities([EcowittSensorEntity(sensor, description)]) diff --git a/homeassistant/components/eheimdigital/manifest.json b/homeassistant/components/eheimdigital/manifest.json index 72864406ecfe8..16913eb54de03 100644 --- a/homeassistant/components/eheimdigital/manifest.json +++ b/homeassistant/components/eheimdigital/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["eheimdigital"], "quality_scale": "platinum", - "requirements": ["eheimdigital==1.4.0"], + "requirements": ["eheimdigital==1.5.0"], "zeroconf": [ { "name": "eheimdigital._http._tcp.local.", "type": "_http._tcp.local." } ] diff --git a/homeassistant/components/hue_ble/manifest.json b/homeassistant/components/hue_ble/manifest.json index ce3b4b4e585b2..707594fcde177 100644 --- a/homeassistant/components/hue_ble/manifest.json +++ b/homeassistant/components/hue_ble/manifest.json @@ -4,7 +4,8 @@ "bluetooth": [ { "connectable": true, - "service_data_uuid": "0000fe0f-0000-1000-8000-00805f9b34fb" + "service_data_uuid": "0000fe0f-0000-1000-8000-00805f9b34fb", + "service_uuid": "0000fe0f-0000-1000-8000-00805f9b34fb" } ], "codeowners": ["@flip-dots"], diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 857f01c6a6af5..a5dca4bbafa05 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -281,7 +281,7 @@ key="range.remaining", translation_key="range_remaining", device_class=SensorDeviceClass.DISTANCE, - native_unit_of_measurement=UnitOfLength.KILOMETERS, + native_unit_of_measurement=UnitOfLength.METERS, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, ), diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index 0b5bca4cd577d..9b9a61a5cc4cc 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import logging from httpx import HTTPStatusError, RequestError, TimeoutException @@ -19,13 +20,15 @@ ) from homeassistant.helpers.httpx_client import get_async_client +from . import api from .api import AsyncConfigEntryAuth from .const import DOMAIN from .coordinator import ( XboxConfigEntry, XboxConsolesCoordinator, + XboxConsoleStatusCoordinator, XboxCoordinators, - XboxUpdateCoordinator, + XboxPresenceCoordinator, ) _LOGGER = logging.getLogger(__name__) @@ -44,12 +47,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: XboxConfigEntry) -> bool: """Set up xbox from a config entry.""" - coordinator = XboxUpdateCoordinator(hass, entry) - await coordinator.async_config_entry_first_refresh() - - consoles = XboxConsolesCoordinator(hass, entry, coordinator) - - entry.runtime_data = XboxCoordinators(coordinator, consoles) + try: + implementation = await async_get_config_entry_implementation(hass, entry) + except ImplementationUnavailableError as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from e + + session = OAuth2Session(hass, entry, implementation) + async_session = get_async_client(hass) + auth = api.AsyncConfigEntryAuth(async_session, session) + client = XboxLiveClient(auth) + + consoles = XboxConsolesCoordinator(hass, entry, client) + await consoles.async_config_entry_first_refresh() + + status = XboxConsoleStatusCoordinator(hass, entry, client, consoles.data) + presence = XboxPresenceCoordinator(hass, entry, client) + await asyncio.gather( + status.async_config_entry_first_refresh(), + presence.async_config_entry_first_refresh(), + ) + + entry.runtime_data = XboxCoordinators(consoles, status, presence) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/xbox/binary_sensor.py b/homeassistant/components/xbox/binary_sensor.py index 1728a58bd7cbe..535dfe9768951 100644 --- a/homeassistant/components/xbox/binary_sensor.py +++ b/homeassistant/components/xbox/binary_sensor.py @@ -112,7 +112,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Xbox Live friends.""" - coordinator = entry.runtime_data.status + coordinator = entry.runtime_data.presence if TYPE_CHECKING: assert entry.unique_id diff --git a/homeassistant/components/xbox/config_flow.py b/homeassistant/components/xbox/config_flow.py index 2650c8f67ca89..5ca58210f1851 100644 --- a/homeassistant/components/xbox/config_flow.py +++ b/homeassistant/components/xbox/config_flow.py @@ -122,7 +122,7 @@ async def async_step_user( if config_entry.state is not ConfigEntryState.LOADED: return self.async_abort(reason="config_entry_not_loaded") - client = config_entry.runtime_data.status.client + client = config_entry.runtime_data.presence.client friends_list = await client.people.get_friends_own() if user_input is not None: diff --git a/homeassistant/components/xbox/coordinator.py b/homeassistant/components/xbox/coordinator.py index aadfd43b2cc1b..a77a4c9ff7e76 100644 --- a/homeassistant/components/xbox/coordinator.py +++ b/homeassistant/components/xbox/coordinator.py @@ -2,10 +2,12 @@ from __future__ import annotations +from abc import abstractmethod from dataclasses import dataclass, field from datetime import datetime, timedelta from http import HTTPStatus import logging +from typing import ClassVar from httpx import HTTPStatusError, RequestError, TimeoutException from pythonxbox.api.client import XboxLiveClient @@ -13,23 +15,15 @@ from pythonxbox.api.provider.catalog.models import AlternateIdType, Product from pythonxbox.api.provider.people.models import Person from pythonxbox.api.provider.smartglass.models import ( - SmartglassConsoleList, + SmartglassConsole, SmartglassConsoleStatus, ) from pythonxbox.api.provider.titlehub.models import Title from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.config_entry_oauth2_flow import ( - ImplementationUnavailableError, - OAuth2Session, - async_get_config_entry_implementation, -) -from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from . import api from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -47,9 +41,8 @@ class ConsoleData: @dataclass class XboxData: - """Xbox dataclass for update coordinator.""" + """Xbox dataclass for presence update coordinator.""" - consoles: dict[str, ConsoleData] = field(default_factory=dict) presence: dict[str, Person] = field(default_factory=dict) title_info: dict[str, Title] = field(default_factory=dict) @@ -58,21 +51,22 @@ class XboxData: class XboxCoordinators: """Xbox coordinators.""" - status: XboxUpdateCoordinator consoles: XboxConsolesCoordinator + status: XboxConsoleStatusCoordinator + presence: XboxPresenceCoordinator -class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): - """Store Xbox Console Status.""" +class XboxBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): + """Base coordinator for Xbox.""" config_entry: XboxConfigEntry - consoles: SmartglassConsoleList - client: XboxLiveClient + _update_inverval: timedelta def __init__( self, hass: HomeAssistant, config_entry: XboxConfigEntry, + client: XboxLiveClient, ) -> None: """Initialize.""" super().__init__( @@ -80,77 +74,86 @@ def __init__( _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=timedelta(seconds=15), + update_interval=self._update_interval, ) - self.data = XboxData() - self.current_friends: set[str] = set() - self.title_data: dict[str, Title] = {} + self.client = client - async def _async_setup(self) -> None: - """Set up coordinator.""" - try: - implementation = await async_get_config_entry_implementation( - self.hass, self.config_entry - ) - except ImplementationUnavailableError as e: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="oauth2_implementation_unavailable", - ) from e + @abstractmethod + async def update_data(self) -> _DataT: + """Update coordinator data.""" - session = OAuth2Session(self.hass, self.config_entry, implementation) - async_session = get_async_client(self.hass) - auth = api.AsyncConfigEntryAuth(async_session, session) - self.client = XboxLiveClient(auth) + async def _async_update_data(self) -> _DataT: + """Fetch console data.""" try: - self.consoles = await self.client.smartglass.get_console_list() + return await self.update_data() except TimeoutException as e: - raise ConfigEntryNotReady( + raise UpdateFailed( translation_domain=DOMAIN, translation_key="timeout_exception", ) from e except (RequestError, HTTPStatusError) as e: _LOGGER.debug("Xbox exception:", exc_info=True) - raise ConfigEntryNotReady( + raise UpdateFailed( translation_domain=DOMAIN, translation_key="request_exception", ) from e + +class XboxConsolesCoordinator(XboxBaseCoordinator[dict[str, SmartglassConsole]]): + """Update list of Xbox consoles.""" + + config_entry: XboxConfigEntry + _update_interval = timedelta(minutes=10) + + async def update_data(self) -> dict[str, SmartglassConsole]: + """Fetch console data.""" + + consoles = await self.client.smartglass.get_console_list() + _LOGGER.debug( - "Found %d consoles: %s", - len(self.consoles.result), - self.consoles.model_dump(), + "Found %d consoles: %s", len(consoles.result), consoles.model_dump() ) - async def _async_update_data(self) -> XboxData: - """Fetch the latest console status.""" - # Update Console Status - new_console_data: dict[str, ConsoleData] = {} - for console in self.consoles.result: - current_state = self.data.consoles.get(console.id) - try: - status = await self.client.smartglass.get_console_status(console.id) - except TimeoutException as e: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="timeout_exception", - ) from e - except (RequestError, HTTPStatusError) as e: - _LOGGER.debug("Xbox exception:", exc_info=True) - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="request_exception", - ) from e - _LOGGER.debug( - "%s status: %s", - console.name, - status.model_dump(), - ) + return {console.id: console for console in consoles.result} + + +class XboxConsoleStatusCoordinator(XboxBaseCoordinator[dict[str, ConsoleData]]): + """Update Xbox console Status.""" + + config_entry: XboxConfigEntry + _update_interval = timedelta(seconds=10) + + def __init__( + self, + hass: HomeAssistant, + config_entry: XboxConfigEntry, + client: XboxLiveClient, + consoles: dict[str, SmartglassConsole], + ) -> None: + """Initialize.""" + super().__init__(hass, config_entry, client) + self.data: dict[str, ConsoleData] = {} + + self.consoles: dict[str, SmartglassConsole] | None = consoles + + async def update_data(self) -> dict[str, ConsoleData]: + """Fetch console data.""" + + consoles: list[SmartglassConsole] = list(self.async_contexts()) + + if not consoles and self.consoles is not None: + consoles = list(self.consoles.values()) + self.consoles = None + + data: dict[str, ConsoleData] = {} + for console in consoles: + status = await self.client.smartglass.get_console_status(console.id) + _LOGGER.debug("%s status: %s", console.name, status.model_dump()) # Setup focus app app_details: Product | None = None - if current_state is not None: + if (current_state := self.data.get(console.id)) is not None: app_details = current_state.app_details if status.focus_app_aumid: @@ -163,51 +166,44 @@ async def _async_update_data(self) -> XboxData: if app_id in SYSTEM_PFN_ID_MAP: id_type = AlternateIdType.LEGACY_XBOX_PRODUCT_ID app_id = SYSTEM_PFN_ID_MAP[app_id][id_type] - try: - catalog_result = ( - await self.client.catalog.get_product_from_alternate_id( - app_id, id_type - ) + + catalog_result = ( + await self.client.catalog.get_product_from_alternate_id( + app_id, id_type ) - except TimeoutException as e: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="timeout_exception", - ) from e - except (RequestError, HTTPStatusError) as e: - _LOGGER.debug("Xbox exception:", exc_info=True) - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="request_exception", - ) from e - else: - if catalog_result.products: - app_details = catalog_result.products[0] + ) + + if catalog_result.products: + app_details = catalog_result.products[0] else: app_details = None - new_console_data[console.id] = ConsoleData( - status=status, app_details=app_details - ) + data[console.id] = ConsoleData(status=status, app_details=app_details) - # Update user presence - try: - batch = await self.client.people.get_friends_by_xuid(self.client.xuid) - friends = await self.client.people.get_friends_own() - except TimeoutException as e: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="timeout_exception", - ) from e - except (RequestError, HTTPStatusError) as e: - _LOGGER.debug("Xbox exception:", exc_info=True) - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="request_exception", - ) from e - else: - presence_data = {self.client.xuid: batch.people[0]} - presence_data.update({friend.xuid: friend for friend in friends.people}) + return data + + +class XboxPresenceCoordinator(XboxBaseCoordinator[XboxData]): + """Update list of Xbox consoles.""" + + config_entry: XboxConfigEntry + _update_interval = timedelta(seconds=30) + title_data: ClassVar[dict[str, Title]] = {} + + async def update_data(self) -> XboxData: + """Fetch presence data.""" + + batch = await self.client.people.get_friends_by_xuid(self.client.xuid) + friends = await self.client.people.get_friends_own() + + presence_data = {self.client.xuid: batch.people[0]} + presence_data.update( + { + friend.xuid: friend + for friend in friends.people + if friend.xuid in self.friend_subentries() + } + ) # retrieve title details for person in presence_data.values(): @@ -229,40 +225,27 @@ async def _async_update_data(self) -> XboxData: title = await self.client.titlehub.get_title_info( presence_detail.title_id ) - except TimeoutException as e: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="timeout_exception", - ) from e except HTTPStatusError as e: - _LOGGER.debug("Xbox exception:", exc_info=True) if e.response.status_code == HTTPStatus.NOT_FOUND: continue - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="request_exception", - ) from e - except RequestError as e: - _LOGGER.debug("Xbox exception:", exc_info=True) - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="request_exception", - ) from e + raise self.title_data[person.xuid] = title.titles[0] else: self.title_data.pop(person.xuid, None) person.last_seen_date_time_utc = self.last_seen_timestamp(person) - return XboxData(new_console_data, presence_data, self.title_data) + return XboxData(presence_data, self.title_data) def last_seen_timestamp(self, person: Person) -> datetime | None: """Returns the most recent of two timestamps.""" # The Xbox API constantly fluctuates the "last seen" timestamp between two close values, # causing unnecessary updates. We only accept the most recent one as valild to prevent this. - if not (prev_data := self.data.presence.get(person.xuid)): - return person.last_seen_date_time_utc - prev_dt = prev_data.last_seen_date_time_utc + prev_dt = ( + prev_data.last_seen_date_time_utc + if self.data and (prev_data := self.data.presence.get(person.xuid)) + else None + ) cur_dt = person.last_seen_date_time_utc if prev_dt and cur_dt: @@ -270,51 +253,10 @@ def last_seen_timestamp(self, person: Person) -> datetime | None: return cur_dt - def configured_as_entry(self) -> set[str]: - """Get xuids of configured entries.""" - + def friend_subentries(self) -> set[str]: + """Get configured friend subentries.""" return { - entry.unique_id - for entry in self.hass.config_entries.async_entries(DOMAIN) - if entry.unique_id is not None + friend.unique_id + for friend in self.config_entry.subentries.values() + if friend.unique_id } - - -class XboxConsolesCoordinator(DataUpdateCoordinator[SmartglassConsoleList]): - """Update list of Xbox consoles.""" - - config_entry: XboxConfigEntry - - def __init__( - self, - hass: HomeAssistant, - config_entry: XboxConfigEntry, - coordinator: XboxUpdateCoordinator, - ) -> None: - """Initialize.""" - super().__init__( - hass, - _LOGGER, - config_entry=config_entry, - name=DOMAIN, - update_interval=timedelta(minutes=10), - ) - self.client = coordinator.client - self.async_set_updated_data(coordinator.consoles) - - async def _async_update_data(self) -> SmartglassConsoleList: - """Fetch console data.""" - - try: - return await self.client.smartglass.get_console_list() - except TimeoutException as e: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="timeout_exception", - ) from e - except (RequestError, HTTPStatusError) as e: - _LOGGER.debug("Xbox exception:", exc_info=True) - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="request_exception", - ) from e diff --git a/homeassistant/components/xbox/diagnostics.py b/homeassistant/components/xbox/diagnostics.py index 535e6582d340a..befc48c05331a 100644 --- a/homeassistant/components/xbox/diagnostics.py +++ b/homeassistant/components/xbox/diagnostics.py @@ -29,12 +29,9 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator = config_entry.runtime_data.status - consoles_coordinator = config_entry.runtime_data.consoles - presence = [ async_redact_data(person.model_dump(), TO_REDACT) - for person in coordinator.data.presence.values() + for person in config_entry.runtime_data.presence.data.presence.values() ] consoles_status = [ { @@ -43,10 +40,16 @@ async def async_get_config_entry_diagnostics( console.app_details.model_dump() if console.app_details else None ), } - for console in coordinator.data.consoles.values() + for console in config_entry.runtime_data.status.data.values() + ] + consoles_list = [ + console.model_dump() + for console in config_entry.runtime_data.consoles.data.values() + ] + title_info = [ + title.model_dump() + for title in config_entry.runtime_data.presence.data.title_info.values() ] - consoles_list = consoles_coordinator.data.model_dump() - title_info = [title.model_dump() for title in coordinator.data.title_info.values()] return { "consoles_status": consoles_status, diff --git a/homeassistant/components/xbox/entity.py b/homeassistant/components/xbox/entity.py index ac406e2d64eb4..381a3c245c1d8 100644 --- a/homeassistant/components/xbox/entity.py +++ b/homeassistant/components/xbox/entity.py @@ -18,7 +18,11 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import ConsoleData, XboxUpdateCoordinator +from .coordinator import ( + ConsoleData, + XboxConsoleStatusCoordinator, + XboxPresenceCoordinator, +) MAP_MODEL = { ConsoleType.XboxOne: "Xbox One", @@ -41,7 +45,7 @@ class XboxBaseEntityDescription(EntityDescription): deprecated: bool | None = None -class XboxBaseEntity(CoordinatorEntity[XboxUpdateCoordinator]): +class XboxBaseEntity(CoordinatorEntity[XboxPresenceCoordinator]): """Base Sensor for the Xbox Integration.""" _attr_has_entity_name = True @@ -49,7 +53,7 @@ class XboxBaseEntity(CoordinatorEntity[XboxUpdateCoordinator]): def __init__( self, - coordinator: XboxUpdateCoordinator, + coordinator: XboxPresenceCoordinator, xuid: str, entity_description: XboxBaseEntityDescription, ) -> None: @@ -106,7 +110,7 @@ def available(self) -> bool: return super().available and self.xuid in self.coordinator.data.presence -class XboxConsoleBaseEntity(CoordinatorEntity[XboxUpdateCoordinator]): +class XboxConsoleBaseEntity(CoordinatorEntity[XboxConsoleStatusCoordinator]): """Console base entity for the Xbox integration.""" _attr_has_entity_name = True @@ -114,11 +118,11 @@ class XboxConsoleBaseEntity(CoordinatorEntity[XboxUpdateCoordinator]): def __init__( self, console: SmartglassConsole, - coordinator: XboxUpdateCoordinator, + coordinator: XboxConsoleStatusCoordinator, ) -> None: """Initialize the Xbox Console entity.""" - super().__init__(coordinator) + super().__init__(coordinator, console) self.client = coordinator.client self._console = console @@ -135,7 +139,7 @@ def __init__( @property def data(self) -> ConsoleData: """Return coordinator data for this console.""" - return self.coordinator.data.consoles[self._console.id] + return self.coordinator.data[self._console.id] def check_deprecated_entity( diff --git a/homeassistant/components/xbox/image.py b/homeassistant/components/xbox/image.py index c6f10a2f625c8..a74b99440949b 100644 --- a/homeassistant/components/xbox/image.py +++ b/homeassistant/components/xbox/image.py @@ -15,7 +15,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from .coordinator import XboxConfigEntry, XboxUpdateCoordinator +from .coordinator import XboxConfigEntry, XboxPresenceCoordinator from .entity import XboxBaseEntity, XboxBaseEntityDescription, profile_pic PARALLEL_UPDATES = 0 @@ -64,7 +64,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Xbox images.""" - coordinator = config_entry.runtime_data.status + coordinator = config_entry.runtime_data.presence if TYPE_CHECKING: assert config_entry.unique_id async_add_entities( @@ -95,7 +95,7 @@ class XboxImageEntity(XboxBaseEntity, ImageEntity): def __init__( self, hass: HomeAssistant, - coordinator: XboxUpdateCoordinator, + coordinator: XboxPresenceCoordinator, xuid: str, entity_description: XboxImageEntityDescription, ) -> None: diff --git a/homeassistant/components/xbox/media_player.py b/homeassistant/components/xbox/media_player.py index 8b93cf87bc396..6ef8c75d2b608 100644 --- a/homeassistant/components/xbox/media_player.py +++ b/homeassistant/components/xbox/media_player.py @@ -18,7 +18,7 @@ MediaPlayerState, MediaType, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .browse_media import build_item_response @@ -57,15 +57,28 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Xbox media_player from a config entry.""" + devices_added: set[str] = set() - coordinator = entry.runtime_data.status + status = entry.runtime_data.status + consoles = entry.runtime_data.consoles - async_add_entities( - [ - XboxMediaPlayer(console, coordinator) - for console in coordinator.consoles.result - ] - ) + @callback + def add_entities() -> None: + nonlocal devices_added + + new_devices = set(consoles.data) - devices_added + + if new_devices: + async_add_entities( + [ + XboxMediaPlayer(consoles.data[console_id], status) + for console_id in new_devices + ] + ) + devices_added |= new_devices + + entry.async_on_unload(consoles.async_add_listener(add_entities)) + add_entities() class XboxMediaPlayer(XboxConsoleBaseEntity, MediaPlayerEntity): diff --git a/homeassistant/components/xbox/media_source.py b/homeassistant/components/xbox/media_source.py index d7a4bde62e3f2..c5e502410dc59 100644 --- a/homeassistant/components/xbox/media_source.py +++ b/homeassistant/components/xbox/media_source.py @@ -641,7 +641,7 @@ def _build_media_items_promotional( def gamerpic(config_entry: XboxConfigEntry) -> str | None: """Return gamerpic.""" - coordinator = config_entry.runtime_data.status + coordinator = config_entry.runtime_data.presence if TYPE_CHECKING: assert config_entry.unique_id person = coordinator.data.presence[coordinator.client.xuid] diff --git a/homeassistant/components/xbox/remote.py b/homeassistant/components/xbox/remote.py index b7c44dd116a86..22e2c31af817d 100644 --- a/homeassistant/components/xbox/remote.py +++ b/homeassistant/components/xbox/remote.py @@ -15,7 +15,7 @@ DEFAULT_DELAY_SECS, RemoteEntity, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import XboxConfigEntry @@ -46,11 +46,29 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Xbox media_player from a config entry.""" + devices_added: set[str] = set() + coordinator = entry.runtime_data.status + consoles = entry.runtime_data.consoles + + @callback + def add_entities() -> None: + nonlocal devices_added + + new_devices = set(consoles.data) - devices_added + + if new_devices: + async_add_entities( + [ + XboxRemote(consoles.data[console_id], coordinator) + for console_id in new_devices + ] + ) + + devices_added |= new_devices - async_add_entities( - [XboxRemote(console, coordinator) for console in coordinator.consoles.result] - ) + entry.async_on_unload(consoles.async_add_listener(add_entities)) + add_entities() class XboxRemote(XboxConsoleBaseEntity, RemoteEntity): diff --git a/homeassistant/components/xbox/sensor.py b/homeassistant/components/xbox/sensor.py index 56775e1e266a1..3f32412b723d9 100644 --- a/homeassistant/components/xbox/sensor.py +++ b/homeassistant/components/xbox/sensor.py @@ -21,7 +21,7 @@ SensorStateClass, ) from homeassistant.const import CONF_NAME, UnitOfInformation -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType @@ -253,12 +253,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Xbox Live friends.""" - coordinator = config_entry.runtime_data.status + presence = config_entry.runtime_data.presence if TYPE_CHECKING: assert config_entry.unique_id async_add_entities( [ - XboxSensorEntity(coordinator, config_entry.unique_id, description) + XboxSensorEntity(presence, config_entry.unique_id, description) for description in SENSOR_DESCRIPTIONS if check_deprecated_entity( hass, config_entry.unique_id, description, SENSOR_DOMAIN @@ -268,31 +268,44 @@ async def async_setup_entry( for subentry_id, subentry in config_entry.subentries.items(): async_add_entities( [ - XboxSensorEntity(coordinator, subentry.unique_id, description) + XboxSensorEntity(presence, subentry.unique_id, description) for description in SENSOR_DESCRIPTIONS if subentry.unique_id and check_deprecated_entity( hass, subentry.unique_id, description, SENSOR_DOMAIN ) - and subentry.unique_id in coordinator.data.presence + and subentry.unique_id in presence.data.presence and subentry.subentry_type == "friend" ], config_subentry_id=subentry_id, ) - consoles_coordinator = config_entry.runtime_data.consoles + consoles = config_entry.runtime_data.consoles - async_add_entities( - [ - XboxStorageDeviceSensorEntity( - console, storage_device, consoles_coordinator, description + devices_added: set[str] = set() + + @callback + def add_entities() -> None: + nonlocal devices_added + + new_devices = set(consoles.data) - devices_added + + if new_devices: + async_add_entities( + [ + XboxStorageDeviceSensorEntity( + consoles.data[console_id], storage_device, consoles, description + ) + for description in STORAGE_SENSOR_DESCRIPTIONS + for console_id in new_devices + if (storage_devices := consoles.data[console_id].storage_devices) + for storage_device in storage_devices + ] ) - for description in STORAGE_SENSOR_DESCRIPTIONS - for console in coordinator.consoles.result - if console.storage_devices - for storage_device in console.storage_devices - ] - ) + devices_added |= new_devices + + config_entry.async_on_unload(consoles.async_add_listener(add_entities)) + add_entities() class XboxSensorEntity(XboxBaseEntity, SensorEntity): @@ -344,9 +357,9 @@ def __init__( @property def data(self) -> StorageDevice | None: """Storage device data.""" - consoles = self.coordinator.data.result - console = next((c for c in consoles if c.id == self._console.id), None) - if not console or not console.storage_devices: + console = self.coordinator.data[self._console.id] + + if not console.storage_devices: return None return next( diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index f212b6aadb44e..51709a3b54812 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -323,6 +323,7 @@ "connectable": True, "domain": "hue_ble", "service_data_uuid": "0000fe0f-0000-1000-8000-00805f9b34fb", + "service_uuid": "0000fe0f-0000-1000-8000-00805f9b34fb", }, { "connectable": True, diff --git a/requirements_all.txt b/requirements_all.txt index eecf9bc43cf6a..1f0293273b0da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -854,7 +854,7 @@ ecoaliface==0.4.0 egauge-async==0.4.0 # homeassistant.components.eheimdigital -eheimdigital==1.4.0 +eheimdigital==1.5.0 # homeassistant.components.ekeybionyx ekey-bionyxpy==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 609d654dbb044..804f2167e660a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -754,7 +754,7 @@ easyenergy==2.1.2 egauge-async==0.4.0 # homeassistant.components.eheimdigital -eheimdigital==1.4.0 +eheimdigital==1.5.0 # homeassistant.components.ekeybionyx ekey-bionyxpy==1.0.1 diff --git a/tests/components/nina/conftest.py b/tests/components/nina/conftest.py new file mode 100644 index 0000000000000..aabdc14b33c80 --- /dev/null +++ b/tests/components/nina/conftest.py @@ -0,0 +1,15 @@ +"""Test fixtures for NINA.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.nina.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/nina/test_config_flow.py b/tests/components/nina/test_config_flow.py index 07d3472a86997..d1f645e5534cd 100644 --- a/tests/components/nina/test_config_flow.py +++ b/tests/components/nina/test_config_flow.py @@ -5,7 +5,7 @@ from copy import deepcopy import json from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from pynina import ApiError @@ -49,9 +49,6 @@ DUMMY_RESPONSE_REGIONS: dict[str, Any] = json.loads( load_fixture("sample_regions.json", "nina") ) -DUMMY_RESPONSE_WARNIGNS: dict[str, Any] = json.loads( - load_fixture("sample_warnings.json", "nina") -) OPTIONS_ENTRY_DATA: dict[str, Any] = { CONF_FILTERS: deepcopy(DUMMY_DATA[CONF_FILTERS]), @@ -89,17 +86,11 @@ async def test_step_user_unexpected_exception(hass: HomeAssistant) -> None: assert result["reason"] == "unknown" -async def test_step_user(hass: HomeAssistant) -> None: +async def test_step_user(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test starting a flow by user with valid values.""" - with ( - patch( - "pynina.baseApi.BaseAPI._makeRequest", - wraps=mocked_request_function, - ), - patch( - "homeassistant.components.nina.async_setup_entry", - return_value=True, - ), + with patch( + "pynina.baseApi.BaseAPI._makeRequest", + wraps=mocked_request_function, ): result: dict[str, Any] = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=deepcopy(DUMMY_DATA) @@ -147,7 +138,7 @@ async def test_step_user_no_selection(hass: HomeAssistant) -> None: async def test_step_user_already_configured(hass: HomeAssistant) -> None: - """Test starting a flow by user but it was already configured.""" + """Test starting a flow by user, but it was already configured.""" with patch( "pynina.baseApi.BaseAPI._makeRequest", wraps=mocked_request_function, @@ -164,7 +155,9 @@ async def test_step_user_already_configured(hass: HomeAssistant) -> None: assert result["reason"] == "single_instance_allowed" -async def test_options_flow_init(hass: HomeAssistant) -> None: +async def test_options_flow_init( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test config flow options.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -176,7 +169,6 @@ async def test_options_flow_init(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) with ( - patch("homeassistant.components.nina.async_setup_entry", return_value=True), patch( "pynina.baseApi.BaseAPI._makeRequest", wraps=mocked_request_function, @@ -224,7 +216,9 @@ async def test_options_flow_init(hass: HomeAssistant) -> None: } -async def test_options_flow_with_no_selection(hass: HomeAssistant) -> None: +async def test_options_flow_with_no_selection( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test config flow options with no selection.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -236,7 +230,6 @@ async def test_options_flow_with_no_selection(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) with ( - patch("homeassistant.components.nina.async_setup_entry", return_value=True), patch( "pynina.baseApi.BaseAPI._makeRequest", wraps=mocked_request_function, @@ -299,7 +292,9 @@ async def test_options_flow_with_no_selection(hass: HomeAssistant) -> None: } -async def test_options_flow_connection_error(hass: HomeAssistant) -> None: +async def test_options_flow_connection_error( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test config flow options but no connection.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -310,15 +305,9 @@ async def test_options_flow_connection_error(hass: HomeAssistant) -> None: ) config_entry.add_to_hass(hass) - with ( - patch( - "pynina.baseApi.BaseAPI._makeRequest", - side_effect=ApiError("Could not connect to Api"), - ), - patch( - "homeassistant.components.nina.async_setup_entry", - return_value=True, - ), + with patch( + "pynina.baseApi.BaseAPI._makeRequest", + side_effect=ApiError("Could not connect to Api"), ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -329,7 +318,9 @@ async def test_options_flow_connection_error(hass: HomeAssistant) -> None: assert result["reason"] == "no_fetch" -async def test_options_flow_unexpected_exception(hass: HomeAssistant) -> None: +async def test_options_flow_unexpected_exception( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test config flow options but with an unexpected exception.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -345,10 +336,6 @@ async def test_options_flow_unexpected_exception(hass: HomeAssistant) -> None: "pynina.baseApi.BaseAPI._makeRequest", side_effect=Exception("DUMMY"), ), - patch( - "homeassistant.components.nina.async_setup_entry", - return_value=True, - ), ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/xbox/snapshots/test_diagnostics.ambr b/tests/components/xbox/snapshots/test_diagnostics.ambr index b385923ded876..bf34129860cb1 100644 --- a/tests/components/xbox/snapshots/test_diagnostics.ambr +++ b/tests/components/xbox/snapshots/test_diagnostics.ambr @@ -1,58 +1,51 @@ # serializer version: 1 # name: test_diagnostics dict({ - 'consoles_list': dict({ - 'agent_user_id': None, - 'result': list([ - dict({ - 'console_streaming_enabled': False, - 'console_type': 'XboxOneX', - 'digital_assistant_remote_control_enabled': True, - 'id': 'ABCDEFG', - 'name': 'XONEX', - 'power_state': 'ConnectedStandby', - 'remote_management_enabled': True, - 'storage_devices': list([ - dict({ - 'free_space_bytes': 236267835392.0, - 'is_default': True, - 'storage_device_id': '1', - 'storage_device_name': 'Internal', - 'total_space_bytes': 838592360448.0, - }), - ]), - }), - dict({ - 'console_streaming_enabled': False, - 'console_type': 'XboxOne', - 'digital_assistant_remote_control_enabled': True, - 'id': 'HIJKLMN', - 'name': 'XONE', - 'power_state': 'ConnectedStandby', - 'remote_management_enabled': True, - 'storage_devices': list([ - dict({ - 'free_space_bytes': 147163541504.0, - 'is_default': False, - 'storage_device_id': '2', - 'storage_device_name': 'Internal', - 'total_space_bytes': 391915761664.0, - }), - dict({ - 'free_space_bytes': 3200714067968.0, - 'is_default': True, - 'storage_device_id': '3', - 'storage_device_name': 'External', - 'total_space_bytes': 4000787029504.0, - }), - ]), - }), - ]), - 'status': dict({ - 'error_code': 'OK', - 'error_message': None, + 'consoles_list': list([ + dict({ + 'console_streaming_enabled': False, + 'console_type': 'XboxOneX', + 'digital_assistant_remote_control_enabled': True, + 'id': 'ABCDEFG', + 'name': 'XONEX', + 'power_state': 'ConnectedStandby', + 'remote_management_enabled': True, + 'storage_devices': list([ + dict({ + 'free_space_bytes': 236267835392.0, + 'is_default': True, + 'storage_device_id': '1', + 'storage_device_name': 'Internal', + 'total_space_bytes': 838592360448.0, + }), + ]), + }), + dict({ + 'console_streaming_enabled': False, + 'console_type': 'XboxOne', + 'digital_assistant_remote_control_enabled': True, + 'id': 'HIJKLMN', + 'name': 'XONE', + 'power_state': 'ConnectedStandby', + 'remote_management_enabled': True, + 'storage_devices': list([ + dict({ + 'free_space_bytes': 147163541504.0, + 'is_default': False, + 'storage_device_id': '2', + 'storage_device_name': 'Internal', + 'total_space_bytes': 391915761664.0, + }), + dict({ + 'free_space_bytes': 3200714067968.0, + 'is_default': True, + 'storage_device_id': '3', + 'storage_device_name': 'External', + 'total_space_bytes': 4000787029504.0, + }), + ]), }), - }), + ]), 'consoles_status': list([ dict({ 'app_details': dict({ diff --git a/tests/components/xbox/test_image.py b/tests/components/xbox/test_image.py index 7e04192636f34..b1ef2344ec8e9 100644 --- a/tests/components/xbox/test_image.py +++ b/tests/components/xbox/test_image.py @@ -115,12 +115,12 @@ async def test_load_image_from_url( "rgWHJigthrlsHCxEOMG9UGNdojCYasYt6MJHBjmxmtuAHJeo.sOkUiPmg4JHXvOS82c3UOrvdJTDaCKwCwHPJ0t0Plha8oHFC1i_o-&format=png" ).respond(status_code=HTTPStatus.OK, content_type="image/png", content=b"Test2") - freezer.tick(timedelta(seconds=15)) + freezer.tick(timedelta(seconds=30)) async_fire_time_changed(hass) await hass.async_block_till_done() assert (state := hass.states.get("image.gsr_ae_gamerpic")) - assert state.state == "2025-06-16T00:00:15+00:00" + assert state.state == "2025-06-16T00:00:30+00:00" access_token = state.attributes["access_token"] assert ( diff --git a/tests/components/xbox/test_init.py b/tests/components/xbox/test_init.py index 6a064d44e1264..752d19071876d 100644 --- a/tests/components/xbox/test_init.py +++ b/tests/components/xbox/test_init.py @@ -59,7 +59,7 @@ async def test_config_implementation_not_available( """Test implementation not available.""" config_entry.add_to_hass(hass) with patch( - "homeassistant.components.xbox.coordinator.async_get_config_entry_implementation", + "homeassistant.components.xbox.async_get_config_entry_implementation", side_effect=ImplementationUnavailableError, ): await hass.config_entries.async_setup(config_entry.entry_id)