diff --git a/homeassistant/components/collection_image/image.py b/homeassistant/components/collection_image/image.py index b138c9f0d181e..747a4b06d522a 100644 --- a/homeassistant/components/collection_image/image.py +++ b/homeassistant/components/collection_image/image.py @@ -5,9 +5,10 @@ import random from typing import override -from homeassistant.components.image import ImageEntity +from homeassistant.components.image import DEFAULT_CONTENT_TYPE, ImageEntity from homeassistant.components.media_player import ( BrowseError, + BrowseMedia, MediaClass, async_process_play_media_url, ) @@ -51,8 +52,6 @@ async def async_setup_entry( class CollectionImageImageEntity(ImageEntity): """Implement the image entity for Collection Image.""" - _unavailable_logged: bool = False - path: Path | None def __init__( @@ -69,80 +68,80 @@ def __init__( self._attr_name = name self.media_content_id = media_content_id - async def get_next_image(self) -> None: - """Update the image entity with the next image from the source media.""" - + def set_unavailable(self) -> None: + """Set the entity to unavailable state.""" + self._attr_available = False + self.path = None + self._attr_image_url = UNDEFINED self._cached_image = None + self.async_write_ha_state() - def set_unavailable() -> None: - self._unavailable_logged = True - self._attr_available = False - self.path = None - self._attr_image_url = UNDEFINED - self.async_write_ha_state() - + async def get_valid_images(self) -> list[BrowseMedia]: + """Given the configured media directory for the entity, get a list of all child images.""" try: media = await async_browse_media(self.hass, self.media_content_id) except BrowseError as err: - if not self._unavailable_logged: - _LOGGER.info("%s: %s", self.entity_id, str(err)) - set_unavailable() - return + _LOGGER.warning("%s: %s", self.entity_id, str(err)) + return [] - if media.children and ( - filtered := [ - item for item in media.children if item.media_class == MediaClass.IMAGE - ] - ): - child = random.choice(filtered) - try: - resolved = await async_resolve_media( - self.hass, child.media_content_id, self.entity_id - ) - except Unresolvable as err: - if not self._unavailable_logged: - _LOGGER.info("%s: %s", self.entity_id, str(err)) - set_unavailable() - return - - if resolved.url: - self.path = None - self._attr_image_url = async_process_play_media_url( - self.hass, resolved.url - ) - else: - self.path = resolved.path - self._attr_image_url = UNDEFINED - - self._attr_content_type = resolved.mime_type - self._attr_available = True - self._attr_image_last_updated = dt_util.utcnow() - if self._unavailable_logged: - _LOGGER.info( - "%s: Has become available again", - self.entity_id, - ) - self._unavailable_logged = False - self.async_write_ha_state() - return - - if not self._unavailable_logged: - _LOGGER.info( + images = [ + item + for item in (media.children or []) + if item.media_class == MediaClass.IMAGE + ] + if not images: + _LOGGER.warning( "%s: No valid images in %s", self.entity_id, self.media_content_id, ) - set_unavailable() - return + return images + + async def get_random_image(self) -> None: + """Update the image entity with a random image from the source media.""" + + filtered = await self.get_valid_images() + if not filtered: + self.set_unavailable() + return + + child = random.choice(filtered) + self._attr_available = True + await self.update_image(child.media_content_id) + + async def update_image(self, image_id: str): + """Update the entity from the image_id.""" + self._cached_image = None + try: + resolved = await async_resolve_media(self.hass, image_id, self.entity_id) + except Unresolvable as err: + _LOGGER.warning("%s: %s", self.entity_id, str(err)) + self._attr_image_last_updated = None + self.path = None + self._attr_image_url = UNDEFINED + self._attr_content_type = DEFAULT_CONTENT_TYPE + self.async_write_ha_state() + return + + if resolved.url: + self.path = None + self._attr_image_url = async_process_play_media_url(self.hass, resolved.url) + else: + self.path = resolved.path + self._attr_image_url = UNDEFINED + + self._attr_content_type = resolved.mime_type + self._attr_image_last_updated = dt_util.utcnow() + self.async_write_ha_state() @override async def async_added_to_hass(self) -> None: """Initialize the first image after entity has been created.""" - async def get_next_image_on_start(_hass: HomeAssistant) -> None: - await self.get_next_image() + async def get_random_image_on_start(_hass: HomeAssistant) -> None: + await self.get_random_image() - self.async_on_remove(async_at_started(self.hass, get_next_image_on_start)) + self.async_on_remove(async_at_started(self.hass, get_random_image_on_start)) @override def image(self) -> bytes | None: diff --git a/homeassistant/components/collection_image/services.py b/homeassistant/components/collection_image/services.py index 081768e2b3451..9b4e9976dd3b2 100644 --- a/homeassistant/components/collection_image/services.py +++ b/homeassistant/components/collection_image/services.py @@ -19,5 +19,5 @@ def async_setup_services(hass: HomeAssistant) -> None: SERVICE_SHUFFLE, entity_domain=IMAGE_DOMAIN, schema={}, - func="get_next_image", + func="get_random_image", ) diff --git a/homeassistant/components/duco/coordinator.py b/homeassistant/components/duco/coordinator.py index a07aa2501a13e..899ad0e5a2f96 100644 --- a/homeassistant/components/duco/coordinator.py +++ b/homeassistant/components/duco/coordinator.py @@ -10,7 +10,6 @@ DucoConnectionError, DucoError, DucoResponseError, - DucoUnsupportedCapabilityError, ) from duco_connectivity.models import ( BoardInfo, @@ -52,9 +51,6 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]): config_entry: DucoConfigEntry board_info: BoardInfo - _supports_time_filter_remain: bool - _supports_ventilation_temperatures: bool - _supports_bypass_supply_temperature_targets: bool _configured_node_names: dict[int, str] def __init__( @@ -73,9 +69,6 @@ def __init__( ) self.client = client self._configured_node_names = {} - self._supports_time_filter_remain = True - self._supports_ventilation_temperatures = True - self._supports_bypass_supply_temperature_targets = True async def _async_load_node_names(self) -> None: """Load configured Duco node names during setup.""" @@ -183,48 +176,37 @@ async def _async_update_data(self) -> DucoData: # Heat recovery info only backs the optional filter timer sensor, so # failures on this supplemental endpoint should not make the primary - # node entities unavailable. + # node entities unavailable. A None result leaves the sensor absent + # but keeps the helper pollable so data can appear on a later refresh. time_filter_remain = None - if self._supports_time_filter_remain: - with suppress(DucoError): - time_filter_remain = await self.client.async_get_time_filter_remaining() - self._supports_time_filter_remain = time_filter_remain is not None + with suppress(DucoError): + time_filter_remain = await self.client.async_get_time_filter_remaining() ventilation_temperatures = ( self.data.ventilation_temperatures if self.data else None ) - if self._supports_ventilation_temperatures: - try: - ventilation_temperatures = ( - await self.client.async_get_ventilation_temperature_info() - ) - except DucoUnsupportedCapabilityError: - ventilation_temperatures = None - self._supports_ventilation_temperatures = False - except DucoError as err: - _LOGGER.debug( - "Could not fetch Duco ventilation temperatures", exc_info=err - ) + try: + ventilation_temperatures = ( + await self.client.async_get_ventilation_temperature_info() + ) + except DucoError as err: + _LOGGER.debug("Could not fetch Duco ventilation temperatures", exc_info=err) bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget] = {} - if self._supports_bypass_supply_temperature_targets: - try: - bypass_supply_temperature_targets = ( - await self.client.async_get_bypass_supply_temperature_targets() - ) - except DucoUnsupportedCapabilityError: - bypass_supply_temperature_targets = {} - self._supports_bypass_supply_temperature_targets = False - except DucoConnectionError as err: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="cannot_connect", - ) from err - except DucoError as err: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="api_error", - ) from err + try: + bypass_supply_temperature_targets = ( + await self.client.async_get_bypass_supply_temperature_targets() + ) + except DucoConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + except DucoError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="api_error", + ) from err return DucoData( nodes={node.node_id: node for node in nodes}, diff --git a/homeassistant/components/duco/manifest.json b/homeassistant/components/duco/manifest.json index 5588f2f06c049..e38fcba8e3411 100644 --- a/homeassistant/components/duco/manifest.json +++ b/homeassistant/components/duco/manifest.json @@ -13,7 +13,7 @@ "iot_class": "local_polling", "loggers": ["duco_connectivity"], "quality_scale": "platinum", - "requirements": ["python-duco-connectivity==0.13.1"], + "requirements": ["python-duco-connectivity==0.14.0"], "zeroconf": [ { "name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*", diff --git a/homeassistant/components/duco/number.py b/homeassistant/components/duco/number.py index c513aabd1a6a0..dcb87784a45a7 100644 --- a/homeassistant/components/duco/number.py +++ b/homeassistant/components/duco/number.py @@ -1,6 +1,5 @@ """Number platform for the Duco integration.""" -from decimal import ROUND_DOWN, ROUND_HALF_UP, Decimal import logging from typing import override @@ -56,14 +55,6 @@ def _async_add_new_entities() -> None: if (description.key, zone_id) in known_entities: continue - # Skip incomplete metadata because guessing valid limits would expose an invalid control. - if ( - target.minimum is None - or target.maximum is None - or target.increment is None - ): - continue - known_entities.add((description.key, zone_id)) new_entities.append( DucoBypassSupplyTemperatureTargetNumber( @@ -130,32 +121,18 @@ def native_value(self) -> float | None: ) return target.value if target else None - def _normalize_step_value(self, value: float) -> float: - """Normalize converted temperature values to the nearest supported native step.""" - if self.unit_of_measurement == self.native_unit_of_measurement: - return value - - # Home Assistant converts service values from the configured temperature - # unit first, which can land between valid Duco Celsius increments. - minimum = Decimal(str(self.native_min_value)) - step = Decimal(str(self.native_step)) - steps = ((Decimal(str(value)) - minimum) / step).to_integral_value( - rounding=ROUND_HALF_UP - ) - # Rounding up may overshoot when the range is not a whole number of steps. - max_steps = ( - (Decimal(str(self.native_max_value)) - minimum) / step - ).to_integral_value(rounding=ROUND_DOWN) - return float(minimum + (min(steps, max_steps) * step)) - @override async def async_set_native_value(self, value: float) -> None: """Set the bypass supply temperature target.""" - value = self._normalize_step_value(value) - if ( - (Decimal(str(value)) - Decimal(str(self.native_min_value))) - / Decimal(str(self.native_step)) - ) % 1 != 0: + target = self.coordinator.data.bypass_supply_temperature_targets[self._zone_id] + + try: + if self.unit_of_measurement != self.native_unit_of_measurement: + value = target.normalize_value(value) + await self.coordinator.client.async_set_bypass_supply_temperature_target( + self._zone_id, value, target=target + ) + except ValueError as err: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="invalid_bypass_supply_temperature_target_step", @@ -164,12 +141,7 @@ async def async_set_native_value(self, value: float) -> None: "minimum": str(self.native_min_value), "increment": str(self.native_step), }, - ) - - try: - await self.coordinator.client.async_set_bypass_supply_temperature_target( - self._zone_id, value - ) + ) from err except DucoRateLimitError as err: _LOGGER.warning( "Duco write rate limit exceeded for bypass target zone %s", diff --git a/homeassistant/components/hdfury/manifest.json b/homeassistant/components/hdfury/manifest.json index 093a475fbc05c..7c00b96fb31a0 100644 --- a/homeassistant/components/hdfury/manifest.json +++ b/homeassistant/components/hdfury/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["hdfury==1.6.0"], + "requirements": ["hdfury==1.6.1"], "zeroconf": [ { "name": "diva-*", "type": "_http._tcp.local." }, { "name": "vertex2-*", "type": "_http._tcp.local." }, diff --git a/homeassistant/components/imou/config_flow.py b/homeassistant/components/imou/config_flow.py index a00b7bf5ffe8f..8eddc540357d0 100644 --- a/homeassistant/components/imou/config_flow.py +++ b/homeassistant/components/imou/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Imou.""" +from collections.abc import Mapping import logging from typing import Any, override @@ -17,12 +18,26 @@ SelectSelector, SelectSelectorConfig, SelectSelectorMode, + TextSelector, + TextSelectorConfig, + TextSelectorType, ) from .const import API_URLS, CONF_API_URL, CONF_APP_ID, CONF_APP_SECRET, DOMAIN _LOGGER = logging.getLogger(__name__) +REAUTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_APP_SECRET): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + } +) + class ImouConfigFlow(ConfigFlow, domain=DOMAIN): """Config flow for Imou integration.""" @@ -30,6 +45,27 @@ class ImouConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 MINOR_VERSION = 1 + async def _validate_input(self, user_input: dict[str, Any]) -> dict[str, str]: + """Validate credentials and close the temporary client.""" + errors: dict[str, str] = {} + api_client = ImouOpenApiClient( + user_input[CONF_APP_ID], + user_input[CONF_APP_SECRET], + API_URLS[user_input[CONF_API_URL]], + ) + try: + await api_client.async_get_token() + except InvalidAppIdOrSecretException: + errors["base"] = "invalid_auth" + except ConnectFailedException, RequestFailedException: + errors["base"] = "cannot_connect" + except ImouException as exception: + _LOGGER.debug("Imou error during config flow: %s", exception) + errors["base"] = "unknown" + finally: + await api_client.async_close() + return errors + @override async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -39,21 +75,7 @@ async def async_step_user( if user_input is not None: await self.async_set_unique_id(user_input[CONF_APP_ID]) self._abort_if_unique_id_configured() - api_client = ImouOpenApiClient( - user_input[CONF_APP_ID], - user_input[CONF_APP_SECRET], - API_URLS[user_input[CONF_API_URL]], - ) - try: - await api_client.async_get_token() - except InvalidAppIdOrSecretException: - errors["base"] = "invalid_auth" - except ConnectFailedException, RequestFailedException: - errors["base"] = "cannot_connect" - except ImouException as exception: - _LOGGER.debug("Imou error during config flow: %s", exception) - errors["base"] = "unknown" - else: + if not (errors := await self._validate_input(user_input)): return self.async_create_entry( title="Imou", data={ @@ -79,3 +101,38 @@ async def async_step_user( ), errors=errors, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauthentication upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauthentication with a new App secret.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + if user_input is not None: + if not ( + errors := await self._validate_input( + { + CONF_APP_ID: reauth_entry.data[CONF_APP_ID], + CONF_APP_SECRET: user_input[CONF_APP_SECRET], + CONF_API_URL: reauth_entry.data[CONF_API_URL], + } + ) + ): + await self.async_set_unique_id(reauth_entry.data[CONF_APP_ID]) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + reauth_entry, + data_updates={CONF_APP_SECRET: user_input[CONF_APP_SECRET]}, + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=REAUTH_SCHEMA, + description_placeholders={"app_id": reauth_entry.data[CONF_APP_ID]}, + errors=errors, + ) diff --git a/homeassistant/components/imou/coordinator.py b/homeassistant/components/imou/coordinator.py index 01b09cc344b2d..883cb89c9f3c0 100644 --- a/homeassistant/components/imou/coordinator.py +++ b/homeassistant/components/imou/coordinator.py @@ -6,11 +6,12 @@ import logging from typing import override -from pyimouapi.exceptions import ImouException +from pyimouapi.exceptions import ImouException, InvalidAppIdOrSecretException from pyimouapi.ha_device import ImouHaDevice, ImouHaDeviceManager from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -83,6 +84,11 @@ async def _async_update_data(self) -> None: fresh_devices = await self._device_manager.async_get_devices() except TimeoutError as err: raise UpdateFailed(f"Timeout while fetching data: {err}") from err + except InvalidAppIdOrSecretException as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err except ImouException as err: raise UpdateFailed(f"Error fetching Imou devices: {err}") from err diff --git a/homeassistant/components/imou/quality_scale.yaml b/homeassistant/components/imou/quality_scale.yaml index 21e8faea2146a..a9c86bee01c3e 100644 --- a/homeassistant/components/imou/quality_scale.yaml +++ b/homeassistant/components/imou/quality_scale.yaml @@ -40,7 +40,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: todo # Gold diff --git a/homeassistant/components/imou/strings.json b/homeassistant/components/imou/strings.json index 889a603f56b1e..c9834ce970690 100644 --- a/homeassistant/components/imou/strings.json +++ b/homeassistant/components/imou/strings.json @@ -2,7 +2,9 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unique_id_mismatch": "The App ID does not match the previously configured account." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -10,6 +12,16 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reauth_confirm": { + "data": { + "app_secret": "[%key:component::imou::config::step::user::data::app_secret%]" + }, + "data_description": { + "app_secret": "[%key:component::imou::config::step::user::data_description::app_secret%]" + }, + "description": "Enter a new App secret for {app_id}.", + "title": "[%key:common::config_flow::title::reauth%]" + }, "user": { "data": { "api_url": "Server region", @@ -131,6 +143,9 @@ "get_stream_failed": { "message": "Could not get the live stream URL from Imou: {error}" }, + "invalid_auth": { + "message": "Imou rejected the App ID and App secret" + }, "press_button_failed": { "message": "Imou rejected the button press: {error}" }, diff --git a/homeassistant/components/lg_infrared/button.py b/homeassistant/components/lg_infrared/button.py index 26591353e8cea..7d938d860f25c 100644 --- a/homeassistant/components/lg_infrared/button.py +++ b/homeassistant/components/lg_infrared/button.py @@ -3,11 +3,13 @@ from dataclasses import dataclass from typing import override +from infrared_protocols.codes.lg.ac import LGACCode from infrared_protocols.codes.lg.tv import LGTVCode from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.components.infrared import InfraredEmitterConsumerEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -21,7 +23,7 @@ class LgIrButtonEntityDescription(ButtonEntityDescription): """Describes LG IR button entity.""" - command_code: LGTVCode + command_code: LGTVCode | LGACCode TV_BUTTON_DESCRIPTIONS: tuple[LgIrButtonEntityDescription, ...] = ( @@ -114,6 +116,70 @@ class LgIrButtonEntityDescription(ButtonEntityDescription): ), ) +# One-shot AC actions with no discrete on/off code, so each is a momentary button. +AC_BUTTON_DESCRIPTIONS: tuple[LgIrButtonEntityDescription, ...] = ( + LgIrButtonEntityDescription( + key="jet", translation_key="jet", command_code=LGACCode.JET + ), + LgIrButtonEntityDescription( + key="eco", translation_key="eco", command_code=LGACCode.ECO + ), + # A separate boost from JET, only offered on LG India units, so it is + # disabled by default. + LgIrButtonEntityDescription( + key="viraat", + translation_key="viraat", + command_code=LGACCode.VIRAAT, + entity_registry_enabled_default=False, + ), + LgIrButtonEntityDescription( + key="ai_convertible", + translation_key="ai_convertible", + command_code=LGACCode.AI_CONVERTIBLE, + ), + LgIrButtonEntityDescription( + key="light", + translation_key="light", + command_code=LGACCode.LIGHT_TOGGLE, + entity_category=EntityCategory.CONFIG, + ), + LgIrButtonEntityDescription( + key="wifi", + translation_key="wifi", + command_code=LGACCode.WIFI_TOGGLE, + entity_category=EntityCategory.CONFIG, + ), + LgIrButtonEntityDescription( + key="audio", + translation_key="audio", + command_code=LGACCode.AUDIO_TOGGLE, + entity_category=EntityCategory.CONFIG, + ), + LgIrButtonEntityDescription( + key="diagnose", + translation_key="diagnose", + command_code=LGACCode.DIAGNOSE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + # Only flips between auto-swing and off; the climate swing dropdown supersedes it, + # so it is kept for older units but disabled by default. + LgIrButtonEntityDescription( + key="swing_v_toggle", + translation_key="swing_v_toggle", + command_code=LGACCode.SWING_V_TOGGLE, + entity_registry_enabled_default=False, + ), +) + +_DEVICE_BUTTONS: dict[LGDeviceType, tuple[LgIrButtonEntityDescription, ...]] = { + LGDeviceType.TV: TV_BUTTON_DESCRIPTIONS, + LGDeviceType.AC: AC_BUTTON_DESCRIPTIONS, +} +_DEVICE_NAMES: dict[LGDeviceType, str] = { + LGDeviceType.TV: "LG TV", + LGDeviceType.AC: "LG AC", +} + async def async_setup_entry( hass: HomeAssistant, @@ -125,11 +191,11 @@ async def async_setup_entry( return device_type = entry.data[CONF_DEVICE_TYPE] - if device_type == LGDeviceType.TV: - async_add_entities( - LgIrButton(entry, infrared_entity_id, description) - for description in TV_BUTTON_DESCRIPTIONS - ) + device_name = _DEVICE_NAMES[device_type] + async_add_entities( + LgIrButton(entry, infrared_entity_id, description, device_name) + for description in _DEVICE_BUTTONS[device_type] + ) class LgIrButton(LgIrEntity, InfraredEmitterConsumerEntity, ButtonEntity): @@ -142,9 +208,12 @@ def __init__( entry: ConfigEntry, infrared_entity_id: str, description: LgIrButtonEntityDescription, + device_name: str, ) -> None: """Initialize LG IR button.""" - super().__init__(entry, unique_id_suffix=description.key) + super().__init__( + entry, unique_id_suffix=description.key, device_name=device_name + ) self._infrared_emitter_entity_id = infrared_entity_id self.entity_description = description diff --git a/homeassistant/components/lg_infrared/icons.json b/homeassistant/components/lg_infrared/icons.json index 5ca08923f9ee8..785708909fd26 100644 --- a/homeassistant/components/lg_infrared/icons.json +++ b/homeassistant/components/lg_infrared/icons.json @@ -1,12 +1,24 @@ { "entity": { "button": { + "ai_convertible": { + "default": "mdi:brain" + }, + "audio": { + "default": "mdi:volume-high" + }, "back": { "default": "mdi:keyboard-backspace" }, + "diagnose": { + "default": "mdi:stethoscope" + }, "down": { "default": "mdi:arrow-down" }, + "eco": { + "default": "mdi:leaf" + }, "exit": { "default": "mdi:exit-to-app" }, @@ -34,9 +46,15 @@ "input": { "default": "mdi:import" }, + "jet": { + "default": "mdi:fan-chevron-up" + }, "left": { "default": "mdi:arrow-left" }, + "light": { + "default": "mdi:led-on" + }, "menu": { "default": "mdi:menu" }, @@ -85,8 +103,17 @@ "right": { "default": "mdi:arrow-right" }, + "swing_v_toggle": { + "default": "mdi:arrow-up-down" + }, "up": { "default": "mdi:arrow-up" + }, + "viraat": { + "default": "mdi:rocket-launch" + }, + "wifi": { + "default": "mdi:wifi" } }, "climate": { diff --git a/homeassistant/components/lg_infrared/quality_scale.yaml b/homeassistant/components/lg_infrared/quality_scale.yaml index 90bc25e6bad0e..237bc59de7e67 100644 --- a/homeassistant/components/lg_infrared/quality_scale.yaml +++ b/homeassistant/components/lg_infrared/quality_scale.yaml @@ -84,10 +84,7 @@ rules: Each config entry creates a single device. entity-category: done entity-device-class: done - entity-disabled-by-default: - status: exempt - comment: | - No entities should be disabled by default. + entity-disabled-by-default: done entity-translations: done exception-translations: status: exempt diff --git a/homeassistant/components/lg_infrared/strings.json b/homeassistant/components/lg_infrared/strings.json index f9fbc8740ff71..49b6a3c9b822c 100644 --- a/homeassistant/components/lg_infrared/strings.json +++ b/homeassistant/components/lg_infrared/strings.json @@ -47,12 +47,24 @@ }, "entity": { "button": { + "ai_convertible": { + "name": "AI mode" + }, + "audio": { + "name": "Toggle beep" + }, "back": { "name": "[%key:common::entity::button::back::name%]" }, + "diagnose": { + "name": "Diagnose" + }, "down": { "name": "[%key:common::entity::button::down::name%]" }, + "eco": { + "name": "Eco mode" + }, "exit": { "name": "[%key:common::entity::button::exit::name%]" }, @@ -80,9 +92,15 @@ "input": { "name": "[%key:common::entity::button::input::name%]" }, + "jet": { + "name": "Jet mode" + }, "left": { "name": "[%key:common::entity::button::left::name%]" }, + "light": { + "name": "Toggle light" + }, "menu": { "name": "[%key:common::entity::button::menu::name%]" }, @@ -131,8 +149,17 @@ "right": { "name": "[%key:common::entity::button::right::name%]" }, + "swing_v_toggle": { + "name": "Toggle vertical swing" + }, "up": { "name": "[%key:common::entity::button::up::name%]" + }, + "viraat": { + "name": "Viraat mode" + }, + "wifi": { + "name": "Start Wi-Fi pairing" } }, "climate": { diff --git a/homeassistant/components/microbees/light.py b/homeassistant/components/microbees/light.py index 2985e9664c2ae..a771c69388c27 100644 --- a/homeassistant/components/microbees/light.py +++ b/homeassistant/components/microbees/light.py @@ -30,6 +30,7 @@ async def async_setup_entry( class MBLight(MicroBeesActuatorEntity, LightEntity): """Representation of a microBees light.""" + _attr_color_mode = ColorMode.RGBW _attr_supported_color_modes = {ColorMode.RGBW} def __init__( diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index 53a512040e56d..131dafff7c704 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -514,8 +514,8 @@ class DishWasherProgramId(MieleEnum, missing_to_none=True): pasta_paela = 14 tall_items = 17, 42 glasses_warm = 19 - quick_intense = 21 - normal = 23, 30, 217 + quick_intense = 21, 46 + normal = 23, 30, 48, 217 pre_wash = 24 pot_rests_and_filters = 25 power_wash = 44, 204 diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 01a80d163d8a5..a43656ced570f 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -108,6 +108,33 @@ # UNKNOWN is intentionally not mapped - will return None } +MASS_ICON_TO_MDI: Mapping[str, str] = { + "bluetooth": "mdi:bluetooth", + "car": "mdi:car", + "cast": "mdi:cast", + "headphones": "mdi:headphones", + "laptop": "mdi:laptop", + "monitor": "mdi:monitor", + "radio": "mdi:radio", + "smartphone": "mdi:cellphone", + "soundbar": "mdi:soundbar", + "speaker": "mdi:speaker", + "speakers": "mdi:speaker-multiple", + "sun": "mdi:white-balance-sunny", + "tablet": "mdi:tablet", + "tv": "mdi:television", + "vinyl": "mdi:record-player", +} + + +def _get_mdi_icon(icon: str) -> str: + """Return an MDI icon for a Music Assistant icon.""" + if icon.startswith("mdi:"): + return icon + if icon.startswith("mdi-"): + return icon.replace("mdi-", "mdi:", 1) + return MASS_ICON_TO_MDI.get(icon, "mdi:speaker") + async def async_setup_entry( hass: HomeAssistant, @@ -136,7 +163,7 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): def __init__(self, mass: MusicAssistantClient, player_id: str) -> None: """Initialize MediaPlayer entity.""" super().__init__(mass, player_id) - self._attr_icon = self.player.icon.replace("mdi-", "mdi:") + self._attr_icon = _get_mdi_icon(self.player.icon) self._set_supported_features() self._attr_device_class = MediaPlayerDeviceClass.SPEAKER self._source_list_mapping: dict[str, str] = {} diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index e9c17bf5a286e..cd7676ac369ec 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -1,35 +1,23 @@ """Connect to a MySensors gateway via pymysensors API.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern -from collections.abc import Callable, Mapping +from collections.abc import Mapping import logging -from mysensors import BaseAsyncGateway - -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import AnyDeviceEntry, DeviceEntry +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ( - ATTR_DEVICES, - DOMAIN, - MYSENSORS_DISCOVERED_NODES, - MYSENSORS_GATEWAYS, - PLATFORMS, - DevId, - DiscoveryInfo, - SensorType, -) -from .entity import MySensorsChildEntity, get_mysensors_devices +from .const import ATTR_DEVICES, DOMAIN, PLATFORMS, DevId, DiscoveryInfo, SensorType +from .entity import MySensorsChildEntity from .gateway import finish_setup, gw_stop, setup_gateway +from .helpers import remove_node_dev_ids +from .models import MySensorsConfigEntry, MySensorsData _LOGGER = logging.getLogger(__name__) -DATA_HASS_CONFIG = "hass_config" - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: MySensorsConfigEntry) -> bool: """Set up an instance of the MySensors integration. Every instance has a connection to exactly one Gateway. @@ -40,43 +28,34 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Gateway setup failed for %s", entry.data) return False - mysensors_data = hass.data.setdefault(DOMAIN, {}) - if MYSENSORS_GATEWAYS not in mysensors_data: - mysensors_data[MYSENSORS_GATEWAYS] = {} - mysensors_data[MYSENSORS_GATEWAYS][entry.entry_id] = gateway + entry.runtime_data = MySensorsData(gateway=gateway) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - await finish_setup(hass, entry, gateway) + await finish_setup(hass, entry) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: MySensorsConfigEntry) -> bool: """Remove an instance of the MySensors integration.""" - - gateway: BaseAsyncGateway = hass.data[DOMAIN][MYSENSORS_GATEWAYS][entry.entry_id] - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if not unload_ok: return False - del hass.data[DOMAIN][MYSENSORS_GATEWAYS][entry.entry_id] - hass.data[DOMAIN].pop(MYSENSORS_DISCOVERED_NODES.format(entry.entry_id), None) - - await gw_stop(hass, entry, gateway) + await gw_stop(entry) return True async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: AnyDeviceEntry + hass: HomeAssistant, + config_entry: MySensorsConfigEntry, + device_entry: AnyDeviceEntry, ) -> bool: """Remove a MySensors config entry from a device.""" if not isinstance(device_entry, DeviceEntry): # This integration does not create child devices. return False - gateway: BaseAsyncGateway = hass.data[DOMAIN][MYSENSORS_GATEWAYS][ - config_entry.entry_id - ] + gateway = config_entry.runtime_data.gateway device_id = next( device_id for domain, device_id in device_entry.identifiers if domain == DOMAIN ) @@ -85,51 +64,35 @@ async def async_remove_config_entry_device( gateway.tasks.persistence.need_save = True # remove node from discovered nodes - hass.data[DOMAIN].setdefault( - MYSENSORS_DISCOVERED_NODES.format(config_entry.entry_id), set() - ).remove(node_id) + config_entry.runtime_data.discovered_nodes.discard(node_id) + remove_node_dev_ids(config_entry, node_id) return True @callback def setup_mysensors_platform( - hass: HomeAssistant, + config_entry: MySensorsConfigEntry, domain: Platform, # hass platform name discovery_info: DiscoveryInfo, device_class: type[MySensorsChildEntity] | Mapping[SensorType, type[MySensorsChildEntity]], - device_args: ( - tuple | None - ) = None, # extra arguments that will be given to the entity constructor - async_add_entities: Callable | None = None, -) -> list[MySensorsChildEntity] | None: - """Set up a MySensors platform. - - Sets up a bunch of instances of a single platform that is supported by this - integration. - - The function is given a list of device ids, each one describing an instance - to set up. The function is also given a class. - - A new instance of the class is created for every device id, and the device - id is given to the constructor of the class. - """ - if device_args is None: - device_args = () + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up entities for newly discovered devices on a MySensors platform.""" new_devices: list[MySensorsChildEntity] = [] new_dev_ids: list[DevId] = discovery_info[ATTR_DEVICES] + dev_ids = config_entry.runtime_data.discovered_dev_ids[domain] + gateway = config_entry.runtime_data.gateway for dev_id in new_dev_ids: - devices: dict[DevId, MySensorsChildEntity] = get_mysensors_devices(hass, domain) - if dev_id in devices: + if dev_id in dev_ids: _LOGGER.debug( "Skipping setup of %s for platform %s as it already exists", dev_id, domain, ) continue - gateway_id, node_id, child_id, value_type = dev_id - gateway: BaseAsyncGateway = hass.data[DOMAIN][MYSENSORS_GATEWAYS][gateway_id] + _gateway_id, node_id, child_id, value_type = dev_id if isinstance(device_class, dict): child = gateway.sensors[node_id].children[child_id] @@ -138,11 +101,10 @@ def setup_mysensors_platform( else: device_class_copy = device_class - args_copy = (*device_args, gateway_id, gateway, node_id, child_id, value_type) - devices[dev_id] = device_class_copy(*args_copy) - new_devices.append(devices[dev_id]) + dev_ids.add(dev_id) + new_devices.append( + device_class_copy(config_entry, node_id, child_id, value_type) + ) if new_devices: _LOGGER.debug("Adding new devices: %s", new_devices) - if async_add_entities is not None: - async_add_entities(new_devices) - return new_devices + async_add_entities(new_devices) diff --git a/homeassistant/components/mysensors/binary_sensor.py b/homeassistant/components/mysensors/binary_sensor.py index 1ee62af51ca37..05ceb3270b85d 100644 --- a/homeassistant/components/mysensors/binary_sensor.py +++ b/homeassistant/components/mysensors/binary_sensor.py @@ -9,7 +9,6 @@ BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -18,6 +17,7 @@ from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .entity import MySensorsChildEntity +from .models import MySensorsConfigEntry @dataclass(frozen=True) @@ -67,7 +67,7 @@ class MySensorsBinarySensorDescription(BinarySensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MySensorsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" @@ -76,11 +76,11 @@ async def async_setup_entry( def async_discover(discovery_info: DiscoveryInfo) -> None: """Discover and add a MySensors binary_sensor.""" setup_mysensors_platform( - hass, + config_entry, Platform.BINARY_SENSOR, discovery_info, MySensorsBinarySensor, - async_add_entities=async_add_entities, + async_add_entities, ) config_entry.async_on_unload( diff --git a/homeassistant/components/mysensors/climate.py b/homeassistant/components/mysensors/climate.py index a5759ef7633d9..2014c8b94361d 100644 --- a/homeassistant/components/mysensors/climate.py +++ b/homeassistant/components/mysensors/climate.py @@ -9,7 +9,6 @@ ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -19,6 +18,7 @@ from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .entity import MySensorsChildEntity +from .models import MySensorsConfigEntry DICT_HA_TO_MYS = { HVACMode.AUTO: "AutoChangeOver", @@ -39,7 +39,7 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MySensorsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" @@ -47,11 +47,11 @@ async def async_setup_entry( async def async_discover(discovery_info: DiscoveryInfo) -> None: """Discover and add a MySensors climate.""" setup_mysensors_platform( - hass, + config_entry, Platform.CLIMATE, discovery_info, MySensorsHVAC, - async_add_entities=async_add_entities, + async_add_entities, ) config_entry.async_on_unload( diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index 8093bd92a9dbe..38f5c0a9cc051 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -23,9 +23,6 @@ CONF_GATEWAY_TYPE_MQTT: ConfGatewayType = "MQTT" DOMAIN: Final = "mysensors" -MYSENSORS_GATEWAY_START_TASK: str = "mysensors_gateway_start_task_{}" -MYSENSORS_GATEWAYS: Final = "mysensors_gateways" -MYSENSORS_DISCOVERED_NODES: Final = "mysensors_discovered_nodes_{}" PLATFORM: Final = "platform" SCHEMA: Final = "schema" CHILD_CALLBACK: str = "mysensors_child_callback_{}_{}_{}_{}" diff --git a/homeassistant/components/mysensors/cover.py b/homeassistant/components/mysensors/cover.py index 2b505d19aba7a..e6cc065c09cc3 100644 --- a/homeassistant/components/mysensors/cover.py +++ b/homeassistant/components/mysensors/cover.py @@ -8,7 +8,6 @@ ATTR_TILT_POSITION, CoverEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -17,6 +16,7 @@ from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .entity import MySensorsChildEntity +from .models import MySensorsConfigEntry @unique @@ -31,7 +31,7 @@ class CoverState(Enum): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MySensorsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" @@ -39,11 +39,11 @@ async def async_setup_entry( async def async_discover(discovery_info: DiscoveryInfo) -> None: """Discover and add a MySensors cover.""" setup_mysensors_platform( - hass, + config_entry, Platform.COVER, discovery_info, MySensorsCover, - async_add_entities=async_add_entities, + async_add_entities, ) config_entry.async_on_unload( diff --git a/homeassistant/components/mysensors/device_tracker.py b/homeassistant/components/mysensors/device_tracker.py index af2a5a00ca056..f195becbe07bb 100644 --- a/homeassistant/components/mysensors/device_tracker.py +++ b/homeassistant/components/mysensors/device_tracker.py @@ -3,7 +3,6 @@ from typing import override from homeassistant.components.device_tracker import TrackerEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -12,11 +11,12 @@ from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .entity import MySensorsChildEntity +from .models import MySensorsConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MySensorsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" @@ -25,11 +25,11 @@ async def async_setup_entry( def async_discover(discovery_info: DiscoveryInfo) -> None: """Discover and add a MySensors device tracker.""" setup_mysensors_platform( - hass, + config_entry, Platform.DEVICE_TRACKER, discovery_info, MySensorsDeviceTracker, - async_add_entities=async_add_entities, + async_add_entities, ) config_entry.async_on_unload( diff --git a/homeassistant/components/mysensors/entity.py b/homeassistant/components/mysensors/entity.py index ce15845552052..ffa9a57a3f730 100644 --- a/homeassistant/components/mysensors/entity.py +++ b/homeassistant/components/mysensors/entity.py @@ -1,5 +1,4 @@ """Handle MySensors devices.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from abc import abstractmethod import logging @@ -8,28 +7,15 @@ from mysensors import BaseAsyncGateway, Sensor from mysensors.sensor import ChildSensor -from homeassistant.const import ( - ATTR_BATTERY_LEVEL, - CONF_DEVICE, - STATE_OFF, - STATE_ON, - Platform, -) +from homeassistant.const import ATTR_BATTERY_LEVEL, CONF_DEVICE, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from .const import ( - CHILD_CALLBACK, - DOMAIN, - NODE_CALLBACK, - PLATFORM_TYPES, - UPDATE_DELAY, - DevId, - GatewayId, -) +from .const import CHILD_CALLBACK, DOMAIN, NODE_CALLBACK, UPDATE_DELAY, DevId, GatewayId +from .models import MySensorsConfigEntry _LOGGER = logging.getLogger(__name__) @@ -38,7 +24,6 @@ ATTR_DEVICE = "device" ATTR_NODE_ID = "node_id" ATTR_HEARTBEAT = "heartbeat" -MYSENSORS_PLATFORM_DEVICES = "mysensors_devices_{}" class MySensorNodeEntity(Entity): @@ -46,12 +31,11 @@ class MySensorNodeEntity(Entity): hass: HomeAssistant - def __init__( - self, gateway_id: GatewayId, gateway: BaseAsyncGateway, node_id: int - ) -> None: + def __init__(self, config_entry: MySensorsConfigEntry, node_id: int) -> None: """Set up the MySensors node entity.""" - self.gateway_id: GatewayId = gateway_id - self.gateway: BaseAsyncGateway = gateway + self.config_entry = config_entry + self.gateway_id: GatewayId = config_entry.entry_id + self.gateway: BaseAsyncGateway = config_entry.runtime_data.gateway self.node_id: int = node_id self._debouncer: Debouncer | None = None @@ -136,18 +120,6 @@ async def async_added_to_hass(self) -> None: self._async_update_callback() -def get_mysensors_devices( - hass: HomeAssistant, domain: Platform -) -> dict[DevId, MySensorsChildEntity]: - """Return MySensors devices for a hass platform name.""" - if MYSENSORS_PLATFORM_DEVICES.format(domain) not in hass.data[DOMAIN]: - hass.data[DOMAIN][MYSENSORS_PLATFORM_DEVICES.format(domain)] = {} - devices: dict[DevId, MySensorsChildEntity] = hass.data[DOMAIN][ - MYSENSORS_PLATFORM_DEVICES.format(domain) - ] - return devices - - class MySensorsChildEntity(MySensorNodeEntity): """Representation of a MySensors entity.""" @@ -155,14 +127,13 @@ class MySensorsChildEntity(MySensorNodeEntity): def __init__( self, - gateway_id: GatewayId, - gateway: BaseAsyncGateway, + config_entry: MySensorsConfigEntry, node_id: int, child_id: int, value_type: int, ) -> None: """Set up the MySensors child entity.""" - super().__init__(gateway_id, gateway, node_id) + super().__init__(config_entry, node_id) self.child_id: int = child_id # value_type as int. string variant can be looked up in gateway consts self.value_type: int = value_type @@ -197,17 +168,6 @@ def name(self) -> str: return str(child.description) return f"{self.node_name} {self.child_id}" - @override - async def async_will_remove_from_hass(self) -> None: - """Remove this entity from home assistant.""" - for platform in PLATFORM_TYPES: - platform_str = MYSENSORS_PLATFORM_DEVICES.format(platform) - if platform_str in self.hass.data[DOMAIN]: - platform_dict = self.hass.data[DOMAIN][platform_str] - if self.dev_id in platform_dict: - del platform_dict[self.dev_id] - _LOGGER.debug("Deleted %s from platform %s", self.dev_id, platform) - @property @override def available(self) -> bool: @@ -220,8 +180,7 @@ def extra_state_attributes(self) -> dict[str, Any]: """Return entity and device specific state attributes.""" attr = super().extra_state_attributes - assert self.platform.config_entry - attr[ATTR_DEVICE] = self.platform.config_entry.data[CONF_DEVICE] + attr[ATTR_DEVICE] = self.config_entry.data[CONF_DEVICE] attr[ATTR_CHILD_ID] = self.child_id attr[ATTR_DESCRIPTION] = self._child.description diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index a6c18f2a24907..466826d75d7bb 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -17,7 +17,6 @@ async_publish, async_subscribe, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv @@ -36,10 +35,7 @@ CONF_TOPIC_IN_PREFIX, CONF_TOPIC_OUT_PREFIX, CONF_VERSION, - DOMAIN, - MYSENSORS_GATEWAY_START_TASK, ConfGatewayType, - GatewayId, ) from .handler import HANDLERS from .helpers import ( @@ -48,6 +44,7 @@ validate_child, validate_node, ) +from .models import MySensorsConfigEntry _LOGGER = logging.getLogger(__name__) @@ -123,7 +120,7 @@ def on_conn_made(_: BaseAsyncGateway) -> None: async def setup_gateway( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: MySensorsConfigEntry ) -> BaseAsyncGateway | None: """Set up the Gateway for the given ConfigEntry.""" @@ -132,7 +129,7 @@ async def setup_gateway( gateway_type=entry.data[CONF_GATEWAY_TYPE], device=entry.data[CONF_DEVICE], version=entry.data[CONF_VERSION], - event_callback=_gw_callback_factory(hass, entry.entry_id), + event_callback=_gw_callback_factory(hass, entry), persistence_file=entry.data.get( CONF_PERSISTENCE_FILE, f"mysensors_{entry.entry_id}.json" ), @@ -231,23 +228,22 @@ def internal_callback(msg: MQTTReceiveMessage) -> None: return gateway -async def finish_setup( - hass: HomeAssistant, entry: ConfigEntry, gateway: BaseAsyncGateway -) -> None: +async def finish_setup(hass: HomeAssistant, entry: MySensorsConfigEntry) -> None: """Load any persistent devices and platforms and start gateway.""" - await _discover_persistent_devices(hass, entry, gateway) - await _gw_start(hass, entry, gateway) + await _discover_persistent_devices(hass, entry) + await _gw_start(hass, entry) async def _discover_persistent_devices( - hass: HomeAssistant, entry: ConfigEntry, gateway: BaseAsyncGateway + hass: HomeAssistant, entry: MySensorsConfigEntry ) -> None: """Discover platforms for devices loaded via persistence file.""" + gateway = entry.runtime_data.gateway new_devices = defaultdict(list) for node_id in gateway.sensors: if not validate_node(gateway, node_id): continue - discover_mysensors_node(hass, entry.entry_id, node_id) + discover_mysensors_node(hass, entry, node_id) node: Sensor = gateway.sensors[node_id] for child in node.children.values(): # child is of type ChildSensor validated = validate_child(entry.entry_id, gateway, node_id, child) @@ -258,22 +254,18 @@ async def _discover_persistent_devices( discover_mysensors_platform(hass, entry.entry_id, platform, dev_ids) -async def gw_stop( - hass: HomeAssistant, entry: ConfigEntry, gateway: BaseAsyncGateway -) -> None: +async def gw_stop(entry: MySensorsConfigEntry) -> None: """Stop the gateway.""" - connect_task = hass.data[DOMAIN].pop( - MYSENSORS_GATEWAY_START_TASK.format(entry.entry_id), None - ) + connect_task = entry.runtime_data.gateway_start_task + entry.runtime_data.gateway_start_task = None if connect_task is not None and not connect_task.done(): connect_task.cancel() - await gateway.stop() + await entry.runtime_data.gateway.stop() -async def _gw_start( - hass: HomeAssistant, entry: ConfigEntry, gateway: BaseAsyncGateway -) -> None: +async def _gw_start(hass: HomeAssistant, entry: MySensorsConfigEntry) -> None: """Start the gateway.""" + gateway = entry.runtime_data.gateway gateway_ready = asyncio.Event() def gateway_connected(_: BaseAsyncGateway) -> None: @@ -282,15 +274,11 @@ def gateway_connected(_: BaseAsyncGateway) -> None: gateway.on_conn_made = gateway_connected # Don't use hass.async_create_task to avoid holding up setup indefinitely. - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=home-assistant-use-runtime-data - hass.data[DOMAIN][MYSENSORS_GATEWAY_START_TASK.format(entry.entry_id)] = ( - asyncio.create_task(gateway.start()) - ) # store the connect task so it can be cancelled in gw_stop + entry.runtime_data.gateway_start_task = asyncio.create_task(gateway.start()) async def stop_this_gw(_: Event) -> None: """Stop the gateway.""" - await gw_stop(hass, entry, gateway) + await gw_stop(entry) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_this_gw), @@ -311,7 +299,7 @@ async def stop_this_gw(_: Event) -> None: def _gw_callback_factory( - hass: HomeAssistant, gateway_id: GatewayId + hass: HomeAssistant, entry: MySensorsConfigEntry ) -> Callable[[Message], None]: """Return a new callback for the gateway.""" @@ -330,6 +318,6 @@ def mysensors_callback(msg: Message) -> None: if msg_handler is None: return - msg_handler(hass, gateway_id, msg) + msg_handler(hass, entry, msg) return mysensors_callback diff --git a/homeassistant/components/mysensors/handler.py b/homeassistant/components/mysensors/handler.py index a00a6ca92e535..83b7b8ed4c20f 100644 --- a/homeassistant/components/mysensors/handler.py +++ b/homeassistant/components/mysensors/handler.py @@ -10,84 +10,90 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util import decorator -from .const import CHILD_CALLBACK, NODE_CALLBACK, DevId, GatewayId -from .entity import get_mysensors_devices +from .const import CHILD_CALLBACK, NODE_CALLBACK, DevId from .helpers import ( discover_mysensors_node, discover_mysensors_platform, validate_set_msg, ) +from .models import MySensorsConfigEntry HANDLERS: decorator.Registry[ - str, Callable[[HomeAssistant, GatewayId, Message], None] + str, Callable[[HomeAssistant, MySensorsConfigEntry, Message], None] ] = decorator.Registry() @HANDLERS.register("set") @callback -def handle_set(hass: HomeAssistant, gateway_id: GatewayId, msg: Message) -> None: +def handle_set(hass: HomeAssistant, entry: MySensorsConfigEntry, msg: Message) -> None: """Handle a mysensors set message.""" - validated = validate_set_msg(gateway_id, msg) - _handle_child_update(hass, gateway_id, validated) + validated = validate_set_msg(entry.entry_id, msg) + _handle_child_update(hass, entry, validated) @HANDLERS.register("internal") @callback -def handle_internal(hass: HomeAssistant, gateway_id: GatewayId, msg: Message) -> None: +def handle_internal( + hass: HomeAssistant, entry: MySensorsConfigEntry, msg: Message +) -> None: """Handle a mysensors internal message.""" internal = msg.gateway.const.Internal(msg.sub_type) if (handler := HANDLERS.get(internal.name)) is None: return - handler(hass, gateway_id, msg) + handler(hass, entry, msg) @HANDLERS.register("I_BATTERY_LEVEL") @callback def handle_battery_level( - hass: HomeAssistant, gateway_id: GatewayId, msg: Message + hass: HomeAssistant, entry: MySensorsConfigEntry, msg: Message ) -> None: """Handle an internal battery level message.""" - _handle_node_update(hass, gateway_id, msg) + _handle_node_update(hass, entry, msg) @HANDLERS.register("I_HEARTBEAT_RESPONSE") @callback -def handle_heartbeat(hass: HomeAssistant, gateway_id: GatewayId, msg: Message) -> None: +def handle_heartbeat( + hass: HomeAssistant, entry: MySensorsConfigEntry, msg: Message +) -> None: """Handle an heartbeat.""" - _handle_node_update(hass, gateway_id, msg) + _handle_node_update(hass, entry, msg) @HANDLERS.register("I_SKETCH_NAME") @callback def handle_sketch_name( - hass: HomeAssistant, gateway_id: GatewayId, msg: Message + hass: HomeAssistant, entry: MySensorsConfigEntry, msg: Message ) -> None: """Handle an internal sketch name message.""" - _handle_node_update(hass, gateway_id, msg) + _handle_node_update(hass, entry, msg) @HANDLERS.register("I_SKETCH_VERSION") @callback def handle_sketch_version( - hass: HomeAssistant, gateway_id: GatewayId, msg: Message + hass: HomeAssistant, entry: MySensorsConfigEntry, msg: Message ) -> None: """Handle an internal sketch version message.""" - _handle_node_update(hass, gateway_id, msg) + _handle_node_update(hass, entry, msg) @HANDLERS.register("presentation") @callback def handle_presentation( - hass: HomeAssistant, gateway_id: GatewayId, msg: Message + hass: HomeAssistant, entry: MySensorsConfigEntry, msg: Message ) -> None: """Handle an internal presentation message.""" if msg.child_id == SYSTEM_CHILD_ID: - discover_mysensors_node(hass, gateway_id, msg.node_id) + discover_mysensors_node(hass, entry, msg.node_id) @callback def _handle_child_update( - hass: HomeAssistant, gateway_id: GatewayId, validated: dict[Platform, list[DevId]] + hass: HomeAssistant, + entry: MySensorsConfigEntry, + validated: dict[Platform, list[DevId]], ) -> None: """Handle a child update.""" signals: list[str] = [] @@ -95,15 +101,15 @@ def _handle_child_update( # Update all platforms for the device via dispatcher. # Add/update entity for validated children. for platform, dev_ids in validated.items(): - devices = get_mysensors_devices(hass, platform) + discovered_dev_ids = entry.runtime_data.discovered_dev_ids[platform] new_dev_ids: list[DevId] = [] for dev_id in dev_ids: - if dev_id in devices: + if dev_id in discovered_dev_ids: signals.append(CHILD_CALLBACK.format(*dev_id)) else: new_dev_ids.append(dev_id) if new_dev_ids: - discover_mysensors_platform(hass, gateway_id, platform, new_dev_ids) + discover_mysensors_platform(hass, entry.entry_id, platform, new_dev_ids) for signal in set(signals): # Only one signal per device is needed. # A device can have multiple platforms, ie multiple schemas. @@ -112,8 +118,8 @@ def _handle_child_update( @callback def _handle_node_update( - hass: HomeAssistant, gateway_id: GatewayId, msg: Message + hass: HomeAssistant, entry: MySensorsConfigEntry, msg: Message ) -> None: """Handle a node update.""" - signal = NODE_CALLBACK.format(gateway_id, msg.node_id) + signal = NODE_CALLBACK.format(entry.entry_id, msg.node_id) async_dispatcher_send(hass, signal) diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py index 6de3cb27c069a..0b739065f021f 100644 --- a/homeassistant/components/mysensors/helpers.py +++ b/homeassistant/components/mysensors/helpers.py @@ -22,7 +22,6 @@ ATTR_NODE_ID, DOMAIN, FLAT_PLATFORM_TYPES, - MYSENSORS_DISCOVERED_NODES, MYSENSORS_DISCOVERY, MYSENSORS_NODE_DISCOVERY, TYPE_TO_PLATFORMS, @@ -31,6 +30,7 @@ SensorType, ValueType, ) +from .models import MySensorsConfigEntry _LOGGER = logging.getLogger(__name__) SCHEMAS: Registry[ @@ -57,27 +57,32 @@ def discover_mysensors_platform( @callback def discover_mysensors_node( - hass: HomeAssistant, gateway_id: GatewayId, node_id: int + hass: HomeAssistant, entry: MySensorsConfigEntry, node_id: int ) -> None: """Discover a MySensors node.""" - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=home-assistant-use-runtime-data - discovered_nodes = hass.data[DOMAIN].setdefault( - MYSENSORS_DISCOVERED_NODES.format(gateway_id), set() - ) + discovered_nodes = entry.runtime_data.discovered_nodes if node_id not in discovered_nodes: discovered_nodes.add(node_id) async_dispatcher_send( hass, - MYSENSORS_NODE_DISCOVERY.format(gateway_id), + MYSENSORS_NODE_DISCOVERY.format(entry.entry_id), { - ATTR_GATEWAY_ID: gateway_id, + ATTR_GATEWAY_ID: entry.entry_id, ATTR_NODE_ID: node_id, }, ) +@callback +def remove_node_dev_ids(entry: MySensorsConfigEntry, node_id: int) -> None: + """Remove all discovered dev ids belonging to a node.""" + for dev_ids in entry.runtime_data.discovered_dev_ids.values(): + dev_ids.difference_update( + {dev_id for dev_id in dev_ids if dev_id[1] == node_id} + ) + + def default_schema( gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType ) -> vol.Schema: diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index d41d1355ef969..dbcebde792c80 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -9,7 +9,6 @@ ColorMode, LightEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -19,11 +18,12 @@ from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType from .entity import MySensorsChildEntity +from .models import MySensorsConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MySensorsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" @@ -36,11 +36,11 @@ async def async_setup_entry( async def async_discover(discovery_info: DiscoveryInfo) -> None: """Discover and add a MySensors light.""" setup_mysensors_platform( - hass, + config_entry, Platform.LIGHT, discovery_info, device_class_map, - async_add_entities=async_add_entities, + async_add_entities, ) config_entry.async_on_unload( diff --git a/homeassistant/components/mysensors/models.py b/homeassistant/components/mysensors/models.py new file mode 100644 index 0000000000000..bbde2a669595b --- /dev/null +++ b/homeassistant/components/mysensors/models.py @@ -0,0 +1,26 @@ +"""Models for the MySensors integration.""" + +from asyncio import Task +from collections import defaultdict +from dataclasses import dataclass, field + +from mysensors import BaseAsyncGateway + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform + +from .const import DevId + +type MySensorsConfigEntry = ConfigEntry[MySensorsData] + + +@dataclass +class MySensorsData: + """Runtime data for a MySensors gateway.""" + + gateway: BaseAsyncGateway + discovered_nodes: set[int] = field(default_factory=set) + discovered_dev_ids: defaultdict[Platform, set[DevId]] = field( + default_factory=lambda: defaultdict(set) + ) + gateway_start_task: Task[None] | None = None diff --git a/homeassistant/components/mysensors/remote.py b/homeassistant/components/mysensors/remote.py index 2992dee79e413..0a27f75b36794 100644 --- a/homeassistant/components/mysensors/remote.py +++ b/homeassistant/components/mysensors/remote.py @@ -8,7 +8,6 @@ RemoteEntity, RemoteEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -17,11 +16,12 @@ from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .entity import MySensorsChildEntity +from .models import MySensorsConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MySensorsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" @@ -30,11 +30,11 @@ async def async_setup_entry( def async_discover(discovery_info: DiscoveryInfo) -> None: """Discover and add a MySensors remote.""" setup_mysensors_platform( - hass, + config_entry, Platform.REMOTE, discovery_info, MySensorsRemote, - async_add_entities=async_add_entities, + async_add_entities, ) config_entry.async_on_unload( diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index 9305cfbaa1db6..d390d0b37f25d 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -3,7 +3,6 @@ from typing import Any, override from awesomeversion import AwesomeVersion -from mysensors import BaseAsyncGateway from homeassistant.components.sensor import ( SensorDeviceClass, @@ -11,7 +10,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEGREE, LIGHT_LUX, @@ -38,16 +36,14 @@ from . import setup_mysensors_platform from .const import ( - ATTR_GATEWAY_ID, ATTR_NODE_ID, - DOMAIN, MYSENSORS_DISCOVERY, - MYSENSORS_GATEWAYS, MYSENSORS_NODE_DISCOVERY, DiscoveryInfo, NodeDiscoveryInfo, ) from .entity import MySensorNodeEntity, MySensorsChildEntity +from .models import MySensorsConfigEntry SENSORS: dict[str, SensorEntityDescription] = { "V_TEMP": SensorEntityDescription( @@ -208,7 +204,7 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MySensorsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" @@ -216,22 +212,18 @@ async def async_setup_entry( async def async_discover(discovery_info: DiscoveryInfo) -> None: """Discover and add a MySensors sensor.""" setup_mysensors_platform( - hass, + config_entry, Platform.SENSOR, discovery_info, MySensorsSensor, - async_add_entities=async_add_entities, + async_add_entities, ) @callback def async_node_discover(discovery_info: NodeDiscoveryInfo) -> None: """Add battery sensor for each MySensors node.""" - gateway_id = discovery_info[ATTR_GATEWAY_ID] node_id = discovery_info[ATTR_NODE_ID] - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=home-assistant-use-runtime-data - gateway: BaseAsyncGateway = hass.data[DOMAIN][MYSENSORS_GATEWAYS][gateway_id] - async_add_entities([MyBatterySensor(gateway_id, gateway, node_id)]) + async_add_entities([MyBatterySensor(config_entry, node_id)]) config_entry.async_on_unload( async_dispatcher_connect( diff --git a/homeassistant/components/mysensors/switch.py b/homeassistant/components/mysensors/switch.py index bdbde3a230bab..c1aa0b547fd72 100644 --- a/homeassistant/components/mysensors/switch.py +++ b/homeassistant/components/mysensors/switch.py @@ -3,7 +3,6 @@ from typing import Any, override from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -12,11 +11,12 @@ from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType from .entity import MySensorsChildEntity +from .models import MySensorsConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MySensorsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" @@ -38,11 +38,11 @@ async def async_setup_entry( async def async_discover(discovery_info: DiscoveryInfo) -> None: """Discover and add a MySensors switch.""" setup_mysensors_platform( - hass, + config_entry, Platform.SWITCH, discovery_info, device_class_map, - async_add_entities=async_add_entities, + async_add_entities, ) config_entry.async_on_unload( diff --git a/homeassistant/components/mysensors/text.py b/homeassistant/components/mysensors/text.py index c5abb2991b074..f88647b0ce921 100644 --- a/homeassistant/components/mysensors/text.py +++ b/homeassistant/components/mysensors/text.py @@ -3,7 +3,6 @@ from typing import override from homeassistant.components.text import TextEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -12,11 +11,12 @@ from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .entity import MySensorsChildEntity +from .models import MySensorsConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MySensorsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" @@ -25,11 +25,11 @@ async def async_setup_entry( def async_discover(discovery_info: DiscoveryInfo) -> None: """Discover and add a MySensors text entity.""" setup_mysensors_platform( - hass, + config_entry, Platform.TEXT, discovery_info, MySensorsText, - async_add_entities=async_add_entities, + async_add_entities, ) config_entry.async_on_unload( diff --git a/homeassistant/components/satel_integra/__init__.py b/homeassistant/components/satel_integra/__init__.py index 69ed6339f29e5..44a7fe31d0ff4 100644 --- a/homeassistant/components/satel_integra/__init__.py +++ b/homeassistant/components/satel_integra/__init__.py @@ -2,6 +2,8 @@ import logging +from satel_integra import SatelIntegraError + from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr @@ -67,8 +69,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: SatelConfigEntry) -> boo ) await coordinator_temperatures.async_config_entry_first_refresh() + try: + panel_info = await client.controller.read_panel_info() + except SatelIntegraError: + _LOGGER.warning("Unable to read Satel panel information", exc_info=True) + panel_info = None + entry.runtime_data = SatelIntegraData( client=client, + panel_info=panel_info, coordinator_zones=coordinator_zones, coordinator_outputs=coordinator_outputs, coordinator_partitions=coordinator_partitions, @@ -89,6 +98,8 @@ async def async_close_connection(event: Event) -> None: config_entry_id=entry.entry_id, identifiers={(DOMAIN, entry.entry_id)}, manufacturer="Satel", + model=panel_info.model.name if panel_info and panel_info.model else None, + sw_version=str(panel_info.firmware) if panel_info else None, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/satel_integra/coordinator.py b/homeassistant/components/satel_integra/coordinator.py index c96cb2b976c0d..39c2adffd3370 100644 --- a/homeassistant/components/satel_integra/coordinator.py +++ b/homeassistant/components/satel_integra/coordinator.py @@ -5,7 +5,7 @@ import logging from typing import override -from satel_integra import AlarmState +from satel_integra import AlarmState, SatelPanelInfo from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -26,6 +26,7 @@ class SatelIntegraData: """Data for the satel_integra integration.""" client: SatelClient + panel_info: SatelPanelInfo | None coordinator_zones: SatelIntegraZonesCoordinator coordinator_outputs: SatelIntegraOutputsCoordinator coordinator_partitions: SatelIntegraPartitionsCoordinator diff --git a/homeassistant/components/satel_integra/diagnostics.py b/homeassistant/components/satel_integra/diagnostics.py index d7e172819c673..0f1ca56d55602 100644 --- a/homeassistant/components/satel_integra/diagnostics.py +++ b/homeassistant/components/satel_integra/diagnostics.py @@ -1,26 +1,29 @@ """Diagnostics support for Satel Integra.""" +from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CODE from homeassistant.core import HomeAssistant from .const import CONF_ENCRYPTION_KEY +from .coordinator import SatelConfigEntry TO_REDACT = {CONF_CODE, CONF_ENCRYPTION_KEY} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: SatelConfigEntry ) -> dict[str, Any]: """Return diagnostics for the config entry.""" - diag: dict[str, Any] = {} - - diag["config_entry_data"] = async_redact_data(entry.data, TO_REDACT) - diag["config_entry_options"] = async_redact_data(entry.options, TO_REDACT) - - diag["subentries"] = dict(entry.subentries) - - return diag + return { + "config_entry_data": async_redact_data(entry.data, TO_REDACT), + "config_entry_options": async_redact_data(entry.options, TO_REDACT), + "subentries": dict(entry.subentries), + "panel_info": ( + asdict(entry.runtime_data.panel_info) + if entry.runtime_data.panel_info + else None + ), + } diff --git a/homeassistant/components/subaru/button.py b/homeassistant/components/subaru/button.py index 2e77211698ce2..700c1690b64de 100644 --- a/homeassistant/components/subaru/button.py +++ b/homeassistant/components/subaru/button.py @@ -17,7 +17,7 @@ VEHICLE_HAS_REMOTE_START, ) from .coordinator import SubaruConfigEntry, SubaruDataUpdateCoordinator -from .entity import SubaruEntity +from .entity import SubaruCoordinatorEntity from .remote_service import async_call_remote_service @@ -58,7 +58,7 @@ async def async_setup_entry( ) -class SubaruButton(SubaruEntity, ButtonEntity): +class SubaruButton(SubaruCoordinatorEntity, ButtonEntity): """Class for a Subaru button.""" entity_description: SubaruButtonEntityDescription @@ -71,9 +71,8 @@ def __init__( description: SubaruButtonEntityDescription, ) -> None: """Initialize the button for the vehicle.""" - super().__init__(vehicle_info, description.key) + super().__init__(vehicle_info, coordinator, description.key) self.controller = controller - self.coordinator = coordinator self.entity_description = description @override diff --git a/homeassistant/components/subaru/lock.py b/homeassistant/components/subaru/lock.py index 362e3ebe4c3de..ba1c687c0c851 100644 --- a/homeassistant/components/subaru/lock.py +++ b/homeassistant/components/subaru/lock.py @@ -20,7 +20,7 @@ VEHICLE_NAME, ) from .coordinator import SubaruConfigEntry -from .entity import SubaruEntity +from .entity import SubaruCoordinatorEntity from .remote_service import async_call_remote_service _LOGGER = logging.getLogger(__name__) @@ -32,10 +32,11 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Subaru locks by config_entry.""" + coordinator = config_entry.runtime_data.coordinator controller = config_entry.runtime_data.controller vehicle_info = config_entry.runtime_data.vehicles async_add_entities( - SubaruLock(vehicle, controller) + SubaruLock(vehicle, controller, coordinator) for vehicle in vehicle_info.values() if vehicle[VEHICLE_HAS_REMOTE_SERVICE] ) @@ -49,7 +50,7 @@ async def async_setup_entry( ) -class SubaruLock(SubaruEntity, LockEntity): +class SubaruLock(SubaruCoordinatorEntity, LockEntity): """Representation of a Subaru door lock. Note that the Subaru API currently does not support @@ -59,9 +60,9 @@ class SubaruLock(SubaruEntity, LockEntity): _attr_translation_key = "door_locks" - def __init__(self, vehicle_info, controller): + def __init__(self, vehicle_info, controller, coordinator): """Initialize the locks for the vehicle.""" - super().__init__(vehicle_info, "door_locks") + super().__init__(vehicle_info, coordinator, "door_locks") self.controller = controller self.car_name = vehicle_info[VEHICLE_NAME] diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index f488c10471026..3db9ca6f22975 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -42,5 +42,5 @@ "iot_class": "local_push", "loggers": ["switchbot"], "quality_scale": "gold", - "requirements": ["PySwitchbot==2.4.1"] + "requirements": ["PySwitchbot==2.7.0"] } diff --git a/homeassistant/components/vistapool/__init__.py b/homeassistant/components/vistapool/__init__.py index f4210ea111fc9..1e083996b7d91 100644 --- a/homeassistant/components/vistapool/__init__.py +++ b/homeassistant/components/vistapool/__init__.py @@ -66,6 +66,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: VistapoolConfigEntry) -> session = async_get_clientsession(hass) auth = AquariteAuth(session, user_config[CONF_USERNAME], user_config[CONF_PASSWORD]) + # Home Assistant runs these callbacks on a failed setup as well, so + # registering before authenticating releases the Firestore gRPC channels + # on every path out of this function. + entry.async_on_unload(auth.close) try: await auth.authenticate() except AuthenticationError as exc: diff --git a/homeassistant/components/vistapool/config_flow.py b/homeassistant/components/vistapool/config_flow.py index c10232bf31652..82c6f419ccf9a 100644 --- a/homeassistant/components/vistapool/config_flow.py +++ b/homeassistant/components/vistapool/config_flow.py @@ -74,6 +74,10 @@ async def async_step_user( CONF_PASSWORD: password, }, ) + finally: + # The entry is set up from the stored credentials with its own + # auth, so this one only lives for the length of the flow. + auth.close() return self.async_show_form( step_id="user", data_schema=AUTH_SCHEMA, errors=errors @@ -130,6 +134,8 @@ async def _async_update_password( return self.async_update_reload_and_abort( entry, data_updates={CONF_PASSWORD: password} ) + finally: + auth.close() return self.async_show_form( step_id=step_id, diff --git a/homeassistant/components/zonneplan/config_flow.py b/homeassistant/components/zonneplan/config_flow.py index 69a3f320f28ee..0b4fd168abfae 100644 --- a/homeassistant/components/zonneplan/config_flow.py +++ b/homeassistant/components/zonneplan/config_flow.py @@ -1,5 +1,6 @@ """Config flow for the Zonneplan integration.""" +from collections.abc import Mapping import logging from typing import Any, override @@ -12,7 +13,7 @@ ) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_EMAIL, CONF_TOKEN from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( @@ -47,30 +48,35 @@ class ZonneplanConfigFlow(ConfigFlow, domain=DOMAIN): _client: Zonneplan _challenge: OtpChallenge + async def _async_request_otp(self, email: str) -> dict[str, str] | None: + """Request an OTP for the given email, returning any form errors.""" + self._client = Zonneplan( + email=email, + session=async_get_clientsession(self.hass), + ) + try: + self._challenge = await self._client.async_request_otp( + source_name=self.hass.config.location_name + ) + except ZonneplanConnectionError: + return {"base": "cannot_connect"} + except ZonneplanTimeoutError: + return {"base": "timeout_connect"} + except Exception: + LOGGER.exception("Unexpected exception") + return {"base": "unknown"} + return None + @override async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step: request an OTP for the given email.""" - errors: dict[str, str] = {} - if user_input is not None: - self._client = Zonneplan( - email=user_input[CONF_EMAIL], - session=async_get_clientsession(self.hass), - ) - try: - self._challenge = await self._client.async_request_otp( - source_name=self.hass.config.location_name - ) - except ZonneplanConnectionError: - errors["base"] = "cannot_connect" - except ZonneplanTimeoutError: - errors["base"] = "timeout_connect" - except Exception: - LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - return await self.async_step_otp() + errors: dict[str, str] | None = None + if user_input is not None and not ( + errors := await self._async_request_otp(user_input[CONF_EMAIL]) + ): + return await self.async_step_otp() return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors @@ -98,6 +104,15 @@ async def async_step_otp( errors["base"] = "unknown" else: await self.async_set_unique_id(account.user_account.uuid) + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={ + CONF_EMAIL: account.user_account.email, + CONF_TOKEN: token.as_dict(), + }, + ) self._abort_if_unique_id_configured() return self.async_create_entry( title=account.user_account.full_name, @@ -113,3 +128,30 @@ async def async_step_otp( errors=errors, description_placeholders={CONF_EMAIL: self._challenge.email}, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication with Zonneplan.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Request a new OTP for the email of the entry being re-authenticated.""" + email = self._get_reauth_entry().data[CONF_EMAIL] + errors: dict[str, str] | None = None + if user_input is not None and not ( + errors := await self._async_request_otp(user_input[CONF_EMAIL]) + ): + return await self.async_step_otp() + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, + suggested_values=user_input or {CONF_EMAIL: email}, + ), + errors=errors, + description_placeholders={CONF_EMAIL: email}, + ) diff --git a/homeassistant/components/zonneplan/coordinator.py b/homeassistant/components/zonneplan/coordinator.py index 9611bf3676771..c0a3e6801caa0 100644 --- a/homeassistant/components/zonneplan/coordinator.py +++ b/homeassistant/components/zonneplan/coordinator.py @@ -18,6 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -83,7 +84,7 @@ async def _async_update_data(self) -> ZonneplanData: PriceChart.GAS_DAILY ) except ZonneplanAuthenticationError as err: - raise UpdateFailed( + raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth", ) from err diff --git a/homeassistant/components/zonneplan/diagnostics.py b/homeassistant/components/zonneplan/diagnostics.py new file mode 100644 index 0000000000000..023e10eead7fb --- /dev/null +++ b/homeassistant/components/zonneplan/diagnostics.py @@ -0,0 +1,53 @@ +"""Diagnostics support for the Zonneplan integration.""" + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_EMAIL, CONF_TOKEN +from homeassistant.core import HomeAssistant + +from .coordinator import ZonneplanConfigEntry + +TO_REDACT = { + CONF_EMAIL, + CONF_TOKEN, + "access_token", + "refresh_token", + "email", + "first_name", + "full_name", + "initials", + "street", + "number", + "addition", + "zipcode", + "city", + "ean", + "serial_number", + "uuid", + "external_contract_id", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ZonneplanConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + data = entry.runtime_data.data + + return async_redact_data( + { + "entry_data": dict(entry.data), + "account": data.account.to_dict(), + "connections": [ + connection.to_dict() + for connection in data.account.connections + if data.account.connections is not None and connection is not None + ], + "electricity_prices": ( + data.electricity_prices.to_dict() if data.electricity_prices else None + ), + "gas_prices": data.gas_prices.to_dict() if data.gas_prices else None, + }, + TO_REDACT, + ) diff --git a/homeassistant/components/zonneplan/quality_scale.yaml b/homeassistant/components/zonneplan/quality_scale.yaml index bb26093c8211e..0ef138579d4c4 100644 --- a/homeassistant/components/zonneplan/quality_scale.yaml +++ b/homeassistant/components/zonneplan/quality_scale.yaml @@ -42,12 +42,12 @@ rules: integration-owner: todo log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: todo # Gold devices: todo - diagnostics: todo + diagnostics: done discovery-update-info: todo discovery: todo docs-data-update: todo diff --git a/homeassistant/components/zonneplan/strings.json b/homeassistant/components/zonneplan/strings.json index 53f2d57d03d62..a0a3e3bf200d8 100644 --- a/homeassistant/components/zonneplan/strings.json +++ b/homeassistant/components/zonneplan/strings.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unique_id_mismatch": "The one-time password was validated for a different Zonneplan account than the one configured." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -19,6 +21,16 @@ }, "description": "Within a few minutes, you will receive an email with a one-time password. Enter the password below to complete the login process." }, + "reauth_confirm": { + "data": { + "email": "[%key:common::config_flow::data::email%]" + }, + "data_description": { + "email": "[%key:component::zonneplan::config::step::user::data_description::email%]" + }, + "description": "The stored credentials for {email} are no longer valid. Confirm your email address to receive a new one-time password.", + "title": "[%key:common::config_flow::title::reauth%]" + }, "user": { "data": { "email": "[%key:common::config_flow::data::email%]" diff --git a/requirements_all.txt b/requirements_all.txt index a022c7ffbdbbc..de0f14bd80479 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -86,7 +86,7 @@ PyRMVtransport==0.3.3 PySrDaliGateway==0.21.0 # homeassistant.components.switchbot -PySwitchbot==2.4.1 +PySwitchbot==2.7.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 @@ -1272,7 +1272,7 @@ hassil==3.12.0 hdate[astral]==1.2.1 # homeassistant.components.hdfury -hdfury==1.6.0 +hdfury==1.6.1 # homeassistant.components.heatmiser heatmiserV3==2.0.6 @@ -2702,7 +2702,7 @@ python-digitalocean==1.13.2 python-dropbox-api==0.1.4 # homeassistant.components.duco -python-duco-connectivity==0.13.1 +python-duco-connectivity==0.14.0 # homeassistant.components.ecobee python-ecobee-api==0.4.1 diff --git a/tests/components/collection_image/test_image.py b/tests/components/collection_image/test_image.py index 0545553ab9120..ffcb405f2ea70 100644 --- a/tests/components/collection_image/test_image.py +++ b/tests/components/collection_image/test_image.py @@ -9,7 +9,11 @@ from homeassistant.components.image import Image, async_get_image from homeassistant.components.media_source import BrowseMediaSource, PlayMedia -from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, STATE_UNAVAILABLE +from homeassistant.const import ( + EVENT_HOMEASSISTANT_STARTED, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -216,7 +220,7 @@ async def test_unresolvable( state = hass.states.get(DEFAULT_ENTITY_ID) - assert state and state.state == STATE_UNAVAILABLE + assert state and state.state == STATE_UNKNOWN await hass.async_block_till_done(wait_background_tasks=True) diff --git a/tests/components/collection_image/test_services.py b/tests/components/collection_image/test_services.py index 4cfd782c325d9..b23890e0cae5b 100644 --- a/tests/components/collection_image/test_services.py +++ b/tests/components/collection_image/test_services.py @@ -27,14 +27,14 @@ async def test_shuffle_action( config_entry: MockConfigEntry, mock_media_source, ) -> None: - """Test that shuffle calls get_next_image on the target entity.""" + """Test that shuffle calls get_random_image on the target entity.""" await _setup_integration(hass, config_entry) with patch.object( CollectionImageImageEntity, - "get_next_image", + "get_random_image", new_callable=AsyncMock, - ) as mock_get_next_image: + ) as mock_get_random_image: await hass.services.async_call( DOMAIN, "shuffle", @@ -42,4 +42,4 @@ async def test_shuffle_action( blocking=True, ) - mock_get_next_image.assert_awaited_once() + mock_get_random_image.assert_awaited_once() diff --git a/tests/components/duco/conftest.py b/tests/components/duco/conftest.py index d92956d82b891..8dbf86d35fdf3 100644 --- a/tests/components/duco/conftest.py +++ b/tests/components/duco/conftest.py @@ -276,6 +276,18 @@ def mock_duco_client( mock_ventilation_temperature_info: VentilationTemperatureInfo, ) -> Generator[AsyncMock]: """Return a mocked DucoClient used by both the integration and config flow.""" + + def set_bypass_supply_temperature_target( + zone_id: int, + temperature: float, + *, + target: BypassSupplyTemperatureTarget, + ) -> None: + target.validate_value(temperature) + mock_bypass_supply_temperature_targets[zone_id] = replace( + target, value=temperature + ) + with ( patch( "homeassistant.components.duco.DucoClient", @@ -301,15 +313,7 @@ def mock_duco_client( mock_bypass_supply_temperature_targets.copy ) client.async_set_bypass_supply_temperature_target.side_effect = ( - lambda zone_id, temperature: ( - mock_bypass_supply_temperature_targets.__setitem__( - zone_id, - replace( - mock_bypass_supply_temperature_targets[zone_id], - value=temperature, - ), - ) - ) + set_bypass_supply_temperature_target ) client.async_get_diagnostics.return_value = [ DiagComponent(component="Ventilation", status="Ok") diff --git a/tests/components/duco/test_init.py b/tests/components/duco/test_init.py index 7035108386954..aca738a00a83f 100644 --- a/tests/components/duco/test_init.py +++ b/tests/components/duco/test_init.py @@ -13,7 +13,6 @@ DucoConnectionError, DucoError, DucoResponseError, - DucoUnsupportedCapabilityError, LanInfo, Node, NodeListActionItemList, @@ -268,20 +267,18 @@ async def test_setup_entry_retries_on_bypass_temperature_failure( assert mock_config_entry.error_reason_translation_placeholders is None -async def test_unsupported_bypass_temperature_capability_is_not_repolled( +async def test_empty_bypass_temperature_targets_are_retried( hass: HomeAssistant, freezer: FrozenDateTimeFactory, + mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget], mock_config_entry: MockConfigEntry, mock_duco_client: AsyncMock, ) -> None: - """Test an unsupported bulk bypass target endpoint is not polled again.""" - mock_duco_client.async_get_bypass_supply_temperature_targets.side_effect = ( - DucoUnsupportedCapabilityError( - 400, - "/config", - '{"Code":3,"Result":"FAILED"}', - ) - ) + """Test empty bypass targets are retried and can later create entities.""" + mock_duco_client.async_get_bypass_supply_temperature_targets.side_effect = [ + {}, + mock_bypass_supply_temperature_targets.copy(), + ] mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -296,7 +293,9 @@ async def test_unsupported_bypass_temperature_capability_is_not_repolled( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - mock_duco_client.async_get_bypass_supply_temperature_targets.assert_awaited_once_with() + assert mock_duco_client.async_get_bypass_supply_temperature_targets.await_count == 2 + assert hass.states.get("number.living_bypass_target_1") is not None + assert hass.states.get("number.living_bypass_target_2") is not None async def test_missing_bypass_temperature_targets_are_retried( diff --git a/tests/components/duco/test_number.py b/tests/components/duco/test_number.py index 5f910402520de..a5c2f9e273df6 100644 --- a/tests/components/duco/test_number.py +++ b/tests/components/duco/test_number.py @@ -92,38 +92,15 @@ async def test_bypass_supply_temperature_targets_missing_skips_number_creation( assert hass.states.get(_ZONE_2_ENTITY_ID) is None -@pytest.mark.parametrize( - "field", - [ - pytest.param("minimum", id="missing_minimum"), - pytest.param("maximum", id="missing_maximum"), - pytest.param("increment", id="missing_increment"), - ], -) -@pytest.mark.usefixtures("mock_duco_client") -async def test_bypass_supply_temperature_target_incomplete_metadata_skips_number_creation( - hass: HomeAssistant, - mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget], - mock_config_entry: MockConfigEntry, - field: str, -) -> None: - """Test incomplete target metadata does not expose an invalid control.""" - mock_bypass_supply_temperature_targets[1] = replace( - mock_bypass_supply_temperature_targets[1], **{field: None} - ) - - await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER]) - - assert hass.states.get(_ZONE_1_ENTITY_ID) is None - assert hass.states.get(_ZONE_2_ENTITY_ID) is not None - - @pytest.mark.usefixtures("init_integration") async def test_set_bypass_supply_temperature_target( hass: HomeAssistant, + mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget], mock_duco_client: AsyncMock, ) -> None: """Test setting a bypass target refreshes the number from the box.""" + target = mock_bypass_supply_temperature_targets[1] + await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, @@ -132,7 +109,7 @@ async def test_set_bypass_supply_temperature_target( ) mock_duco_client.async_set_bypass_supply_temperature_target.assert_awaited_once_with( - 1, 20.5 + 1, 20.5, target=target ) state = hass.states.get(_ZONE_1_ENTITY_ID) assert state is not None @@ -152,6 +129,7 @@ async def test_set_bypass_supply_temperature_target_honors_increment_metadata( increment=0.5, maximum=25.5, ) + target = mock_bypass_supply_temperature_targets[1] await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER]) @@ -163,7 +141,7 @@ async def test_set_bypass_supply_temperature_target_honors_increment_metadata( ) mock_duco_client.async_set_bypass_supply_temperature_target.assert_awaited_once_with( - 1, 20.5 + 1, 20.5, target=target ) with pytest.raises( @@ -192,6 +170,7 @@ async def test_set_bypass_supply_temperature_target_in_fahrenheit_units( increment=0.5, maximum=25.5, ) + target = mock_bypass_supply_temperature_targets[1] await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER]) @@ -203,7 +182,7 @@ async def test_set_bypass_supply_temperature_target_in_fahrenheit_units( ) mock_duco_client.async_set_bypass_supply_temperature_target.assert_awaited_once_with( - 1, 20.5 + 1, 20.5, target=target ) state = hass.states.get(_ZONE_1_ENTITY_ID) assert state is not None @@ -224,6 +203,7 @@ async def test_set_bypass_supply_temperature_target_stays_within_maximum( increment=0.5, maximum=24.8, ) + target = mock_bypass_supply_temperature_targets[1] await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER]) @@ -235,7 +215,7 @@ async def test_set_bypass_supply_temperature_target_stays_within_maximum( ) mock_duco_client.async_set_bypass_supply_temperature_target.assert_awaited_once_with( - 1, 24.5 + 1, 24.5, target=target ) diff --git a/tests/components/duco/test_sensor.py b/tests/components/duco/test_sensor.py index 633b8e0b3bc0c..9cf809fc98782 100644 --- a/tests/components/duco/test_sensor.py +++ b/tests/components/duco/test_sensor.py @@ -7,7 +7,6 @@ from duco_connectivity import ( DucoConnectionError, DucoError, - DucoUnsupportedCapabilityError, Node, NodeGeneralInfo, NodeSensorInfo, @@ -252,14 +251,14 @@ async def test_lan_info_failures_keep_node_entities_available( assert state.state == "-60" -async def test_time_filter_remaining_missing_skips_sensor_creation( +async def test_time_filter_remaining_missing_is_retried( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_duco_client: AsyncMock, mock_sensor_nodes: list[Node], freezer: FrozenDateTimeFactory, ) -> None: - """Test the filter timer sensor is not created when unsupported.""" + """Test a missing filter timer does not create the sensor but is retried.""" mock_duco_client.async_get_nodes.return_value = mock_sensor_nodes mock_duco_client.async_get_time_filter_remaining = AsyncMock( @@ -274,18 +273,21 @@ async def test_time_filter_remaining_missing_skips_sensor_creation( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(FILTER_REMAINING_ENTITY_ID) is None + assert mock_duco_client.async_get_time_filter_remaining.await_count == 2 + state = hass.states.get(FILTER_REMAINING_ENTITY_ID) + assert state is not None + assert state.state == "180" -async def test_ventilation_temperatures_missing_skip_sensor_creation( +async def test_empty_ventilation_temperatures_are_retried( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_duco_client: AsyncMock, freezer: FrozenDateTimeFactory, ) -> None: - """Test unsupported ventilation temperatures never expose temperature states.""" + """Test empty ventilation temperatures are retried and can appear later.""" mock_duco_client.async_get_ventilation_temperature_info.side_effect = [ - DucoUnsupportedCapabilityError(400, "/info", '{"Code":3,"Result":"FAILED"}'), + VentilationTemperatureInfo(), VentilationTemperatureInfo(temp_oda=5.5), ] @@ -298,8 +300,10 @@ async def test_ventilation_temperatures_missing_skip_sensor_creation( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - for entity_id in VENTILATION_TEMPERATURE_ENTITY_IDS: - assert hass.states.get(entity_id) is None + assert mock_duco_client.async_get_ventilation_temperature_info.await_count == 2 + state = hass.states.get("sensor.living_outdoor_air_temperature") + assert state is not None + assert state.state == "5.5" async def test_partial_ventilation_temperatures_only_expose_available_sensor_values( diff --git a/tests/components/imou/test_config_flow.py b/tests/components/imou/test_config_flow.py index d6e8eaad8917b..700fe9df47fd9 100644 --- a/tests/components/imou/test_config_flow.py +++ b/tests/components/imou/test_config_flow.py @@ -31,6 +31,8 @@ macaddress="1c4d895f7a29", ) +NEW_APP_SECRET = "new_app_secret" + async def test_user_flow_success( hass: HomeAssistant, @@ -262,3 +264,92 @@ async def test_dhcp_discovery_invalid_auth( assert result["data"] == USER_INPUT assert result["result"].unique_id == USER_INPUT[CONF_APP_ID] assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_flow_success( + hass: HomeAssistant, + mock_imou_openapi_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Reauth updates the App secret and reloads the entry.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_APP_SECRET: NEW_APP_SECRET}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_APP_SECRET] == NEW_APP_SECRET + mock_imou_openapi_client.async_close.assert_awaited_once() + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (ConnectFailedException("fail"), "cannot_connect"), + (RequestFailedException("fail"), "cannot_connect"), + (InvalidAppIdOrSecretException("fail"), "invalid_auth"), + (ImouException("fail"), "unknown"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_flow_exception_then_recover( + hass: HomeAssistant, + mock_imou_openapi_client: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + expected_error: str, +) -> None: + """Errors map to stable keys; clearing the failure allows completing reauth.""" + mock_config_entry.add_to_hass(hass) + mock_imou_openapi_client.async_get_token.side_effect = side_effect + + result = await mock_config_entry.start_reauth_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_APP_SECRET: NEW_APP_SECRET}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"]["base"] == expected_error + + mock_imou_openapi_client.async_get_token.reset_mock(side_effect=True) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_APP_SECRET: NEW_APP_SECRET}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_APP_SECRET] == NEW_APP_SECRET + assert mock_imou_openapi_client.async_close.await_count == 2 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_unique_id_mismatch( + hass: HomeAssistant, + mock_imou_openapi_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Reauth aborts when the unique ID does not match the existing entry.""" + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry(mock_config_entry, unique_id="other-app-id") + + result = await mock_config_entry.start_reauth_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_APP_SECRET: NEW_APP_SECRET}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" diff --git a/tests/components/imou/test_init.py b/tests/components/imou/test_init.py index f653971c621c0..3d1f1282338fa 100644 --- a/tests/components/imou/test_init.py +++ b/tests/components/imou/test_init.py @@ -4,7 +4,7 @@ from freezegun.api import FrozenDateTimeFactory from pyimouapi.const import PARAM_STATE, PARAM_STATUS -from pyimouapi.exceptions import ImouException +from pyimouapi.exceptions import ImouException, InvalidAppIdOrSecretException from pyimouapi.ha_device import DeviceStatus, ImouHaDevice import pytest @@ -42,22 +42,34 @@ async def test_setup_and_unload_entry( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED +@pytest.mark.parametrize( + ("exception", "expected_state"), + [ + ( + InvalidAppIdOrSecretException("bad credentials"), + ConfigEntryState.SETUP_ERROR, + ), + (ImouException("cloud failure"), ConfigEntryState.SETUP_RETRY), + (TimeoutError("timeout"), ConfigEntryState.SETUP_RETRY), + (RuntimeError("unexpected"), ConfigEntryState.SETUP_RETRY), + ], +) @pytest.mark.usefixtures("mock_imou_openapi_client", "mock_imou_ha_device_manager") -async def test_setup_entry_failed_on_refresh( +async def test_setup_entry_exceptions( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_imou_ha_device_manager: AsyncMock, + exception: Exception, + expected_state: ConfigEntryState, ) -> None: - """Device fetch failure during coordinator setup surfaces as setup retry.""" - mock_imou_ha_device_manager.async_get_devices.side_effect = RuntimeError( - "Setup failed" - ) + """Test the coordinator errors while listing devices during setup.""" + mock_imou_ha_device_manager.async_get_devices.side_effect = exception mock_config_entry.add_to_hass(hass) assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_config_entry.state is expected_state @pytest.mark.usefixtures("init_integration") diff --git a/tests/components/lg_infrared/snapshots/test_button.ambr b/tests/components/lg_infrared/snapshots/test_button.ambr index d62d2c6f0188b..5dc15fe65f4fc 100644 --- a/tests/components/lg_infrared/snapshots/test_button.ambr +++ b/tests/components/lg_infrared/snapshots/test_button.ambr @@ -1,5 +1,455 @@ # serializer version: 1 -# name: test_entities[button.lg_tv_back-entry] +# name: test_entities[ac][button.lg_ac_ai_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.lg_ac_ai_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AI mode', + 'platform': 'lg_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ai_convertible', + 'unique_id': '01JTEST0000000000000000000_ai_convertible', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[ac][button.lg_ac_ai_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LG AC AI mode', + }), + 'context': , + 'entity_id': 'button.lg_ac_ai_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[ac][button.lg_ac_diagnose-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.lg_ac_diagnose', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Diagnose', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Diagnose', + 'platform': 'lg_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'diagnose', + 'unique_id': '01JTEST0000000000000000000_diagnose', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[ac][button.lg_ac_diagnose-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LG AC Diagnose', + }), + 'context': , + 'entity_id': 'button.lg_ac_diagnose', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[ac][button.lg_ac_eco_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.lg_ac_eco_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Eco mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Eco mode', + 'platform': 'lg_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'eco', + 'unique_id': '01JTEST0000000000000000000_eco', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[ac][button.lg_ac_eco_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LG AC Eco mode', + }), + 'context': , + 'entity_id': 'button.lg_ac_eco_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[ac][button.lg_ac_jet_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.lg_ac_jet_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Jet mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Jet mode', + 'platform': 'lg_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'jet', + 'unique_id': '01JTEST0000000000000000000_jet', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[ac][button.lg_ac_jet_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LG AC Jet mode', + }), + 'context': , + 'entity_id': 'button.lg_ac_jet_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[ac][button.lg_ac_start_wi_fi_pairing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.lg_ac_start_wi_fi_pairing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Start Wi-Fi pairing', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Start Wi-Fi pairing', + 'platform': 'lg_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wifi', + 'unique_id': '01JTEST0000000000000000000_wifi', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[ac][button.lg_ac_start_wi_fi_pairing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LG AC Start Wi-Fi pairing', + }), + 'context': , + 'entity_id': 'button.lg_ac_start_wi_fi_pairing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[ac][button.lg_ac_toggle_beep-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.lg_ac_toggle_beep', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Toggle beep', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Toggle beep', + 'platform': 'lg_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'audio', + 'unique_id': '01JTEST0000000000000000000_audio', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[ac][button.lg_ac_toggle_beep-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LG AC Toggle beep', + }), + 'context': , + 'entity_id': 'button.lg_ac_toggle_beep', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[ac][button.lg_ac_toggle_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.lg_ac_toggle_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Toggle light', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Toggle light', + 'platform': 'lg_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': '01JTEST0000000000000000000_light', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[ac][button.lg_ac_toggle_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LG AC Toggle light', + }), + 'context': , + 'entity_id': 'button.lg_ac_toggle_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[ac][button.lg_ac_toggle_vertical_swing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.lg_ac_toggle_vertical_swing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Toggle vertical swing', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Toggle vertical swing', + 'platform': 'lg_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'swing_v_toggle', + 'unique_id': '01JTEST0000000000000000000_swing_v_toggle', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[ac][button.lg_ac_toggle_vertical_swing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LG AC Toggle vertical swing', + }), + 'context': , + 'entity_id': 'button.lg_ac_toggle_vertical_swing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[ac][button.lg_ac_viraat_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.lg_ac_viraat_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Viraat mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Viraat mode', + 'platform': 'lg_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'viraat', + 'unique_id': '01JTEST0000000000000000000_viraat', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[ac][button.lg_ac_viraat_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LG AC Viraat mode', + }), + 'context': , + 'entity_id': 'button.lg_ac_viraat_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[tv][button.lg_tv_back-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -36,7 +486,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_back-state] +# name: test_entities[tv][button.lg_tv_back-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV Back', @@ -49,7 +499,7 @@ 'state': 'unknown', }) # --- -# name: test_entities[button.lg_tv_down-entry] +# name: test_entities[tv][button.lg_tv_down-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -86,7 +536,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_down-state] +# name: test_entities[tv][button.lg_tv_down-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV Down', @@ -99,7 +549,7 @@ 'state': 'unknown', }) # --- -# name: test_entities[button.lg_tv_exit-entry] +# name: test_entities[tv][button.lg_tv_exit-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -136,7 +586,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_exit-state] +# name: test_entities[tv][button.lg_tv_exit-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV Exit', @@ -149,7 +599,7 @@ 'state': 'unknown', }) # --- -# name: test_entities[button.lg_tv_guide-entry] +# name: test_entities[tv][button.lg_tv_guide-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -186,7 +636,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_guide-state] +# name: test_entities[tv][button.lg_tv_guide-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV Guide', @@ -199,7 +649,7 @@ 'state': 'unknown', }) # --- -# name: test_entities[button.lg_tv_hdmi_1-entry] +# name: test_entities[tv][button.lg_tv_hdmi_1-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -236,7 +686,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_hdmi_1-state] +# name: test_entities[tv][button.lg_tv_hdmi_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV HDMI 1', @@ -249,7 +699,7 @@ 'state': 'unknown', }) # --- -# name: test_entities[button.lg_tv_hdmi_2-entry] +# name: test_entities[tv][button.lg_tv_hdmi_2-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -286,7 +736,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_hdmi_2-state] +# name: test_entities[tv][button.lg_tv_hdmi_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV HDMI 2', @@ -299,7 +749,7 @@ 'state': 'unknown', }) # --- -# name: test_entities[button.lg_tv_hdmi_3-entry] +# name: test_entities[tv][button.lg_tv_hdmi_3-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -336,7 +786,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_hdmi_3-state] +# name: test_entities[tv][button.lg_tv_hdmi_3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV HDMI 3', @@ -349,7 +799,7 @@ 'state': 'unknown', }) # --- -# name: test_entities[button.lg_tv_hdmi_4-entry] +# name: test_entities[tv][button.lg_tv_hdmi_4-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -386,7 +836,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_hdmi_4-state] +# name: test_entities[tv][button.lg_tv_hdmi_4-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV HDMI 4', @@ -399,7 +849,7 @@ 'state': 'unknown', }) # --- -# name: test_entities[button.lg_tv_home-entry] +# name: test_entities[tv][button.lg_tv_home-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -436,7 +886,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_home-state] +# name: test_entities[tv][button.lg_tv_home-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV Home', @@ -449,7 +899,7 @@ 'state': 'unknown', }) # --- -# name: test_entities[button.lg_tv_info-entry] +# name: test_entities[tv][button.lg_tv_info-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -486,7 +936,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_info-state] +# name: test_entities[tv][button.lg_tv_info-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV Info', @@ -499,7 +949,7 @@ 'state': 'unknown', }) # --- -# name: test_entities[button.lg_tv_input-entry] +# name: test_entities[tv][button.lg_tv_input-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -536,7 +986,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_input-state] +# name: test_entities[tv][button.lg_tv_input-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV Input', @@ -549,7 +999,7 @@ 'state': 'unknown', }) # --- -# name: test_entities[button.lg_tv_left-entry] +# name: test_entities[tv][button.lg_tv_left-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -586,7 +1036,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_left-state] +# name: test_entities[tv][button.lg_tv_left-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV Left', @@ -599,7 +1049,7 @@ 'state': 'unknown', }) # --- -# name: test_entities[button.lg_tv_menu-entry] +# name: test_entities[tv][button.lg_tv_menu-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -636,7 +1086,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_menu-state] +# name: test_entities[tv][button.lg_tv_menu-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV Menu', @@ -649,7 +1099,7 @@ 'state': 'unknown', }) # --- -# name: test_entities[button.lg_tv_number_0-entry] +# name: test_entities[tv][button.lg_tv_number_0-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -686,7 +1136,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_number_0-state] +# name: test_entities[tv][button.lg_tv_number_0-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV Number 0', @@ -699,7 +1149,7 @@ 'state': 'unknown', }) # --- -# name: test_entities[button.lg_tv_number_1-entry] +# name: test_entities[tv][button.lg_tv_number_1-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -736,7 +1186,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_number_1-state] +# name: test_entities[tv][button.lg_tv_number_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV Number 1', @@ -749,7 +1199,7 @@ 'state': 'unknown', }) # --- -# name: test_entities[button.lg_tv_number_2-entry] +# name: test_entities[tv][button.lg_tv_number_2-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -786,7 +1236,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_number_2-state] +# name: test_entities[tv][button.lg_tv_number_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV Number 2', @@ -799,7 +1249,7 @@ 'state': 'unknown', }) # --- -# name: test_entities[button.lg_tv_number_3-entry] +# name: test_entities[tv][button.lg_tv_number_3-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -836,7 +1286,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_number_3-state] +# name: test_entities[tv][button.lg_tv_number_3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV Number 3', @@ -849,7 +1299,7 @@ 'state': 'unknown', }) # --- -# name: test_entities[button.lg_tv_number_4-entry] +# name: test_entities[tv][button.lg_tv_number_4-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -886,7 +1336,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_number_4-state] +# name: test_entities[tv][button.lg_tv_number_4-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV Number 4', @@ -899,7 +1349,7 @@ 'state': 'unknown', }) # --- -# name: test_entities[button.lg_tv_number_5-entry] +# name: test_entities[tv][button.lg_tv_number_5-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -936,7 +1386,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_number_5-state] +# name: test_entities[tv][button.lg_tv_number_5-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV Number 5', @@ -949,7 +1399,7 @@ 'state': 'unknown', }) # --- -# name: test_entities[button.lg_tv_number_6-entry] +# name: test_entities[tv][button.lg_tv_number_6-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -986,7 +1436,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_number_6-state] +# name: test_entities[tv][button.lg_tv_number_6-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV Number 6', @@ -999,7 +1449,7 @@ 'state': 'unknown', }) # --- -# name: test_entities[button.lg_tv_number_7-entry] +# name: test_entities[tv][button.lg_tv_number_7-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1036,7 +1486,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_number_7-state] +# name: test_entities[tv][button.lg_tv_number_7-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV Number 7', @@ -1049,7 +1499,7 @@ 'state': 'unknown', }) # --- -# name: test_entities[button.lg_tv_number_8-entry] +# name: test_entities[tv][button.lg_tv_number_8-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1086,7 +1536,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_number_8-state] +# name: test_entities[tv][button.lg_tv_number_8-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV Number 8', @@ -1099,7 +1549,7 @@ 'state': 'unknown', }) # --- -# name: test_entities[button.lg_tv_number_9-entry] +# name: test_entities[tv][button.lg_tv_number_9-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1136,7 +1586,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_number_9-state] +# name: test_entities[tv][button.lg_tv_number_9-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV Number 9', @@ -1149,7 +1599,7 @@ 'state': 'unknown', }) # --- -# name: test_entities[button.lg_tv_ok-entry] +# name: test_entities[tv][button.lg_tv_ok-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1186,7 +1636,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_ok-state] +# name: test_entities[tv][button.lg_tv_ok-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV OK', @@ -1199,7 +1649,7 @@ 'state': 'unknown', }) # --- -# name: test_entities[button.lg_tv_power-entry] +# name: test_entities[tv][button.lg_tv_power-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1236,7 +1686,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_power-state] +# name: test_entities[tv][button.lg_tv_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV Power', @@ -1249,7 +1699,7 @@ 'state': 'unknown', }) # --- -# name: test_entities[button.lg_tv_power_off-entry] +# name: test_entities[tv][button.lg_tv_power_off-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1286,7 +1736,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_power_off-state] +# name: test_entities[tv][button.lg_tv_power_off-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV Power off', @@ -1299,7 +1749,7 @@ 'state': 'unknown', }) # --- -# name: test_entities[button.lg_tv_power_on-entry] +# name: test_entities[tv][button.lg_tv_power_on-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1336,7 +1786,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_power_on-state] +# name: test_entities[tv][button.lg_tv_power_on-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV Power on', @@ -1349,7 +1799,7 @@ 'state': 'unknown', }) # --- -# name: test_entities[button.lg_tv_right-entry] +# name: test_entities[tv][button.lg_tv_right-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1386,7 +1836,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_right-state] +# name: test_entities[tv][button.lg_tv_right-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV Right', @@ -1399,7 +1849,7 @@ 'state': 'unknown', }) # --- -# name: test_entities[button.lg_tv_up-entry] +# name: test_entities[tv][button.lg_tv_up-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1436,7 +1886,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.lg_tv_up-state] +# name: test_entities[tv][button.lg_tv_up-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LG TV Up', diff --git a/tests/components/lg_infrared/test_button.py b/tests/components/lg_infrared/test_button.py index 6eb329c59c52f..9eed512db5314 100644 --- a/tests/components/lg_infrared/test_button.py +++ b/tests/components/lg_infrared/test_button.py @@ -1,10 +1,12 @@ """Tests for the LG Infrared button platform.""" +from infrared_protocols.codes.lg.ac import LGACCode from infrared_protocols.codes.lg.tv import LGTVCode import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.lg_infrared.const import LGDeviceType from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -21,7 +23,8 @@ def platforms() -> list[Platform]: return [Platform.BUTTON] -@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize("device_type", [LGDeviceType.TV, LGDeviceType.AC]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") async def test_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -104,3 +107,54 @@ async def test_button_availability_follows_ir_entity( """Test button becomes unavailable when IR entity is unavailable.""" entity_id = "button.lg_tv_power_on" await assert_availability_follows_source_entity(hass, entity_id, EMITTER_ENTITY_ID) + + +@pytest.mark.parametrize("device_type", [LGDeviceType.AC]) +@pytest.mark.parametrize( + ("entity_id", "expected_code"), + [ + ("button.lg_ac_jet_mode", LGACCode.JET), + ("button.lg_ac_eco_mode", LGACCode.ECO), + ("button.lg_ac_viraat_mode", LGACCode.VIRAAT), + ("button.lg_ac_ai_mode", LGACCode.AI_CONVERTIBLE), + ("button.lg_ac_toggle_light", LGACCode.LIGHT_TOGGLE), + ("button.lg_ac_start_wi_fi_pairing", LGACCode.WIFI_TOGGLE), + ("button.lg_ac_toggle_beep", LGACCode.AUDIO_TOGGLE), + ("button.lg_ac_diagnose", LGACCode.DIAGNOSE), + ("button.lg_ac_toggle_vertical_swing", LGACCode.SWING_V_TOGGLE), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_ac_button_press_sends_correct_code( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + entity_id: str, + expected_code: LGACCode, +) -> None: + """Test pressing an AC button sends the matching fixed IR code.""" + await hass.services.async_call( + BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == 1 + timings = mock_infrared_emitter_entity.send_command_calls[0].get_raw_timings() + assert timings == expected_code.to_command().get_raw_timings() + + +@pytest.mark.parametrize("device_type", [LGDeviceType.AC]) +@pytest.mark.parametrize("key", ["swing_v_toggle", "viraat"]) +@pytest.mark.usefixtures("init_integration") +async def test_ac_buttons_disabled_by_default( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + key: str, +) -> None: + """Test the buttons only some units support are registered but disabled.""" + entity_id = entity_registry.async_get_entity_id( + BUTTON_DOMAIN, "lg_infrared", f"{mock_config_entry.entry_id}_{key}" + ) + assert entity_id is not None + entry = entity_registry.async_get(entity_id) + assert entry is not None + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py index 5de1b9dad5cc5..b3e7e549654b3 100644 --- a/tests/components/music_assistant/test_media_player.py +++ b/tests/components/music_assistant/test_media_player.py @@ -52,6 +52,7 @@ ATTR_USERNAME, DOMAIN, ) +from homeassistant.components.music_assistant.media_player import MusicAssistantPlayer from homeassistant.components.music_assistant.services import ( SERVICE_GET_QUEUE, SERVICE_PLAY_ANNOUNCEMENT, @@ -81,6 +82,7 @@ from homeassistant.helpers import entity_registry as er from .common import ( + create_players_from_fixture, setup_integration_from_fixtures, snapshot_music_assistant_entities, trigger_subscription_callback, @@ -96,6 +98,31 @@ ) +@pytest.mark.parametrize( + ("mass_icon", "mdi_icon"), + [ + pytest.param("speaker", "mdi:speaker", id="speaker"), + pytest.param("speakers", "mdi:speaker-multiple", id="speakers"), + pytest.param("tv", "mdi:television", id="tv"), + pytest.param("smartphone", "mdi:cellphone", id="smartphone"), + pytest.param("google-nest", "mdi:speaker", id="fallback"), + pytest.param("mdi-speaker", "mdi:speaker", id="legacy-mdi-dash"), + pytest.param("mdi:speaker", "mdi:speaker", id="legacy-mdi-colon"), + ], +) +def test_player_icon( + music_assistant_client: MagicMock, mass_icon: str, mdi_icon: str +) -> None: + """Test Music Assistant player icon mapping.""" + player = create_players_from_fixture()[0] + player.icon = mass_icon + music_assistant_client.players._players[player.player_id] = player + + entity = MusicAssistantPlayer(music_assistant_client, player.player_id) + + assert entity.icon == mdi_icon + + async def test_media_player( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/mysensors/test_init.py b/tests/components/mysensors/test_init.py index 5f1b5889aac2f..587e04f4b1440 100644 --- a/tests/components/mysensors/test_init.py +++ b/tests/components/mysensors/test_init.py @@ -5,6 +5,7 @@ from mysensors import BaseSyncGateway from mysensors.sensor import Sensor +import pytest from homeassistant.components.mysensors import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -61,6 +62,72 @@ async def test_load_unload( assert state.state == STATE_UNAVAILABLE +@pytest.mark.usefixtures("door_sensor") +async def test_reload( + hass: HomeAssistant, + transport: MagicMock, + integration: MockConfigEntry, +) -> None: + """Test reloading the MySensors config entry recreates entities.""" + config_entry = integration + + entity_id = "binary_sensor.door_sensor_1_1" + state = hass.states.get(entity_id) + + assert state + assert state.state != STATE_UNAVAILABLE + + assert await hass.config_entries.async_reload(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.LOADED + assert transport.return_value.disconnect.call_count == 1 + + state = hass.states.get(entity_id) + + assert state + assert state.state != STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("text_node", "integration") +async def test_disabling_entity_keeps_other_platforms_dev_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + receive_message: Callable[[str], None], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that disabling one entity does not discard another platform's dev id. + + An S_INFO/V_TEXT child is set up on both the sensor and text platforms, sharing + the same dev id. Disabling only the text entity must not discard that dev id + for the sensor platform too, or the next message for it would make the + integration attempt to recreate the still-loaded sensor entity as a duplicate. + """ + sensor_entity_id = "sensor.text_node_1_1" + text_entity_id = "text.text_node_1_1" + + assert hass.states.get(sensor_entity_id) + assert hass.states.get(text_entity_id) + + entity_registry.async_update_entity( + text_entity_id, disabled_by=er.RegistryEntryDisabler.USER + ) + await hass.async_block_till_done() + + assert not hass.states.get(text_entity_id) + assert hass.states.get(sensor_entity_id) + + receive_message("1;1;1;0;47;test\n") + await hass.async_block_till_done() + + assert "already exists" not in caplog.text + assert hass.states.get(sensor_entity_id) + assert not hass.states.get(text_entity_id) + + text_entry = entity_registry.async_get(text_entity_id) + assert text_entry + assert text_entry.disabled_by is er.RegistryEntryDisabler.USER + + async def test_remove_config_entry_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, diff --git a/tests/components/satel_integra/conftest.py b/tests/components/satel_integra/conftest.py index 96ae6637134a3..c55a8a6f055ae 100644 --- a/tests/components/satel_integra/conftest.py +++ b/tests/components/satel_integra/conftest.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from satel_integra import SatelFirmwareVersion, SatelPanelInfo, SatelPanelModel from homeassistant.components.satel_integra.config_flow import SatelConfigFlow from homeassistant.components.satel_integra.const import DOMAIN @@ -64,6 +65,17 @@ def mock_satel() -> Generator[AsyncMock]: client.violated_zones = [] client.connect = AsyncMock(return_value=True) + client.read_panel_info = AsyncMock( + return_value=SatelPanelInfo( + type_code=2, + model=SatelPanelModel("INTEGRA 64"), + firmware=SatelFirmwareVersion( + version="1.24", release_date="2025-03-12" + ), + language_code=0, + settings_stored_in_flash=True, + ) + ) client.read_temperature = AsyncMock(return_value=21.5) client.read_temperatures = AsyncMock(return_value={1: 21.5}) client.set_output = AsyncMock() diff --git a/tests/components/satel_integra/snapshots/test_diagnostics.ambr b/tests/components/satel_integra/snapshots/test_diagnostics.ambr index 221596f2602b0..3d0aa7b892b23 100644 --- a/tests/components/satel_integra/snapshots/test_diagnostics.ambr +++ b/tests/components/satel_integra/snapshots/test_diagnostics.ambr @@ -9,6 +9,18 @@ 'config_entry_options': dict({ 'code': '**REDACTED**', }), + 'panel_info': dict({ + 'firmware': dict({ + 'release_date': '2025-03-12', + 'version': '1.24', + }), + 'language_code': 0, + 'model': dict({ + 'name': 'INTEGRA 64', + }), + 'settings_stored_in_flash': True, + 'type_code': 2, + }), 'subentries': dict({ 'ID_OUTPUT': dict({ 'data': dict({ @@ -67,6 +79,18 @@ 'config_entry_options': dict({ 'code': '**REDACTED**', }), + 'panel_info': dict({ + 'firmware': dict({ + 'release_date': '2025-03-12', + 'version': '1.24', + }), + 'language_code': 0, + 'model': dict({ + 'name': 'INTEGRA 64', + }), + 'settings_stored_in_flash': True, + 'type_code': 2, + }), 'subentries': dict({ 'ID_ZONE': dict({ 'data': dict({ diff --git a/tests/components/satel_integra/snapshots/test_init.ambr b/tests/components/satel_integra/snapshots/test_init.ambr index a88c6922cc6b0..c150f1550ca9a 100644 --- a/tests/components/satel_integra/snapshots/test_init.ambr +++ b/tests/components/satel_integra/snapshots/test_init.ambr @@ -20,12 +20,12 @@ 'labels': set({ }), 'manufacturer': 'Satel', - 'model': None, + 'model': 'INTEGRA 64', 'model_id': None, 'name': '192.168.0.2', 'name_by_user': None, 'serial_number': None, - 'sw_version': None, + 'sw_version': '1.24 (2025-03-12)', 'via_device_id': None, }) # --- diff --git a/tests/components/satel_integra/test_diagnostics.py b/tests/components/satel_integra/test_diagnostics.py index 7ac3ab78c46d4..bf8d60bf2ea22 100644 --- a/tests/components/satel_integra/test_diagnostics.py +++ b/tests/components/satel_integra/test_diagnostics.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock import pytest +from satel_integra import SatelUnexpectedResponseError from syrupy.assertion import SnapshotAssertion from syrupy.filters import props @@ -10,6 +11,7 @@ from . import setup_integration +from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -35,3 +37,21 @@ async def test_diagnostics( diagnostics = await get_diagnostics_for_config_entry(hass, hass_client, entry) assert diagnostics == snapshot(exclude=props("created_at", "modified_at", "id")) + + +async def test_diagnostics_without_panel_info( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_satel: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test diagnostics when panel information could not be read during setup.""" + mock_satel.read_panel_info.side_effect = SatelUnexpectedResponseError + await setup_integration(hass, mock_config_entry) + + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert diagnostics["panel_info"] is None + mock_satel.read_panel_info.assert_awaited_once_with() diff --git a/tests/components/satel_integra/test_init.py b/tests/components/satel_integra/test_init.py index 8fcd61b5db7e9..8bb43d3427883 100644 --- a/tests/components/satel_integra/test_init.py +++ b/tests/components/satel_integra/test_init.py @@ -8,6 +8,7 @@ SatelConnectFailedError, SatelConnectionInitializationError, SatelPanelBusyError, + SatelUnexpectedResponseError, ) from syrupy.assertion import SnapshotAssertion @@ -222,6 +223,27 @@ async def test_parent_device_exists( (DOMAIN, MOCK_ENTRY_ID), mock_config_entry.entry_id ) assert device_entry == snapshot(name="parent-device") + mock_satel.read_panel_info.assert_awaited_once_with() + + +async def test_panel_info_read_error( + hass: HomeAssistant, + mock_satel: AsyncMock, + device_registry: DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a panel information read error does not prevent setup.""" + mock_satel.read_panel_info.side_effect = SatelUnexpectedResponseError + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + device_entry = device_registry.async_get_device_by_identifier( + (DOMAIN, MOCK_ENTRY_ID), mock_config_entry.entry_id + ) + assert device_entry is not None + assert device_entry.model is None + assert device_entry.sw_version is None @pytest.mark.parametrize( diff --git a/tests/components/subaru/test_button.py b/tests/components/subaru/test_button.py index 09e30054b2fec..a805bf823bc01 100644 --- a/tests/components/subaru/test_button.py +++ b/tests/components/subaru/test_button.py @@ -10,7 +10,7 @@ VEHICLE_HAS_EV, VEHICLE_HAS_REMOTE_START, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -299,3 +299,15 @@ async def test_no_buttons_without_remote_start( assert entry is None entry = entity_registry.async_get(VEHICLE_BUTTONS[TEST_VIN_3_G3]["remote_stop"]) assert entry is None + + +async def test_button_unavailable_on_fetch_failure( + hass: HomeAssistant, subaru_config_entry: MockConfigEntry +) -> None: + """Test button goes unavailable when the coordinator fails to fetch data.""" + await setup_subaru_config_entry( + hass, subaru_config_entry, fetch_effect=SubaruException("403 Error") + ) + + state = hass.states.get(VEHICLE_BUTTONS[TEST_VIN_2_EV]["remote_start"]) + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/subaru/test_lock.py b/tests/components/subaru/test_lock.py index fd0b6fcc823b4..2984f90111c0f 100644 --- a/tests/components/subaru/test_lock.py +++ b/tests/components/subaru/test_lock.py @@ -3,6 +3,7 @@ from unittest.mock import patch import pytest +from subarulink import SubaruException from voluptuous.error import MultipleInvalid from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN @@ -12,12 +13,19 @@ SERVICE_UNLOCK_SPECIFIC_DOOR, UNLOCK_DOOR_DRIVERS, ) -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_LOCK, SERVICE_UNLOCK +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_LOCK, + SERVICE_UNLOCK, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from .conftest import MOCK_API +from .conftest import MOCK_API, setup_subaru_config_entry + +from tests.common import MockConfigEntry MOCK_API_LOCK = f"{MOCK_API}lock" MOCK_API_UNLOCK = f"{MOCK_API}unlock" @@ -87,3 +95,15 @@ async def test_unlock_specific_door_invalid(hass: HomeAssistant, ev_entry) -> No blocking=True, ) mock_unlock.assert_not_called() + + +async def test_lock_unavailable_on_fetch_failure( + hass: HomeAssistant, subaru_config_entry: MockConfigEntry +) -> None: + """Test lock goes unavailable when the coordinator fails to fetch data.""" + await setup_subaru_config_entry( + hass, subaru_config_entry, fetch_effect=SubaruException("403 Error") + ) + + state = hass.states.get(DEVICE_ID) + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/vistapool/conftest.py b/tests/components/vistapool/conftest.py index 9d35cc16c46df..cc5ad5dd8e120 100644 --- a/tests/components/vistapool/conftest.py +++ b/tests/components/vistapool/conftest.py @@ -46,6 +46,9 @@ def mock_vistapool_auth() -> Generator[MagicMock]: """Mock `AquariteAuth` across the config flow and the integration setup.""" auth = MagicMock() auth.authenticate = AsyncMock() + # Home Assistant turns a truthy on-unload return value into a task, so this + # has to mirror the real method and return None. + auth.close = MagicMock(return_value=None) auth.user_id = MOCK_USER_ID auth.is_token_expiring = MagicMock(return_value=False) auth.calculate_sleep_duration = MagicMock(return_value=3600) diff --git a/tests/components/vistapool/test_config_flow.py b/tests/components/vistapool/test_config_flow.py index 4807d9cff2e57..5b38d7b77d3a8 100644 --- a/tests/components/vistapool/test_config_flow.py +++ b/tests/components/vistapool/test_config_flow.py @@ -451,3 +451,49 @@ async def test_credential_update_error_paths( assert result["type"] is FlowResultType.ABORT assert result["reason"] == success_reason assert mock_setup_entry.call_count == 1 + + +@pytest.mark.parametrize( + "authenticate_error", + [ + pytest.param(None, id="success"), + pytest.param(AuthenticationError, id="invalid_auth"), + pytest.param(AquariteError, id="cannot_connect"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry", "mock_vistapool_client") +async def test_user_step_closes_firestore_clients( + hass: HomeAssistant, + mock_vistapool_auth: MagicMock, + authenticate_error: type[Exception] | None, +) -> None: + """Test the user step releases the Firestore channels on every outcome.""" + mock_vistapool_auth.authenticate.side_effect = authenticate_error + + await _configure(hass) + + mock_vistapool_auth.close.assert_called_once() + + +@pytest.mark.parametrize(("flow_starter", "step_id", "success_reason"), _FLOW_PARAMS) +@pytest.mark.usefixtures("mock_setup_entry", "mock_vistapool_client") +async def test_credential_update_closes_firestore_clients( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_auth: MagicMock, + flow_starter: Any, + step_id: str, + success_reason: str, +) -> None: + """Test reauth and reconfigure release the Firestore channels.""" + mock_config_entry.add_to_hass(hass) + + result = await flow_starter(mock_config_entry, hass) + assert result["step_id"] == step_id + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: _NEW_PASSWORD} + ) + + assert result["reason"] == success_reason + mock_vistapool_auth.close.assert_called_once() diff --git a/tests/components/vistapool/test_init.py b/tests/components/vistapool/test_init.py index 31bd531a833df..24a64c5a689d1 100644 --- a/tests/components/vistapool/test_init.py +++ b/tests/components/vistapool/test_init.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock from aioaquarite import AquariteError, AuthenticationError +import pytest from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.vistapool.const import DOMAIN @@ -409,3 +410,50 @@ async def test_unload_entry( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_unload_closes_firestore_clients( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_auth: MagicMock, + mock_vistapool_client: AsyncMock, +) -> None: + """Test unloading releases the Firestore gRPC channels.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + mock_vistapool_auth.close.assert_not_called() + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_vistapool_auth.close.assert_called_once() + + +@pytest.mark.parametrize( + ("owner", "method", "exception"), + [ + pytest.param("auth", "authenticate", AuthenticationError, id="auth_rejected"), + pytest.param("auth", "authenticate", AquariteError, id="auth_unreachable"), + pytest.param("client", "get_pools", AquariteError, id="pools_unreachable"), + ], +) +async def test_failed_setup_closes_firestore_clients( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_auth: MagicMock, + mock_vistapool_client: AsyncMock, + owner: str, + method: str, + exception: type[Exception], +) -> None: + """Test a setup that never completes still releases the Firestore channels.""" + mocks = {"auth": mock_vistapool_auth, "client": mock_vistapool_client} + getattr(mocks[owner], method).side_effect = exception + mock_config_entry.add_to_hass(hass) + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_vistapool_auth.close.assert_called_once() diff --git a/tests/components/zonneplan/snapshots/test_diagnostics.ambr b/tests/components/zonneplan/snapshots/test_diagnostics.ambr new file mode 100644 index 0000000000000..dc9f06a993270 --- /dev/null +++ b/tests/components/zonneplan/snapshots/test_diagnostics.ambr @@ -0,0 +1,1012 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'account': dict({ + 'address_groups': list([ + dict({ + 'address': dict({ + 'addition': '', + 'city': '**REDACTED**', + 'id': '1234AB1', + 'number': '**REDACTED**', + 'street': '**REDACTED**', + 'sunrise': '2026-08-29T04:39:24+00:00', + 'sunset': '2026-08-29T18:30:23+00:00', + 'zipcode': '**REDACTED**', + }), + 'connections': list([ + dict({ + 'contracts': list([ + dict({ + 'end_date': None, + 'label': 'Stroom tegen uurprijzen', + 'meta': dict({ + 'agreement_date': '2026-08-29', + 'contract_type': 'smart', + 'deposit_type': 'fixed', + 'end_reason': None, + 'expected_delivery': 4470, + 'expected_production': 2520, + 'external_contract_id': '**REDACTED**', + 'gas_price_ceiling_contract_end_date': None, + 'gas_price_ceiling_contract_start_date': None, + 'monthly_advanced_deposit_amount': 930000000, + 'original_end_date': None, + 'proposition_reference': 'vast-voorschot-e-2025-12-kwart', + 'show_in_contract_screen': True, + 'start_reason': None, + }), + 'start_date': '2026-09-17T22:00:00+00:00', + 'type': 'electricity', + 'uuid': '**REDACTED**', + }), + ]), + 'ean': '**REDACTED**', + 'features': list([ + dict({ + 'code': 'E004', + 'label': 'Verbruikshistorie', + 'start_date': '2026-08-29', + }), + ]), + 'market_segment': 'electricity', + 'uuid': '**REDACTED**', + }), + dict({ + 'contracts': list([ + dict({ + 'end_date': None, + 'label': 'Gas tegen dagprijzen', + 'meta': dict({ + 'agreement_date': '2026-08-29', + 'contract_type': 'smart', + 'deposit_type': 'fixed', + 'end_reason': None, + 'expected_delivery': 2171, + 'expected_production': None, + 'external_contract_id': '**REDACTED**', + 'gas_price_ceiling_contract_end_date': None, + 'gas_price_ceiling_contract_start_date': None, + 'monthly_advanced_deposit_amount': 3500000000, + 'original_end_date': None, + 'proposition_reference': 'vast-voorschot-g-2024-11', + 'show_in_contract_screen': True, + 'start_reason': None, + }), + 'start_date': '2026-09-17T22:00:00+00:00', + 'type': 'gas', + 'uuid': '**REDACTED**', + }), + ]), + 'ean': '**REDACTED**', + 'features': list([ + ]), + 'market_segment': 'gas', + 'uuid': '**REDACTED**', + }), + ]), + 'is_representative': True, + 'organization_uuid': '00000000-0000-4000-8000-000000000008', + 'uuid': '**REDACTED**', + }), + ]), + 'user_account': dict({ + 'email': '**REDACTED**', + 'first_name': '**REDACTED**', + 'full_name': '**REDACTED**', + 'initials': '', + 'is_representative': False, + 'uuid': '**REDACTED**', + }), + }), + 'connections': list([ + dict({ + 'contracts': list([ + dict({ + 'end_date': None, + 'label': 'Stroom tegen uurprijzen', + 'meta': dict({ + 'agreement_date': '2026-08-29', + 'contract_type': 'smart', + 'deposit_type': 'fixed', + 'end_reason': None, + 'expected_delivery': 4470, + 'expected_production': 2520, + 'external_contract_id': '**REDACTED**', + 'gas_price_ceiling_contract_end_date': None, + 'gas_price_ceiling_contract_start_date': None, + 'monthly_advanced_deposit_amount': 930000000, + 'original_end_date': None, + 'proposition_reference': 'vast-voorschot-e-2025-12-kwart', + 'show_in_contract_screen': True, + 'start_reason': None, + }), + 'start_date': '2026-09-17T22:00:00+00:00', + 'type': 'electricity', + 'uuid': '**REDACTED**', + }), + ]), + 'ean': '**REDACTED**', + 'features': list([ + dict({ + 'code': 'E004', + 'label': 'Verbruikshistorie', + 'start_date': '2026-08-29', + }), + ]), + 'market_segment': 'electricity', + 'uuid': '**REDACTED**', + }), + dict({ + 'contracts': list([ + dict({ + 'end_date': None, + 'label': 'Gas tegen dagprijzen', + 'meta': dict({ + 'agreement_date': '2026-08-29', + 'contract_type': 'smart', + 'deposit_type': 'fixed', + 'end_reason': None, + 'expected_delivery': 2171, + 'expected_production': None, + 'external_contract_id': '**REDACTED**', + 'gas_price_ceiling_contract_end_date': None, + 'gas_price_ceiling_contract_start_date': None, + 'monthly_advanced_deposit_amount': 3500000000, + 'original_end_date': None, + 'proposition_reference': 'vast-voorschot-g-2024-11', + 'show_in_contract_screen': True, + 'start_reason': None, + }), + 'start_date': '2026-09-17T22:00:00+00:00', + 'type': 'gas', + 'uuid': '**REDACTED**', + }), + ]), + 'ean': '**REDACTED**', + 'features': list([ + ]), + 'market_segment': 'gas', + 'uuid': '**REDACTED**', + }), + ]), + 'electricity_prices': dict({ + 'chart': dict({ + 'range': dict({ + 'end_date': '2026-08-30T23:59:59+02:00', + 'start_date': '2026-08-28T15:00:00+02:00', + }), + 'series': dict({ + 'prices': list([ + dict({ + 'end_date': '2026-08-28T14:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 1496060, + }), + 'price_tax_included': dict({ + 'amount': 2604541, + }), + 'start_date': '2026-08-28T13:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 1000, + }), + 'tariff_group': 'normal', + }), + dict({ + 'end_date': '2026-08-28T15:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 1544823, + }), + 'price_tax_included': dict({ + 'amount': 2653304, + }), + 'start_date': '2026-08-28T14:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 1000, + }), + 'tariff_group': 'normal', + }), + dict({ + 'end_date': '2026-08-28T16:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 1850016, + }), + 'price_tax_included': dict({ + 'amount': 2958497, + }), + 'start_date': '2026-08-28T15:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 1000, + }), + 'tariff_group': 'normal', + }), + dict({ + 'end_date': '2026-08-28T17:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 2123385, + }), + 'price_tax_included': dict({ + 'amount': 3231866, + }), + 'start_date': '2026-08-28T16:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 1000, + }), + 'tariff_group': 'normal', + }), + dict({ + 'end_date': '2026-08-28T18:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 2281199, + }), + 'price_tax_included': dict({ + 'amount': 3389680, + }), + 'start_date': '2026-08-28T17:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 896, + }), + 'tariff_group': 'high', + }), + dict({ + 'end_date': '2026-08-28T19:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 2335679, + }), + 'price_tax_included': dict({ + 'amount': 3444160, + }), + 'start_date': '2026-08-28T18:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 747, + }), + 'tariff_group': 'high', + }), + dict({ + 'end_date': '2026-08-28T20:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 2210867, + }), + 'price_tax_included': dict({ + 'amount': 3319348, + }), + 'start_date': '2026-08-28T19:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 633, + }), + 'tariff_group': 'high', + }), + dict({ + 'end_date': '2026-08-28T21:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 2156509, + }), + 'price_tax_included': dict({ + 'amount': 3264990, + }), + 'start_date': '2026-08-28T20:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 689, + }), + 'tariff_group': 'normal', + }), + dict({ + 'end_date': '2026-08-28T22:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 1922253, + }), + 'price_tax_included': dict({ + 'amount': 3030734, + }), + 'start_date': '2026-08-28T21:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 712, + }), + 'tariff_group': 'normal', + }), + dict({ + 'end_date': '2026-08-28T23:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 1753819, + }), + 'price_tax_included': dict({ + 'amount': 2862300, + }), + 'start_date': '2026-08-28T22:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 729, + }), + 'tariff_group': 'normal', + }), + dict({ + 'end_date': '2026-08-29T00:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 1551176, + }), + 'price_tax_included': dict({ + 'amount': 2659657, + }), + 'start_date': '2026-08-28T23:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 781, + }), + 'tariff_group': 'normal', + }), + dict({ + 'end_date': '2026-08-29T01:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 1409061, + }), + 'price_tax_included': dict({ + 'amount': 2517542, + }), + 'start_date': '2026-08-29T00:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 776, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-29T02:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 1320550, + }), + 'price_tax_included': dict({ + 'amount': 2429031, + }), + 'start_date': '2026-08-29T01:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 776, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-29T03:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 1314863, + }), + 'price_tax_included': dict({ + 'amount': 2423344, + }), + 'start_date': '2026-08-29T02:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 672, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-29T04:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 1358635, + }), + 'price_tax_included': dict({ + 'amount': 2467116, + }), + 'start_date': '2026-08-29T03:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 675, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-29T05:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 1380899, + }), + 'price_tax_included': dict({ + 'amount': 2489380, + }), + 'start_date': '2026-08-29T04:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 678, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-29T06:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 1425215, + }), + 'price_tax_included': dict({ + 'amount': 2533696, + }), + 'start_date': '2026-08-29T05:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 674, + }), + 'tariff_group': 'normal', + }), + dict({ + 'end_date': '2026-08-29T07:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 934530, + }), + 'price_tax_included': dict({ + 'amount': 2043011, + }), + 'start_date': '2026-08-29T06:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 748, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-29T08:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 263010, + }), + 'price_tax_included': dict({ + 'amount': 1371491, + }), + 'start_date': '2026-08-29T07:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 993, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-29T09:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 201571, + }), + 'price_tax_included': dict({ + 'amount': 1310052, + }), + 'start_date': '2026-08-29T08:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 1000, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-29T10:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 192921, + }), + 'price_tax_included': dict({ + 'amount': 1301402, + }), + 'start_date': '2026-08-29T09:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 1000, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-29T11:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 184481, + }), + 'price_tax_included': dict({ + 'amount': 1292962, + }), + 'start_date': '2026-08-29T10:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 1000, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-29T12:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 180760, + }), + 'price_tax_included': dict({ + 'amount': 1289241, + }), + 'start_date': '2026-08-29T11:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 1000, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-29T13:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 193737, + }), + 'price_tax_included': dict({ + 'amount': 1302218, + }), + 'start_date': '2026-08-29T12:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 1000, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-29T14:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 235664, + }), + 'price_tax_included': dict({ + 'amount': 1344145, + }), + 'start_date': '2026-08-29T13:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 1000, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-29T15:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 483442, + }), + 'price_tax_included': dict({ + 'amount': 1591923, + }), + 'start_date': '2026-08-29T14:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 1000, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-29T16:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 1434290, + }), + 'price_tax_included': dict({ + 'amount': 2542771, + }), + 'start_date': '2026-08-29T15:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 1000, + }), + 'tariff_group': 'normal', + }), + dict({ + 'end_date': '2026-08-29T17:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 2031334, + }), + 'price_tax_included': dict({ + 'amount': 3139815, + }), + 'start_date': '2026-08-29T16:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 1000, + }), + 'tariff_group': 'normal', + }), + dict({ + 'end_date': '2026-08-29T18:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 2450386, + }), + 'price_tax_included': dict({ + 'amount': 3558867, + }), + 'start_date': '2026-08-29T17:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 815, + }), + 'tariff_group': 'high', + }), + dict({ + 'end_date': '2026-08-29T19:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 2473105, + }), + 'price_tax_included': dict({ + 'amount': 3581586, + }), + 'start_date': '2026-08-29T18:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 681, + }), + 'tariff_group': 'high', + }), + dict({ + 'end_date': '2026-08-29T20:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 2279566, + }), + 'price_tax_included': dict({ + 'amount': 3388047, + }), + 'start_date': '2026-08-29T19:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 612, + }), + 'tariff_group': 'high', + }), + dict({ + 'end_date': '2026-08-29T21:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 2118696, + }), + 'price_tax_included': dict({ + 'amount': 3227177, + }), + 'start_date': '2026-08-29T20:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 614, + }), + 'tariff_group': 'normal', + }), + dict({ + 'end_date': '2026-08-29T22:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 1934685, + }), + 'price_tax_included': dict({ + 'amount': 3043166, + }), + 'start_date': '2026-08-29T21:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 648, + }), + 'tariff_group': 'normal', + }), + dict({ + 'end_date': '2026-08-29T23:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 1617181, + }), + 'price_tax_included': dict({ + 'amount': 2725662, + }), + 'start_date': '2026-08-29T22:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 677, + }), + 'tariff_group': 'normal', + }), + dict({ + 'end_date': '2026-08-30T00:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 1442064, + }), + 'price_tax_included': dict({ + 'amount': 2550545, + }), + 'start_date': '2026-08-29T23:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 756, + }), + 'tariff_group': 'normal', + }), + dict({ + 'end_date': '2026-08-30T01:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 1243382, + }), + 'price_tax_included': dict({ + 'amount': 2351863, + }), + 'start_date': '2026-08-30T00:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 780, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-30T02:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 1125377, + }), + 'price_tax_included': dict({ + 'amount': 2233858, + }), + 'start_date': '2026-08-30T01:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 824, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-30T03:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 1022527, + }), + 'price_tax_included': dict({ + 'amount': 2131008, + }), + 'start_date': '2026-08-30T02:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 835, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-30T04:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 1064363, + }), + 'price_tax_included': dict({ + 'amount': 2172844, + }), + 'start_date': '2026-08-30T03:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 846, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-30T05:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 1064695, + }), + 'price_tax_included': dict({ + 'amount': 2173176, + }), + 'start_date': '2026-08-30T04:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 860, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-30T06:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 915775, + }), + 'price_tax_included': dict({ + 'amount': 2024256, + }), + 'start_date': '2026-08-30T05:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 853, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-30T07:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 395443, + }), + 'price_tax_included': dict({ + 'amount': 1503924, + }), + 'start_date': '2026-08-30T06:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 889, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-30T08:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 203296, + }), + 'price_tax_included': dict({ + 'amount': 1311777, + }), + 'start_date': '2026-08-30T07:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 1000, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-30T09:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 198910, + }), + 'price_tax_included': dict({ + 'amount': 1307391, + }), + 'start_date': '2026-08-30T08:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 1000, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-30T10:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 198185, + }), + 'price_tax_included': dict({ + 'amount': 1306666, + }), + 'start_date': '2026-08-30T09:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 1000, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-30T11:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 190622, + }), + 'price_tax_included': dict({ + 'amount': 1299103, + }), + 'start_date': '2026-08-30T10:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 1000, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-30T12:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 187415, + }), + 'price_tax_included': dict({ + 'amount': 1295896, + }), + 'start_date': '2026-08-30T11:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 1000, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-30T13:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 187627, + }), + 'price_tax_included': dict({ + 'amount': 1296108, + }), + 'start_date': '2026-08-30T12:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 1000, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-30T14:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 198547, + }), + 'price_tax_included': dict({ + 'amount': 1307028, + }), + 'start_date': '2026-08-30T13:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 1000, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-30T15:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 205777, + }), + 'price_tax_included': dict({ + 'amount': 1314258, + }), + 'start_date': '2026-08-30T14:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 1000, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-30T16:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 886251, + }), + 'price_tax_included': dict({ + 'amount': 1994732, + }), + 'start_date': '2026-08-30T15:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 1000, + }), + 'tariff_group': 'low', + }), + dict({ + 'end_date': '2026-08-30T17:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 2100213, + }), + 'price_tax_included': dict({ + 'amount': 3208694, + }), + 'start_date': '2026-08-30T16:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 898, + }), + 'tariff_group': 'normal', + }), + dict({ + 'end_date': '2026-08-30T18:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 2427095, + }), + 'price_tax_included': dict({ + 'amount': 3535576, + }), + 'start_date': '2026-08-30T17:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 584, + }), + 'tariff_group': 'high', + }), + dict({ + 'end_date': '2026-08-30T19:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 2539080, + }), + 'price_tax_included': dict({ + 'amount': 3647561, + }), + 'start_date': '2026-08-30T18:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 409, + }), + 'tariff_group': 'high', + }), + dict({ + 'end_date': '2026-08-30T20:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 2536055, + }), + 'price_tax_included': dict({ + 'amount': 3644536, + }), + 'start_date': '2026-08-30T19:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 314, + }), + 'tariff_group': 'high', + }), + dict({ + 'end_date': '2026-08-30T21:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 2396089, + }), + 'price_tax_included': dict({ + 'amount': 3504570, + }), + 'start_date': '2026-08-30T20:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 348, + }), + 'tariff_group': 'high', + }), + dict({ + 'end_date': '2026-08-30T22:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 2233071, + }), + 'price_tax_included': dict({ + 'amount': 3341552, + }), + 'start_date': '2026-08-30T21:00:00+00:00', + 'sustainability_score': dict({ + 'permille': 374, + }), + 'tariff_group': 'high', + }), + ]), + }), + }), + }), + 'entry_data': dict({ + 'email': '**REDACTED**', + 'token': '**REDACTED**', + }), + 'gas_prices': dict({ + 'chart': dict({ + 'range': dict({ + 'end_date': '2026-08-30T23:59:59+02:00', + 'start_date': '2026-08-28T19:00:00+02:00', + }), + 'series': dict({ + 'prices': list([ + dict({ + 'end_date': '2026-08-30T04:00:00+00:00', + 'price_tax_excluded': dict({ + 'amount': 8761816, + }), + 'price_tax_included': dict({ + 'amount': 16029802, + }), + 'start_date': '2026-08-29T04:00:00+00:00', + 'sustainability_score': None, + 'tariff_group': None, + }), + ]), + }), + }), + }), + }) +# --- diff --git a/tests/components/zonneplan/test_config_flow.py b/tests/components/zonneplan/test_config_flow.py index f0e3b3e730842..78ae6e3ec0a4f 100644 --- a/tests/components/zonneplan/test_config_flow.py +++ b/tests/components/zonneplan/test_config_flow.py @@ -1,9 +1,12 @@ """Test the Zonneplan config flow.""" +from dataclasses import replace +from datetime import UTC, datetime from unittest.mock import AsyncMock import pytest from pyzonneplan import ( + Token, ZonneplanConnectionError, ZonneplanInvalidOtpError, ZonneplanTimeoutError, @@ -15,10 +18,17 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .conftest import MOCK_ACCOUNT, MOCK_USER_INPUT +from .conftest import MOCK_ACCOUNT, MOCK_EMAIL, MOCK_USER_INPUT from tests.common import MockConfigEntry +MOCK_OTHER_ACCOUNT = replace( + MOCK_ACCOUNT, + user_account=replace( + MOCK_ACCOUNT.user_account, uuid="00000000-0000-4000-8000-00000000000f" + ), +) + @pytest.mark.usefixtures("mock_setup_entry") async def test_full_flow(hass: HomeAssistant, mock_zonneplan_client: AsyncMock) -> None: @@ -165,3 +175,116 @@ async def test_already_configured( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zonneplan_client: AsyncMock, +) -> None: + """Test the reauthentication flow refreshes the stored token.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"] == { + CONF_EMAIL: MOCK_EMAIL, + "name": mock_config_entry.title, + } + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "otp" + mock_zonneplan_client.async_request_otp.assert_called_once() + + new_token = Token( + access_token="new-access-token", + refresh_token="new-refresh-token", + expires_at=datetime(2031, 1, 1, tzinfo=UTC), + ) + mock_zonneplan_client.async_submit_otp.return_value = new_token + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"otp": "123456"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_EMAIL] == MOCK_EMAIL + assert mock_config_entry.data[CONF_TOKEN] == new_token.as_dict() + + +@pytest.mark.parametrize( + ("exception", "reason"), + [ + pytest.param(ZonneplanConnectionError("offline"), "cannot_connect"), + pytest.param(ZonneplanTimeoutError("timed out"), "timeout_connect"), + pytest.param(Exception("unexpected"), "unknown"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_confirm_exceptions( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zonneplan_client: AsyncMock, + exception: Exception, + reason: str, +) -> None: + """Test we handle all reauth confirm step exceptions.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + mock_zonneplan_client.async_request_otp.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": reason} + + mock_zonneplan_client.async_request_otp.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "otp" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"otp": "123456"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_wrong_account( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zonneplan_client: AsyncMock, +) -> None: + """Test reauthenticating against a different account is aborted.""" + mock_config_entry.add_to_hass(hass) + mock_zonneplan_client.async_get_account.return_value = MOCK_OTHER_ACCOUNT + + result = await mock_config_entry.start_reauth_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"otp": "123456"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" diff --git a/tests/components/zonneplan/test_diagnostics.py b/tests/components/zonneplan/test_diagnostics.py new file mode 100644 index 0000000000000..e6d341c8e8d99 --- /dev/null +++ b/tests/components/zonneplan/test_diagnostics.py @@ -0,0 +1,26 @@ +"""Tests for the Zonneplan diagnostics platform.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the diagnostics for a config entry.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) diff --git a/tests/components/zonneplan/test_init.py b/tests/components/zonneplan/test_init.py index da4cbbfae7214..6fec8b121afd1 100644 --- a/tests/components/zonneplan/test_init.py +++ b/tests/components/zonneplan/test_init.py @@ -20,11 +20,23 @@ @pytest.mark.parametrize( - "exception", + ("exception", "expected_state"), [ - ZonneplanAuthenticationError("bad token"), - ZonneplanTimeoutError("timed out"), - ZonneplanConnectionError("boom"), + pytest.param( + ZonneplanAuthenticationError("bad token"), + ConfigEntryState.SETUP_ERROR, + id="authentication_error", + ), + pytest.param( + ZonneplanTimeoutError("timed out"), + ConfigEntryState.SETUP_RETRY, + id="timeout_error", + ), + pytest.param( + ZonneplanConnectionError("boom"), + ConfigEntryState.SETUP_RETRY, + id="connection_error", + ), ], ) async def test_setup_entry_update_failed( @@ -32,6 +44,7 @@ async def test_setup_entry_update_failed( mock_config_entry: MockConfigEntry, mock_zonneplan_client: AsyncMock, exception: Exception, + expected_state: ConfigEntryState, ) -> None: """Test errors while fetching data mark the entry for retry.""" mock_zonneplan_client.async_get_account.side_effect = exception @@ -40,7 +53,7 @@ async def test_setup_entry_update_failed( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_config_entry.state is expected_state async def test_setup_entry_persists_rotated_token( diff --git a/tests/e2e/package.json b/tests/e2e/package.json index 9a8abca50c668..fdf6cc120b7f0 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "description": "End-to-end browser tests for Home Assistant Core", "private": true, - "packageManager": "pnpm@11.23.0", + "packageManager": "pnpm@11.24.0", "scripts": { "test": "playwright test" }, diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 23fea7bd28a77..1d0a93b027a0d 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -396,8 +396,6 @@ async def test_loading_from_storage( "devices": [ { "area_id": "12345A", - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, "config_entry_id": mock_config_entry.entry_id, "config_subentry_id": None, "composite_device_id": None, @@ -428,8 +426,6 @@ async def test_loading_from_storage( "deleted_devices": [ { "area_id": "12345A", - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, "config_entry_id": mock_config_entry.entry_id, "config_subentry_id": None, "has_composite_identifiers": False, @@ -1754,7 +1750,7 @@ async def test_migration_from_1_11( """Test migration from version 1.11.""" hass_storage[dr.STORAGE_KEY] = { "version": 1, - "minor_version": 10, + "minor_version": 11, "key": dr.STORAGE_KEY, "data": { "devices": [ @@ -1763,7 +1759,7 @@ async def test_migration_from_1_11( "config_entries": [mock_config_entry.entry_id], "config_entries_subentries": {mock_config_entry.entry_id: [None]}, "configuration_url": None, - "connections": [["mac", "123456ABCDEF"]], + "connections": [["mac", "12:34:56:ab:cd:ef"]], "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, "entry_type": "service", @@ -1788,7 +1784,7 @@ async def test_migration_from_1_11( "area_id": None, "config_entries": ["234567"], "config_entries_subentries": {"234567": [None]}, - "connections": [["mac", "123456ABCDAB"]], + "connections": [["mac", "12:34:56:ab:cd:ab"]], "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, "id": "abcdefghijklm2", @@ -7762,8 +7758,6 @@ async def test_loading_invalid_configuration_url_from_storage( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, "config_entry_id": mock_config_entry.entry_id, "config_subentry_id": None, "composite_device_id": None,