diff --git a/.strict-typing b/.strict-typing index f54a31c979acd1..1360aefca3393d 100644 --- a/.strict-typing +++ b/.strict-typing @@ -548,6 +548,7 @@ homeassistant.components.smhi.* homeassistant.components.smlight.* homeassistant.components.smtp.* homeassistant.components.snooz.* +homeassistant.components.sofar.* homeassistant.components.solaredge_modbus.* homeassistant.components.solarlog.* homeassistant.components.sonarr.* diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index bb5cc4a1be40e3..43134737e061d4 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -3,7 +3,6 @@ import asyncio import base64 from collections.abc import Mapping -import logging from typing import Any, cast, override import bcrypt @@ -12,7 +11,6 @@ from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.storage import Store from ..models import AuthFlowContext, AuthFlowResult, Credentials, UserMeta @@ -85,19 +83,10 @@ def __init__(self, hass: HomeAssistant) -> None: hass, STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True ) self._data: dict[str, list[dict[str, str]]] | None = None - # Legacy mode will allow usernames to start/end with whitespace - # and will compare usernames case-insensitive. - # Deprecated in June 2019 and will be removed in 2026.7 - self.is_legacy = False @callback - def normalize_username( - self, username: str, *, force_normalize: bool = False - ) -> str: + def normalize_username(self, username: str) -> str: """Normalize a username based on the mode.""" - if self.is_legacy and not force_normalize: - return username - return username.strip().casefold() async def async_load(self) -> None: @@ -105,53 +94,8 @@ async def async_load(self) -> None: if (data := await self._store.async_load()) is None: data = cast(dict[str, list[dict[str, str]]], {"users": []}) - self._async_check_for_not_normalized_usernames(data) self._data = data - @callback - def _async_check_for_not_normalized_usernames( - self, data: dict[str, list[dict[str, str]]] - ) -> None: - not_normalized_usernames: set[str] = set() - - for user in data["users"]: - username = user["username"] - - if self.normalize_username(username, force_normalize=True) != username: - logging.getLogger(__name__).warning( - ( - "Home Assistant auth provider is running in" - " legacy mode because we detected usernames" - " that are normalized (lowercase and without" - " spaces). Please change the username: '%s'." - ), - username, - ) - not_normalized_usernames.add(username) - - if not_normalized_usernames: - self.is_legacy = True - ir.async_create_issue( - self.hass, - "auth", - "homeassistant_provider_not_normalized_usernames", - breaks_in_ha_version="2026.7.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="homeassistant_provider_not_normalized_usernames", - translation_placeholders={ - "usernames": ( - f'- "{'"\n- "'.join(sorted(not_normalized_usernames))}"' - ) - }, - learn_more_url="homeassistant://config/users", - ) - else: - self.is_legacy = False - ir.async_delete_issue( - self.hass, "auth", "homeassistant_provider_not_normalized_usernames" - ) - @property def users(self) -> list[dict[str, str]]: """Return users.""" @@ -247,9 +191,7 @@ def _validate_new_username(self, new_username: str) -> None: Raises InvalidUsername if the new username is invalid. """ - normalized_username = self.normalize_username( - new_username, force_normalize=True - ) + normalized_username = self.normalize_username(new_username) if normalized_username != new_username: raise InvalidUsername( translation_key="username_not_normalized", @@ -279,7 +221,6 @@ def change_username(self, username: str, new_username: str) -> None: if self.normalize_username(user["username"]) == username: user["username"] = new_username assert self._data is not None - self._async_check_for_not_normalized_usernames(self._data) break else: raise InvalidUser(translation_key="user_not_found") diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index c6e2e985890f57..397e05160c6e0a 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -28,6 +28,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowFormStep, @@ -41,7 +42,7 @@ DEVICE_INPUT = "device_input" -INPUT_PIN_SCHEMA = vol.Schema({vol.Required(CONF_PIN, default=None): int}) +INPUT_PIN_SCHEMA = vol.Schema({vol.Required(CONF_PIN, default=""): cv.string}) DEFAULT_START_OFF = False @@ -513,17 +514,23 @@ async def async_step_pair_with_pin( assert self.pairing assert self.protocol if user_input is not None: - try: - self.pairing.pin(user_input[CONF_PIN]) - await self.pairing.finish() - self.credentials[self.protocol.value] = self.pairing.service.credentials - return await self.async_pair_next_protocol() - except exceptions.PairingError: - _LOGGER.exception("Authentication problem") - errors["base"] = "invalid_auth" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" + pin = user_input[CONF_PIN] + if not pin.isascii() or not pin.isdigit(): + errors["pin"] = "invalid_pin" + else: + try: + self.pairing.pin(pin) + await self.pairing.finish() + self.credentials[self.protocol.value] = ( + self.pairing.service.credentials + ) + return await self.async_pair_next_protocol() + except exceptions.PairingError: + _LOGGER.exception("Authentication problem") + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" return self.async_show_form( step_id="pair_with_pin", diff --git a/homeassistant/components/apple_tv/strings.json b/homeassistant/components/apple_tv/strings.json index fad0672e1161c5..02ef2b64481b54 100644 --- a/homeassistant/components/apple_tv/strings.json +++ b/homeassistant/components/apple_tv/strings.json @@ -17,6 +17,7 @@ "error": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_pin": "Invalid PIN", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, @@ -34,7 +35,7 @@ "data": { "pin": "[%key:common::config_flow::data::pin%]" }, - "description": "Pairing is required for the `{protocol}` protocol. Please enter the PIN code displayed on screen. Leading zeros shall be omitted, i.e. enter 123 if the displayed code is 0123.", + "description": "Pairing is required for the `{protocol}` protocol. Please enter the PIN code displayed on screen, including any leading zeros.", "title": "Pairing" }, "password": { diff --git a/homeassistant/components/auth/strings.json b/homeassistant/components/auth/strings.json index b94ee98c7274da..b58b6eaa76db50 100644 --- a/homeassistant/components/auth/strings.json +++ b/homeassistant/components/auth/strings.json @@ -10,12 +10,6 @@ "message": "Username \"{new_username}\" is not normalized. Please make sure the username is lowercase and does not contain any whitespace." } }, - "issues": { - "homeassistant_provider_not_normalized_usernames": { - "description": "The Home Assistant auth provider is running in legacy mode because we detected not normalized usernames. The legacy mode is deprecated and will be removed. Please change the following usernames:\n\n{usernames}\n\nNormalized usernames are case folded (lower case) and stripped of whitespaces.", - "title": "Not normalized usernames detected" - } - }, "mfa_setup": { "notify": { "abort": { diff --git a/homeassistant/components/elgato/__init__.py b/homeassistant/components/elgato/__init__.py index 310bd3a9752c1e..34b4b819576df8 100644 --- a/homeassistant/components/elgato/__init__.py +++ b/homeassistant/components/elgato/__init__.py @@ -4,11 +4,17 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType +from homeassistant.util.hass_dict import HassKey from .const import DOMAIN -from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator +from .coordinator import ( + ElgatoConfigEntry, + ElgatoDataUpdateCoordinator, + ElgatoFirmwareCoordinator, +) from .services import async_setup_services +ELGATO_KEY: HassKey[ElgatoFirmwareCoordinator] = HassKey(DOMAIN) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [ Platform.BUTTON, @@ -17,12 +23,29 @@ Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.UPDATE, ] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the component.""" + """Set up the component. + + Elgato publishes one firmware catalog covering every model, so a single + coordinator serves every device rather than each config entry fetching + the same thing. + """ async_setup_services(hass) + + coordinator = ElgatoFirmwareCoordinator(hass) + hass.data[ELGATO_KEY] = coordinator + + # Elgato's servers are not on the local network and a request to them can + # sit there for its full timeout, so nothing waits on this. The update + # entities fill themselves in once the answer arrives. + hass.async_create_background_task( + coordinator.async_refresh(), f"{DOMAIN}_firmware_refresh" + ) + return True diff --git a/homeassistant/components/elgato/button.py b/homeassistant/components/elgato/button.py index f117497b3a7b52..66ffec9b551e16 100644 --- a/homeassistant/components/elgato/button.py +++ b/homeassistant/components/elgato/button.py @@ -17,7 +17,7 @@ from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity -from .helpers import elgato_exception_handler +from .helpers import elgato_device_action PARALLEL_UPDATES = 1 @@ -78,7 +78,7 @@ def __init__( f"{coordinator.data.info.serial_number}_{description.key}" ) - @elgato_exception_handler + @elgato_device_action @override async def async_press(self) -> None: """Trigger button press on the Elgato device.""" diff --git a/homeassistant/components/elgato/const.py b/homeassistant/components/elgato/const.py index a3da1b7d41654f..b898d8cd79ac93 100644 --- a/homeassistant/components/elgato/const.py +++ b/homeassistant/components/elgato/const.py @@ -10,5 +10,9 @@ LOGGER = logging.getLogger(__package__) SCAN_INTERVAL = timedelta(seconds=10) +# Elgato publishes firmware a handful of times a year, bundled with a new +# Control Center release. Asking more often than this buys nothing. +FIRMWARE_SCAN_INTERVAL = timedelta(hours=12) + # Attributes ATTR_ON = "on" diff --git a/homeassistant/components/elgato/coordinator.py b/homeassistant/components/elgato/coordinator.py index 7e2afde8579536..d372178ccebad4 100644 --- a/homeassistant/components/elgato/coordinator.py +++ b/homeassistant/components/elgato/coordinator.py @@ -1,5 +1,6 @@ """DataUpdateCoordinator for Elgato.""" +import asyncio from dataclasses import dataclass from typing import override @@ -8,6 +9,8 @@ Elgato, ElgatoConnectionError, ElgatoError, + FirmwareCatalog, + FirmwareVersion, Info, Settings, State, @@ -19,7 +22,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, LOGGER, SCAN_INTERVAL +from .const import DOMAIN, FIRMWARE_SCAN_INTERVAL, LOGGER, SCAN_INTERVAL type ElgatoConfigEntry = ConfigEntry[ElgatoDataUpdateCoordinator] @@ -42,11 +45,14 @@ class ElgatoDataUpdateCoordinator(DataUpdateCoordinator[ElgatoData]): def __init__(self, hass: HomeAssistant, entry: ElgatoConfigEntry) -> None: """Initialize the coordinator.""" - self.config_entry = entry self.client = Elgato( entry.data[CONF_HOST], session=async_get_clientsession(hass), ) + # A firmware install gets the device to itself. It stops answering + # while it erases a flash slot, and enough traffic during that window + # takes its HTTP server down with it and restarts the light. + self.device_lock = asyncio.Lock() super().__init__( hass, LOGGER, @@ -59,15 +65,16 @@ def __init__(self, hass: HomeAssistant, entry: ElgatoConfigEntry) -> None: async def _async_update_data(self) -> ElgatoData: """Fetch data from the Elgato device.""" try: - if self.has_battery is None: - self.has_battery = await self.client.has_battery() - - return ElgatoData( - battery=await self.client.battery() if self.has_battery else None, - info=await self.client.info(), - settings=await self.client.settings(), - state=await self.client.state(), - ) + async with self.device_lock: + if self.has_battery is None: + self.has_battery = await self.client.has_battery() + + return ElgatoData( + battery=await self.client.battery() if self.has_battery else None, + info=await self.client.info(), + settings=await self.client.settings(), + state=await self.client.state(), + ) except ElgatoConnectionError as err: raise UpdateFailed( translation_domain=DOMAIN, @@ -78,3 +85,40 @@ async def _async_update_data(self) -> ElgatoData: translation_domain=DOMAIN, translation_key="unknown_error", ) from err + + +class ElgatoFirmwareCoordinator(DataUpdateCoordinator[dict[int, FirmwareVersion]]): + """Class to manage fetching the firmware Elgato ships. + + Elgato publishes one catalog covering every model, so this is shared by + all Elgato devices rather than set up per config entry. It also lives on + Elgato's servers rather than the local network, and changes a handful of + times a year, so it runs on its own cadence. + """ + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the global Elgato firmware updater.""" + self.catalog = FirmwareCatalog(session=async_get_clientsession(hass)) + super().__init__( + hass, + LOGGER, + config_entry=None, + name=f"{DOMAIN}_firmware", + update_interval=FIRMWARE_SCAN_INTERVAL, + ) + + @override + async def _async_update_data(self) -> dict[int, FirmwareVersion]: + """Fetch the firmware Elgato currently ships, per board type.""" + try: + return await self.catalog.versions(refresh=True) + except ElgatoConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="firmware_communication_error", + ) from err + except ElgatoError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="firmware_unknown_error", + ) from err diff --git a/homeassistant/components/elgato/helpers.py b/homeassistant/components/elgato/helpers.py index 12753e76212d7b..5e81bbc99ae75d 100644 --- a/homeassistant/components/elgato/helpers.py +++ b/homeassistant/components/elgato/helpers.py @@ -34,20 +34,29 @@ def color_temperature_range(data: ElgatoData) -> tuple[int, int]: return COLOR_TEMPERATURE_RANGE -def elgato_exception_handler[_ElgatoEntityT: ElgatoEntity, **_P]( +def elgato_device_action[_ElgatoEntityT: ElgatoEntity, **_P]( func: Callable[Concatenate[_ElgatoEntityT, _P], Coroutine[Any, Any, Any]], ) -> Callable[Concatenate[_ElgatoEntityT, _P], Coroutine[Any, Any, None]]: - """Decorate Elgato calls to handle Elgato exceptions. + """Decorate anything that asks something of an Elgato device. - A decorator that wraps the passed in function, catches Elgato errors, - and raises a translated ``HomeAssistantError``. + Three things every such call wants, in this order. + + It waits its turn, because a firmware install has the device to itself: + it answers nothing while it erases a flash slot, and enough traffic in + that window takes its HTTP server down and restarts the light. + + Elgato errors become a translated ``HomeAssistantError``. + + And the device is asked for its new state afterwards, outside the lock, + because that is another request and it has to queue like the rest. """ async def handler( self: _ElgatoEntityT, *args: _P.args, **kwargs: _P.kwargs ) -> None: try: - await func(self, *args, **kwargs) + async with self.coordinator.device_lock: + await func(self, *args, **kwargs) except ElgatoConnectionError as error: self.coordinator.last_update_success = False self.coordinator.async_update_listeners() @@ -61,4 +70,6 @@ async def handler( translation_key="unknown_error", ) from error + await self.coordinator.async_request_refresh() + return handler diff --git a/homeassistant/components/elgato/light.py b/homeassistant/components/elgato/light.py index d223a24b6844ba..0f505a4d81e7a1 100644 --- a/homeassistant/components/elgato/light.py +++ b/homeassistant/components/elgato/light.py @@ -15,7 +15,7 @@ from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity -from .helpers import color_temperature_range, elgato_exception_handler, supports_color +from .helpers import color_temperature_range, elgato_device_action, supports_color PARALLEL_UPDATES = 1 @@ -87,14 +87,13 @@ def is_on(self) -> bool: """Return the state of the light.""" return self.coordinator.data.state.on - @elgato_exception_handler + @elgato_device_action @override async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" await self.coordinator.client.light(on=False) - await self.coordinator.async_refresh() - @elgato_exception_handler + @elgato_device_action @override async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" @@ -135,9 +134,8 @@ async def async_turn_on(self, **kwargs: Any) -> None: saturation=saturation, temperature=temperature, ) - await self.coordinator.async_refresh() - @elgato_exception_handler + @elgato_device_action async def async_identify(self) -> None: """Identify the light, will make it blink.""" await self.coordinator.client.identify() diff --git a/homeassistant/components/elgato/manifest.json b/homeassistant/components/elgato/manifest.json index 71775228f167c1..cec35d1bbc6466 100644 --- a/homeassistant/components/elgato/manifest.json +++ b/homeassistant/components/elgato/manifest.json @@ -12,6 +12,6 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["elgato==6.1.0"], + "requirements": ["elgato==6.1.1"], "zeroconf": ["_elg._tcp.local."] } diff --git a/homeassistant/components/elgato/number.py b/homeassistant/components/elgato/number.py index f6eebb4667f564..84f0f15dfa09a4 100644 --- a/homeassistant/components/elgato/number.py +++ b/homeassistant/components/elgato/number.py @@ -17,7 +17,7 @@ from .coordinator import ElgatoConfigEntry, ElgatoData, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity -from .helpers import color_temperature_range, elgato_exception_handler +from .helpers import color_temperature_range, elgato_device_action PARALLEL_UPDATES = 1 @@ -122,9 +122,8 @@ def native_value(self) -> float | None: # 6535 K, above a maximum that cannot then be set again. return min(max(value, self.native_min_value), self.native_max_value) - @elgato_exception_handler + @elgato_device_action @override async def async_set_native_value(self, value: float) -> None: """Change the number value.""" await self.entity_description.set_fn(self.coordinator.client, value) - await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/elgato/select.py b/homeassistant/components/elgato/select.py index fc7e7b0cf8d3cf..da4433fad5d1a6 100644 --- a/homeassistant/components/elgato/select.py +++ b/homeassistant/components/elgato/select.py @@ -13,7 +13,7 @@ from .coordinator import ElgatoConfigEntry, ElgatoData, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity -from .helpers import elgato_exception_handler +from .helpers import elgato_device_action PARALLEL_UPDATES = 1 @@ -92,9 +92,8 @@ def current_option(self) -> str | None: """Return the selected option.""" return self.entity_description.current_fn(self.coordinator.data) - @elgato_exception_handler + @elgato_device_action @override async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self.entity_description.select_fn(self.coordinator.client, option) - await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/elgato/strings.json b/homeassistant/components/elgato/strings.json index 6f813761f5440f..25de84d5c025c8 100644 --- a/homeassistant/components/elgato/strings.json +++ b/homeassistant/components/elgato/strings.json @@ -86,6 +86,15 @@ "communication_error": { "message": "An error occurred while communicating with the Elgato device." }, + "firmware_communication_error": { + "message": "An error occurred while downloading the firmware from Elgato." + }, + "firmware_install_error": { + "message": "The Elgato device did not accept the firmware: {error}" + }, + "firmware_unknown_error": { + "message": "An unknown error occurred while downloading the firmware from Elgato." + }, "unknown_error": { "message": "An unknown error occurred while communicating with the Elgato device." } diff --git a/homeassistant/components/elgato/switch.py b/homeassistant/components/elgato/switch.py index cf7f6ed42d02ea..cd2ebb260dda2b 100644 --- a/homeassistant/components/elgato/switch.py +++ b/homeassistant/components/elgato/switch.py @@ -13,7 +13,7 @@ from .coordinator import ElgatoConfigEntry, ElgatoData, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity -from .helpers import elgato_exception_handler +from .helpers import elgato_device_action PARALLEL_UPDATES = 1 @@ -91,16 +91,14 @@ def is_on(self) -> bool | None: """Return state of the switch.""" return self.entity_description.is_on_fn(self.coordinator.data) - @elgato_exception_handler + @elgato_device_action @override async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" await self.entity_description.set_fn(self.coordinator.client, True) - await self.coordinator.async_request_refresh() - @elgato_exception_handler + @elgato_device_action @override async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self.entity_description.set_fn(self.coordinator.client, False) - await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/elgato/update.py b/homeassistant/components/elgato/update.py new file mode 100644 index 00000000000000..d4aa29b2e3d849 --- /dev/null +++ b/homeassistant/components/elgato/update.py @@ -0,0 +1,276 @@ +"""Support for Elgato firmware updates.""" + +from datetime import datetime +from typing import Any, override + +from elgato import ( + ElgatoConnectionError, + ElgatoError, + ElgatoFirmwareError, + FirmwareImage, + FirmwareVersion, +) + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityFeature, +) +from homeassistant.const import EntityCategory +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.event import async_call_later + +from . import ELGATO_KEY +from .const import DOMAIN +from .coordinator import ( + ElgatoConfigEntry, + ElgatoDataUpdateCoordinator, + ElgatoFirmwareCoordinator, +) +from .entity import ElgatoEntity +from .helpers import elgato_device_action + +PARALLEL_UPDATES = 1 + +# A device takes about a minute to come back after it swaps boot slots. This +# is the point at which one that never does stops being called installing. +REBOOT_TIMEOUT = 300 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ElgatoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Elgato firmware update based on a config entry.""" + async_add_entities([ElgatoUpdateEntity(entry.runtime_data, hass.data[ELGATO_KEY])]) + + +class ElgatoUpdateEntity(ElgatoEntity, UpdateEntity): + """Representation of the firmware on an Elgato Light. + + Elgato bumps the build number on every release but not always the version + in front of it, so two builds of 1.0.4 are a thing. Both numbers go into + the version string, which is what puts them in order. + """ + + _attr_device_class = UpdateDeviceClass.FIRMWARE + # Whether an install is running, and which build it is waiting to see. + # They are not the same thing: the download has no target build yet. + _installing: bool = False + _installing_build: int | None = None + _installing_timeout: CALLBACK_TYPE | None = None + _attr_entity_category = EntityCategory.CONFIG + _attr_supported_features = ( + UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS + ) + + def __init__( + self, + coordinator: ElgatoDataUpdateCoordinator, + firmware: ElgatoFirmwareCoordinator, + ) -> None: + """Initiate the Elgato firmware update.""" + super().__init__(coordinator) + + self.firmware = firmware + self._attr_unique_id = coordinator.data.info.serial_number + + @override + async def async_added_to_hass(self) -> None: + """Follow the firmware coordinator as well as the device one.""" + await super().async_added_to_hass() + self.async_on_remove( + self.firmware.async_add_listener(self.async_write_ha_state) + ) + # Otherwise the reboot timer outlives the entity it belongs to. + self.async_on_remove(self._installing_finished) + + @property + @override + def available(self) -> bool: + """Return if both the device and Elgato could be reached.""" + return super().available and self.firmware.last_update_success + + @override + async def async_update(self) -> None: + """Update the entity. + + Asking for an update check has to reach the catalog; the device + coordinator alone knows nothing about what Elgato ships. + """ + await super().async_update() + await self.firmware.async_request_refresh() + + @property + @override + def installed_version(self) -> str: + """Return the firmware currently on the device.""" + info = self.coordinator.data.info + return f"{info.firmware_version}.{info.firmware_build_number}" + + @property + @override + def in_progress(self) -> bool: + """Return if an install is still going on.""" + return self._installing + + @property + @override + def latest_version(self) -> str | None: + """Return the firmware Elgato currently ships for this device. + + The catalog covers every model, so a board Elgato ships nothing for + simply has no entry and this entity has nothing to compare against. + """ + if (latest := self._latest) is None: + return None + return f"{latest.version}.{latest.build_number}" + + @property + def _latest(self) -> FirmwareVersion | None: + """Return the entry in the catalog for the board of this device.""" + if not (catalog := self.firmware.data): + return None + return catalog.get(self.coordinator.data.info.hardware_board_type) + + @override + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install the firmware Elgato ships for this device. + + A device answers that it accepted the reboot and then takes about a + minute to come back. This entity keeps saying it is installing until + the device reports the build it was given, so the old version does + not sit there looking finished while the light is still dark. + """ + # Before the download, not after: fetching the image is part of the + # install, and until this says so a second call walks straight past + # the guard that is meant to stop it. + self._installing = True + self._attr_update_percentage = None + self.async_write_ha_state() + + try: + # Downloading talks to Elgato, so it happens without the device + # lock. Holding it would park every light command behind a + # request to someone else's servers, for their timeout. + image = await self._download() + await self._upload(image) + except BaseException: + self._installing_finished() + raise + finally: + self.async_write_ha_state() + + self._installing_build = image.build_number + # A device that never comes back on the new firmware would otherwise + # leave this saying it is installing for good. + self._installing_timeout = async_call_later( + self.hass, REBOOT_TIMEOUT, self._installing_timed_out + ) + + @elgato_device_action + async def _upload(self, image: FirmwareImage) -> None: + """Hand the firmware to the device, which has it to itself.""" + try: + await self.coordinator.client.update_firmware( + image, on_progress=self._handle_progress + ) + except ElgatoFirmwareError as err: + # A device turns firmware away for reasons someone can act on: + # too little battery left, an image for another model. Say which. + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="firmware_install_error", + translation_placeholders={"error": str(err)}, + ) from err + + async def _download(self) -> FirmwareImage: + """Fetch the firmware image from Elgato. + + This is the half of the install that happens off the local network, + so it reports on the coordinator that covers it and says Elgato in + the message. Letting the handler around async_install see these would + mark the device coordinator failed and blame the light, over a + problem that is entirely at Elgato's end. + """ + try: + board_type = self.coordinator.data.info.hardware_board_type + return await self.firmware.catalog.download(board_type) + except ElgatoConnectionError as err: + self.firmware.async_set_update_error(err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="firmware_communication_error", + ) from err + except ElgatoError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="firmware_unknown_error", + ) from err + + @callback + @override + def _handle_coordinator_update(self) -> None: + """Notice the device coming back on its new firmware.""" + if self.coordinator.last_update_success: + self._sync_device_firmware() + + if ( + self._installing_build is not None + and self.coordinator.data.info.firmware_build_number + >= self._installing_build + ): + self._installing_finished() + + super()._handle_coordinator_update() + + @callback + def _sync_device_firmware(self) -> None: + """Tell the device registry what the device is running now. + + DeviceInfo is read when an entity is added and not again, so without + this the device page keeps the firmware it had at setup. Which is the + version someone reads right after installing a new one. + """ + info = self.coordinator.data.info + version = f"{info.firmware_version} ({info.firmware_build_number})" + + registry = dr.async_get(self.hass) + device = registry.async_get_device_by_identifier( + (DOMAIN, info.serial_number), self.coordinator.config_entry.entry_id + ) + if device is not None and device.sw_version != version: + registry.async_update_device(device.id, sw_version=version) + + @callback + def _installing_finished(self) -> None: + """Stop reporting an install, however it ended.""" + self._installing = False + self._installing_build = None + self._attr_update_percentage = None + if self._installing_timeout is not None: + self._installing_timeout() + self._installing_timeout = None + + @callback + def _installing_timed_out(self, _now: datetime) -> None: + """Give up on a device that never came back. + + This entity changed its own mind, so it publishes that itself rather + than waiting for a coordinator update to come along and do it. + """ + self._installing_timeout = None + self._installing_finished() + self.async_write_ha_state() + + @callback + def _handle_progress(self, sent: int, total: int) -> None: + """Report how much of the firmware the device has taken.""" + self._attr_update_percentage = round(sent / total * 100) + self.async_write_ha_state() diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index 379e0235103223..f578a8da7ab1e3 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -381,11 +381,11 @@ async def google_generative_ai_config_option_schema( api_models = [api_model async for api_model in api_models_pager] models = [ SelectOptionDict( - label=api_model.name.lstrip("models/"), + label=api_model.name.removeprefix("models/"), value=api_model.name, ) for api_model in sorted( - api_models, key=lambda x: (x.name or "").lstrip("models/") + api_models, key=lambda x: (x.name or "").removeprefix("models/") ) if ( api_model.name diff --git a/homeassistant/components/google_pubsub/manifest.json b/homeassistant/components/google_pubsub/manifest.json index b96f4e9ebc0ac1..2ccb655caec947 100644 --- a/homeassistant/components/google_pubsub/manifest.json +++ b/homeassistant/components/google_pubsub/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/google_pubsub", "iot_class": "cloud_push", "quality_scale": "legacy", - "requirements": ["google-cloud-pubsub==2.29.0"] + "requirements": ["google-cloud-pubsub==2.39.2"] } diff --git a/homeassistant/components/hikvision/manifest.json b/homeassistant/components/hikvision/manifest.json index cee9ceff506cab..fd508fcb53821b 100644 --- a/homeassistant/components/hikvision/manifest.json +++ b/homeassistant/components/hikvision/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["pyhik"], - "requirements": ["pyHik==0.4.3"] + "requirements": ["pyHik==0.4.4"] } diff --git a/homeassistant/components/icloud/calendar.py b/homeassistant/components/icloud/calendar.py index 7b756418016613..af0e8fec8a75c0 100644 --- a/homeassistant/components/icloud/calendar.py +++ b/homeassistant/components/icloud/calendar.py @@ -17,6 +17,10 @@ from .const import DOMAIN from .coordinator import IcloudCalendarCoordinator, IcloudCalendarData, localize +# The coordinator owns the polling and the entities are read-only, so there is +# nothing here for Home Assistant to serialize. +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/lyngdorf/diagnostics.py b/homeassistant/components/lyngdorf/diagnostics.py index 0acb33de4875df..45ac73a947bbdc 100644 --- a/homeassistant/components/lyngdorf/diagnostics.py +++ b/homeassistant/components/lyngdorf/diagnostics.py @@ -73,7 +73,6 @@ async def async_get_config_entry_diagnostics( "model": receiver.model.name, "power_on": receiver.power_on, "volume": volume.value if volume is not None else None, - "max_volume": receiver.max_volume, "mute_enabled": receiver.muted, "source": receiver.source, "available_sources": receiver.sources, diff --git a/homeassistant/components/lyngdorf/manifest.json b/homeassistant/components/lyngdorf/manifest.json index b4bd874ff4d36e..6bd78d3cdd3008 100644 --- a/homeassistant/components/lyngdorf/manifest.json +++ b/homeassistant/components/lyngdorf/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["lyngdorf", "async_upnp_client"], "quality_scale": "silver", - "requirements": ["lyngdorf==1.11.0"], + "requirements": ["lyngdorf==2.1.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2", diff --git a/homeassistant/components/midea/__init__.py b/homeassistant/components/midea/__init__.py index 57e0821ccff332..f47cde0bf451bb 100644 --- a/homeassistant/components/midea/__init__.py +++ b/homeassistant/components/midea/__init__.py @@ -57,6 +57,8 @@ def _create_device(data: Mapping[str, Any], ip_address: str) -> MideaDevice | No data[CONF_MODEL], data[CONF_SUBTYPE], "", + data.get(CONF_MAC, None), + data.get(CONF_SN, None), ) diff --git a/homeassistant/components/mitsubishi_comfort/__init__.py b/homeassistant/components/mitsubishi_comfort/__init__.py index d43a494a8dd3e4..75a8c4e3ae2615 100644 --- a/homeassistant/components/mitsubishi_comfort/__init__.py +++ b/homeassistant/components/mitsubishi_comfort/__init__.py @@ -2,6 +2,7 @@ import asyncio import logging +from typing import Any from mitsubishi_comfort import ( DeviceInfo, @@ -11,20 +12,28 @@ ) from mitsubishi_comfort.exceptions import AuthenticationError, DeviceConnectionError +from homeassistant.components.dhcp import async_discovered_service_info from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( CONF_ADDRESSES, + CONF_CREDENTIALS, DEFAULT_CONNECT_TIMEOUT, DEFAULT_RESPONSE_TIMEOUT, DOMAIN, PLATFORMS, ) from .coordinator import MitsubishiComfortConfigEntry, MitsubishiComfortCoordinator +from .helpers import ( + async_create_missing_address_issue, + async_reconcile_missing_address_issue, + build_credentials, + is_fully_credentialed, +) _LOGGER = logging.getLogger(__name__) @@ -58,9 +67,21 @@ async def async_setup_entry( entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], session=session ) + # Replay cached per-device credentials so discover_devices() can skip the + # slow, rate-limited Socket.IO password fetch. The config flow seeds + # these; without them a second Socket.IO call right after the flow's own + # is throttled to empty, leaving devices unconfigurable. + cached_credentials: dict[str, dict[str, str]] = entry.data.get(CONF_CREDENTIALS, {}) + + # The issue is not persistent and unload deletes it: if the cloud is + # unreachable below, addressless devices would retry with no fix flow + # offered. Reconcile from the stored data now; the fresh device list + # refines it further down. + async_reconcile_missing_address_issue(hass, entry) + try: await account.login() - devices = await account.discover_devices() + devices = await account.discover_devices(cached_credentials=cached_credentials) except AuthenticationError as err: raise ConfigEntryError("Mitsubishi cloud authentication failed") from err except DeviceConnectionError as err: @@ -76,39 +97,96 @@ async def async_setup_entry( # device with its MAC so the manifest's "registered_devices" DHCP matcher # tracks it; DHCP discovery then supplies the IP via async_step_dhcp. device_registry = dr.async_get(hass) - owned_macs = {dr.format_mac(info.mac) for info in devices.values()} + owned_macs = {dr.format_mac(info.mac) for info in devices.values() if info.mac} for serial, info in devices.items(): device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, serial)}, - connections={(dr.CONNECTION_NETWORK_MAC, info.mac)}, + # Connections are globally indexed: registering an empty MAC would + # merge every MAC-less device into one registry entry. + connections=( + {(dr.CONNECTION_NETWORK_MAC, info.mac)} if info.mac else set() + ), manufacturer="Mitsubishi", name=info.label, serial_number=serial, ) - # Resolved IPs are stored keyed by MAC. Drop any for devices that are no - # longer on the account. + # Cache the freshly discovered credentials (password, cryptoSerial, MAC) so + # later setups replay them; also drops entries for devices no longer present. + credentials = build_credentials(devices) + + # Stored IPs are keyed by MAC; drop any for devices no longer on the account. + # Addresses come from DHCP discovery (async_step_dhcp), the sighting cache + # below, and the repair flow — the cloud never returns a device's LAN IP. stored: dict[str, str] = entry.data.get(CONF_ADDRESSES, {}) addresses = {mac: ip for mac, ip in stored.items() if mac in owned_macs} + + # The dhcp component caches every sighting, including devices seen before + # they were registered here — those never re-fire registered_devices + # discovery, so look the cache up instead of waiting for a new sighting. + # Stored addresses win: live discovery handles genuine IP changes. + discovered = { + dr.format_mac(info.macaddress): info.ip + for info in async_discovered_service_info(hass) + } + addresses |= { + mac: ip + for mac, ip in discovered.items() + if mac in owned_macs and mac not in addresses + } + + data_updates: dict[str, Any] = {} + if credentials != cached_credentials: + data_updates[CONF_CREDENTIALS] = credentials if addresses != stored: + data_updates[CONF_ADDRESSES] = addresses + if data_updates: hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_ADDRESSES: addresses} + entry, data={**entry.data, **data_updates} ) coordinators: dict[str, MitsubishiComfortCoordinator] = {} + no_address: list[str] = [] + incomplete: list[str] = [] for serial, info in devices.items(): + if not is_fully_credentialed(info): + incomplete.append(info.label) + continue address = addresses.get(dr.format_mac(info.mac)) - if not address or not info.password or not info.crypto_serial: - # No LAN address yet: the device is registered, so DHCP discovery - # supplies its IP and reloads the entry to add it. - _LOGGER.debug("Device %s has no known LAN address yet", info.label) + if not address: + no_address.append(info.label) continue + _LOGGER.debug("Setting up %s at %s", info.label, address) device = _make_device(info, serial, address, session) coordinators[serial] = MitsubishiComfortCoordinator( hass, entry, device, info.mac ) + if incomplete: + _LOGGER.debug( + "The cloud returned incomplete local connection data for %d device(s): %s", + len(incomplete), + ", ".join(sorted(incomplete)), + ) + # A device the cloud cannot locate stays unaddressable across restarts + # until DHCP discovery reaches it or the user enters an IP in the repair + # flow; raise a fixable repair issue while any device lacks an address and + # clear it once they all have one. + if no_address: + async_create_missing_address_issue(hass, entry.entry_id) + else: + ir.async_delete_issue(hass, DOMAIN, f"missing_address_{entry.entry_id}") + # The three buckets reconcile: set up + awaiting address + incomplete local + # data equals the number of devices on the account. + _LOGGER.debug( + "Set up %d of %d device(s); %d awaiting a LAN address, %d with incomplete local data", + len(coordinators), + len(devices), + len(no_address), + len(incomplete), + ) + await asyncio.gather( *(c.async_config_entry_first_refresh() for c in coordinators.values()) ) @@ -123,8 +201,22 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + # Only after a successful unload: a failed unload leaves the entry + # active, so its addressless devices still need the repair. + ir.async_delete_issue(hass, DOMAIN, f"missing_address_{entry.entry_id}") await asyncio.gather( *(c.device.close() for c in entry.runtime_data.values()), return_exceptions=True, ) return unload_ok + + +async def async_remove_entry( + hass: HomeAssistant, entry: MitsubishiComfortConfigEntry +) -> None: + """Remove a config entry's leftovers. + + Removal never calls async_unload_entry for an entry that failed setup, so + the repair issue such a setup left behind is deleted here. + """ + ir.async_delete_issue(hass, DOMAIN, f"missing_address_{entry.entry_id}") diff --git a/homeassistant/components/mitsubishi_comfort/config_flow.py b/homeassistant/components/mitsubishi_comfort/config_flow.py index 84581f611b5fa6..989d5e1c62a8d5 100644 --- a/homeassistant/components/mitsubishi_comfort/config_flow.py +++ b/homeassistant/components/mitsubishi_comfort/config_flow.py @@ -13,7 +13,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .const import CONF_ADDRESSES, DOMAIN +from .const import CONF_ADDRESSES, CONF_CREDENTIALS, DOMAIN +from .helpers import build_credentials, is_fully_credentialed _LOGGER = logging.getLogger(__name__) @@ -30,6 +31,15 @@ class MitsubishiComfortConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + def __init__(self) -> None: + """Initialize the flow.""" + # Fields recovered by earlier attempts in this flow, replayed on retry: + # the rate-limited Socket.IO password fetch may succeed on one attempt + # and return nothing on the next, so no single attempt has to recover + # everything. + self._cached_credentials: dict[str, dict[str, str]] = {} + self._cached_username: str | None = None + @override async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -38,6 +48,11 @@ async def async_step_user( errors: dict[str, str] = {} if user_input is not None: + # The recovered fields belong to the account entered. + if user_input[CONF_USERNAME] != self._cached_username: + self._cached_username = user_input[CONF_USERNAME] + self._cached_credentials = {} + account = MitsubishiCloudAccount( user_input[CONF_USERNAME], user_input[CONF_PASSWORD], @@ -47,27 +62,46 @@ async def async_step_user( devices: dict = {} try: await account.login() - devices = await account.discover_devices() + devices = await account.discover_devices( + cached_credentials=self._cached_credentials + ) except AuthenticationError: errors["base"] = "invalid_auth" except DeviceConnectionError: errors["base"] = "cannot_connect" except Exception: - _LOGGER.exception("Unexpected error during setup") + _LOGGER.exception( + "Unexpected error discovering Mitsubishi Comfort devices" + ) errors["base"] = "unknown" + else: + _LOGGER.debug("Discovered %d device(s)", len(devices)) if not errors: await self.async_set_unique_id(account.user_id) self._abort_if_unique_id_configured() + # Persist the fields discovered here for async_setup_entry to + # replay via discover_devices(cached_credentials=...): the + # slow, rate-limited Socket.IO call then runs only for + # passwords still missing. + credentials = build_credentials(devices) + if credentials: + self._cached_credentials = credentials if not devices: errors["base"] = "no_devices" + elif not any(is_fully_credentialed(info) for info in devices.values()): + # The cache may hold partial (MAC-less) records setup cannot + # use; creating the entry with nothing settable-up would + # load zero devices without raising any repair. + errors["base"] = "no_usable_devices" else: return self.async_create_entry( title=f"Mitsubishi Comfort ({user_input[CONF_USERNAME]})", data={ CONF_USERNAME: user_input[CONF_USERNAME], CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_CREDENTIALS: credentials, }, ) @@ -105,6 +139,7 @@ async def async_step_dhcp( addresses = entry.data.get(CONF_ADDRESSES, {}) if addresses.get(mac) != discovery_info.ip: + _LOGGER.debug("DHCP discovery resolved %s to %s", mac, discovery_info.ip) self.hass.config_entries.async_update_entry( entry, data={ diff --git a/homeassistant/components/mitsubishi_comfort/const.py b/homeassistant/components/mitsubishi_comfort/const.py index 5d5760da33d373..ae3907b9cc809c 100644 --- a/homeassistant/components/mitsubishi_comfort/const.py +++ b/homeassistant/components/mitsubishi_comfort/const.py @@ -10,10 +10,17 @@ # Config entry data key holding the per-device LAN address cache, keyed by the # device's formatted MAC. The cloud API only returns each device's MAC, never -# its LAN IP, so addresses are resolved from DHCP discovery and persisted here -# to survive restarts without re-discovery. +# its LAN IP, so addresses come from DHCP discovery (live and cached sightings) +# and from manual entry in the repair flow, then persisted here to survive +# restarts. CONF_ADDRESSES: Final = "addresses" +# Config entry data key holding per-device discovery fields (the Socket.IO-fetched +# password, plus the cryptoSerial and MAC read from the device status endpoint), +# keyed by serial and replayed via discover_devices(cached_credentials=...) so +# later setup attempts can reuse every field already recovered. +CONF_CREDENTIALS: Final = "credentials" + DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) DEFAULT_CONNECT_TIMEOUT: Final = 1.2 DEFAULT_RESPONSE_TIMEOUT: Final = 8.0 diff --git a/homeassistant/components/mitsubishi_comfort/helpers.py b/homeassistant/components/mitsubishi_comfort/helpers.py new file mode 100644 index 00000000000000..6daa1b024c16fe --- /dev/null +++ b/homeassistant/components/mitsubishi_comfort/helpers.py @@ -0,0 +1,78 @@ +"""Helpers shared across the Mitsubishi Comfort integration.""" + +from mitsubishi_comfort import DeviceInfo + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, issue_registry as ir + +from .const import CONF_ADDRESSES, CONF_CREDENTIALS, DOMAIN + + +def is_fully_credentialed(info: DeviceInfo) -> bool: + """Return whether the device can be set up: local secrets plus its MAC. + + Without a password and cryptoSerial the device cannot be authenticated + against the local API, and the MAC keys the address cache, so without it + the device cannot be set up or offered in the address repair flow yet. + """ + return bool(info.password and info.crypto_serial and info.mac) + + +def has_full_credentials(cred: dict[str, str]) -> bool: + """Return whether a cached record holds the secrets plus MAC setup needs.""" + return bool(cred["password"] and cred["crypto_serial"] and cred["mac"]) + + +def async_create_missing_address_issue(hass: HomeAssistant, entry_id: str) -> None: + """Raise the fixable repair offering manual entry of missing LAN addresses.""" + ir.async_create_issue( + hass, + DOMAIN, + f"missing_address_{entry_id}", + is_fixable=True, + severity=ir.IssueSeverity.ERROR, + translation_key="missing_address", + data={"entry_id": entry_id}, + ) + + +def async_reconcile_missing_address_issue( + hass: HomeAssistant, entry: ConfigEntry +) -> None: + """Create or clear the repair from the entry's stored data alone. + + The issue is not persistent and unload deletes it, so paths that cannot + consult the cloud's fresh device list — setup before the cloud is + reached, a reload whose unload failed — reconcile it from the cached + credentials and stored addresses instead. + """ + addresses: dict[str, str] = entry.data.get(CONF_ADDRESSES, {}) + credentials: dict[str, dict[str, str]] = entry.data.get(CONF_CREDENTIALS, {}) + if any( + dr.format_mac(cred["mac"]) not in addresses + for cred in credentials.values() + if has_full_credentials(cred) + ): + async_create_missing_address_issue(hass, entry.entry_id) + else: + ir.async_delete_issue(hass, DOMAIN, f"missing_address_{entry.entry_id}") + + +def build_credentials(devices: dict[str, DeviceInfo]) -> dict[str, dict[str, str]]: + """Build the per-device credential cache, keyed by serial. + + discover_devices() consumes the password, cryptoSerial, and MAC + independently, so any recovered field is worth caching — above all the + password, which the throttled Socket.IO fetch may never return again. + All-empty records carry nothing worth replaying and are dropped. + """ + return { + serial: { + "password": info.password, + "crypto_serial": info.crypto_serial, + "mac": info.mac, + } + for serial, info in devices.items() + if info.password or info.crypto_serial or info.mac + } diff --git a/homeassistant/components/mitsubishi_comfort/manifest.json b/homeassistant/components/mitsubishi_comfort/manifest.json index 6f139e85a31960..4218b0a806de5d 100644 --- a/homeassistant/components/mitsubishi_comfort/manifest.json +++ b/homeassistant/components/mitsubishi_comfort/manifest.json @@ -3,6 +3,7 @@ "name": "Mitsubishi Comfort", "codeowners": ["@nikolairahimi"], "config_flow": true, + "dependencies": ["dhcp"], "dhcp": [{ "registered_devices": true }], "documentation": "https://www.home-assistant.io/integrations/mitsubishi_comfort", "integration_type": "hub", diff --git a/homeassistant/components/mitsubishi_comfort/quality_scale.yaml b/homeassistant/components/mitsubishi_comfort/quality_scale.yaml index 8ab6f27d0467c3..accfe1ca04a65c 100644 --- a/homeassistant/components/mitsubishi_comfort/quality_scale.yaml +++ b/homeassistant/components/mitsubishi_comfort/quality_scale.yaml @@ -63,7 +63,7 @@ rules: reconfiguration-flow: todo dynamic-devices: todo discovery-update-info: done - repair-issues: todo + repair-issues: done docs-use-cases: done docs-supported-devices: done docs-supported-functions: done diff --git a/homeassistant/components/mitsubishi_comfort/repairs.py b/homeassistant/components/mitsubishi_comfort/repairs.py new file mode 100644 index 00000000000000..905ebf9bf1e242 --- /dev/null +++ b/homeassistant/components/mitsubishi_comfort/repairs.py @@ -0,0 +1,213 @@ +"""Repairs for the Mitsubishi Comfort integration.""" + +import asyncio +from ipaddress import IPv4Address +from typing import cast + +from aiohttp import ClientSession +from mitsubishi_comfort import DeviceInfo, probe_candidate_ips +import voluptuous as vol + +from homeassistant.components.dhcp import async_discovered_service_info +from homeassistant.components.repairs import ( + ConfirmRepairFlow, + RepairsFlow, + RepairsFlowResult, +) +from homeassistant.config_entries import ConfigEntry, OperationNotAllowed, UnknownEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_ADDRESSES, CONF_CREDENTIALS +from .helpers import async_reconcile_missing_address_issue, has_full_credentials + + +async def _async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload the entry, restoring the repair issue if the reload fails. + + The repairs framework deletes the issue when the fix flow finishes, and + normally the reload's setup re-creates it while devices remain + addressless — but a failed unload ends the reload before setup runs, + which would leave the still-loaded entry without its fix flow. + """ + try: + if await hass.config_entries.async_reload(entry.entry_id): + return + except UnknownEntry: + # Removed while this task was pending; nothing left to repair. + return + except OperationNotAllowed: + # A FAILED_UNLOAD entry cannot reload; treat it as a failed reload so + # a repeat repair attempt on the wedged entry keeps its issue. + pass + async_reconcile_missing_address_issue(hass, entry) + + +async def _async_probe( + serial: str, cred: dict[str, str], address: str, session: ClientSession +) -> bool: + """Return whether the device answers an authenticated probe at address.""" + info = DeviceInfo( + serial=serial, + label=serial, + address="", + mac=cred["mac"], + unit_type="", + password=cred["password"], + crypto_serial=cred["crypto_serial"], + ) + return bool(await probe_candidate_ips({serial: info}, [address], session=session)) + + +class MissingAddressRepairFlow(RepairsFlow): + """Collect LAN IPs for devices DHCP discovery has not resolved. + + The cloud never returns a device's LAN IP. DHCP discovery supplies it for + devices Home Assistant can see, but not for devices on another subnet or + VLAN — for those the user enters the IP here. + """ + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize the flow for the entry that raised the issue.""" + self.entry = entry + super().__init__() + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> RepairsFlowResult: + """Handle the first step of the fix flow. + + The repairs manager passes the flow init data ({"issue_id": ...}) as + user_input here, so redirect to a named step that sees real form + input only. + """ + return await self.async_step_addresses() + + async def async_step_addresses( + self, user_input: dict[str, str] | None = None + ) -> RepairsFlowResult: + """Ask for the LAN IP of each device that has none.""" + stored: dict[str, str] = dict(self.entry.data.get(CONF_ADDRESSES, {})) + credentials: dict[str, dict[str, str]] = self.entry.data.get( + CONF_CREDENTIALS, {} + ) + # The freshly pruned credential cache reflects the account's current, + # usable devices, so it decides which fields to offer — the registry + # may be empty (no discovery succeeded yet) or hold removed devices. + # Only fully-credentialed devices (secrets plus MAC): a partial record + # cannot pass the authenticated probe, and setup counts its device as + # incomplete rather than addressless. + macs: dict[str, str] = { + formatted: serial + for serial, cred in credentials.items() + if has_full_credentials(cred) + and (formatted := dr.format_mac(cred["mac"])) not in stored + } + # The registry supplies friendly names; a device never registered + # keeps its serial as the label. + device_registry = dr.async_get(self.hass) + for device in dr.async_entries_for_config_entry( + device_registry, self.entry.entry_id + ): + mac = next( + ( + conn_id + for conn_type, conn_id in device.connections + if conn_type == dr.CONNECTION_NETWORK_MAC + ), + None, + ) + if mac is not None and (formatted := dr.format_mac(mac)) in macs: + macs[formatted] = device.name_by_user or device.name or formatted + + errors: dict[str, str] = {} + if user_input is not None: + entered: dict[str, str] = {} + for mac in macs: + value = user_input.get(mac, "").strip() + if not value: + continue + try: + # IPv4 only: the local API URL is built without IPv6 + # brackets, so an IPv6 literal can never work. + IPv4Address(value) + except ValueError: + errors[mac] = "invalid_ip" + else: + entered[mac] = value + if not errors and entered: + # Each address must answer an authenticated probe for its own + # device: a stored wrong address would suppress this repair + # while leaving the entry stuck in setup retries. + by_mac = { + dr.format_mac(cred["mac"]): (serial, cred) + for serial, cred in credentials.items() + if has_full_credentials(cred) + } + session = async_get_clientsession(self.hass) + reachable = await asyncio.gather( + *( + _async_probe(*by_mac[mac], address, session) + for mac, address in entered.items() + ) + ) + errors |= { + mac: "cannot_connect" + for mac, ok in zip(entered, reachable, strict=True) + if not ok + } + if not errors: + # Re-read the cache: DHCP discovery may have stored addresses + # while the probes above were awaited. On overlap the stored + # lease wins — live discovery saw the device after the user + # typed the address. + current: dict[str, str] = self.entry.data.get(CONF_ADDRESSES, {}) + self.hass.config_entries.async_update_entry( + self.entry, + data={**self.entry.data, CONF_ADDRESSES: {**entered, **current}}, + ) + # The repairs framework deletes the issue after this step + # returns; run the reload non-eagerly so it happens after that + # deletion and setup can re-create the issue if devices are + # still addressless. + self.hass.async_create_task( + _async_reload_entry(self.hass, self.entry), + f"mitsubishi_comfort repair reload {self.entry.entry_id}", + eager_start=False, + ) + return self.async_create_entry(data={}) + + # Pre-fill with the submitted values on a validation error so the user + # does not lose what they typed; otherwise suggest any IP the DHCP + # sighting cache has picked up since setup. + if user_input is None: + user_input = { + formatted: info.ip + for info in async_discovered_service_info(self.hass) + if (formatted := dr.format_mac(info.macaddress)) in macs + } + schema = vol.Schema({vol.Optional(mac): str for mac in macs}) + return self.async_show_form( + step_id="addresses", + data_schema=self.add_suggested_values_to_schema(schema, user_input), + errors=errors, + # The fields are keyed (and labeled) by raw MAC, so pair each name + # with its MAC here or the user cannot tell which field is which. + description_placeholders={ + "devices": ", ".join(f"{name} ({mac})" for mac, name in macs.items()) + }, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create a fix flow for a missing-address issue.""" + if data is not None and ( + entry := hass.config_entries.async_get_entry(cast(str, data["entry_id"])) + ): + return MissingAddressRepairFlow(entry) + return ConfirmRepairFlow() diff --git a/homeassistant/components/mitsubishi_comfort/strings.json b/homeassistant/components/mitsubishi_comfort/strings.json index 61b4c33a5bb35c..26c0cf8cd38124 100644 --- a/homeassistant/components/mitsubishi_comfort/strings.json +++ b/homeassistant/components/mitsubishi_comfort/strings.json @@ -7,6 +7,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "no_devices": "No devices were found on this account", + "no_usable_devices": "The cloud did not return complete local-control information for any device on this account. Wait a few minutes and try again.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { @@ -44,5 +45,26 @@ "update_failed": { "message": "{device_name} returned no data" } + }, + "issues": { + "missing_address": { + "fix_flow": { + "error": { + "cannot_connect": "One or more devices did not respond at the entered address.", + "invalid_ip": "One or more entries are not valid IPv4 addresses." + }, + "step": { + "addresses": { + "description": "These devices have no known local IP address, so they have no entities yet. Enter the local IPv4 address for each device below; each field is labeled with the device's MAC address. Leave a field blank to keep waiting for DHCP discovery (which only works when the device is on the same network as Home Assistant). After you submit, the integration reloads and sets up every device that has an address. Devices: {devices}", + "title": "Device IP addresses" + }, + "confirm": { + "description": "The account this issue was created for no longer exists, so there is nothing left to fix. Confirm to dismiss the issue.", + "title": "Issue is no longer relevant" + } + } + }, + "title": "Mitsubishi Comfort devices have no local IP address" + } } } diff --git a/homeassistant/components/modern_forms/__init__.py b/homeassistant/components/modern_forms/__init__.py index de310200735d5e..4c0e013a850a6b 100644 --- a/homeassistant/components/modern_forms/__init__.py +++ b/homeassistant/components/modern_forms/__init__.py @@ -15,6 +15,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.FAN, Platform.LIGHT, Platform.NUMBER, diff --git a/homeassistant/components/modern_forms/binary_sensor.py b/homeassistant/components/modern_forms/binary_sensor.py index e9aa1ab19eca0c..075cb7d4e14fc5 100644 --- a/homeassistant/components/modern_forms/binary_sensor.py +++ b/homeassistant/components/modern_forms/binary_sensor.py @@ -20,16 +20,19 @@ async def async_setup_entry( """Set up Modern Forms binary sensors.""" coordinator = entry.runtime_data - binary_sensors: list[ModernFormsBinarySensor] = [ - ModernFormsFanSleepTimerActive(entry.entry_id, coordinator), - ] + binary_sensors: list[ModernFormsBinarySensor] = [] - # Only setup light sleep timer sensor if light unit installed - if coordinator.data.info.light_type: + if coordinator.data.has_sleep_timer(): binary_sensors.append( - ModernFormsLightSleepTimerActive(entry.entry_id, coordinator) + ModernFormsFanSleepTimerActive(entry.entry_id, coordinator) ) + # Only setup light sleep timer sensor if light unit installed + if coordinator.data.info.light_type: + binary_sensors.append( + ModernFormsLightSleepTimerActive(entry.entry_id, coordinator) + ) + async_add_entities(binary_sensors) diff --git a/homeassistant/components/modern_forms/button.py b/homeassistant/components/modern_forms/button.py new file mode 100644 index 00000000000000..617bfb211c123c --- /dev/null +++ b/homeassistant/components/modern_forms/button.py @@ -0,0 +1,43 @@ +"""Support for Modern Forms buttons.""" + +from typing import override + +from homeassistant.components.button import ButtonDeviceClass, ButtonEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import modernforms_exception_handler +from .coordinator import ModernFormsConfigEntry, ModernFormsDataUpdateCoordinator +from .entity import ModernFormsDeviceEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ModernFormsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Modern Forms buttons based on a config entry.""" + coordinator = config_entry.runtime_data + + async_add_entities([ModernFormsRestartButton(config_entry.entry_id, coordinator)]) + + +class ModernFormsRestartButton(ModernFormsDeviceEntity, ButtonEntity): + """Defines a Modern Forms restart button.""" + + _attr_device_class = ButtonDeviceClass.RESTART + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, entry_id: str, coordinator: ModernFormsDataUpdateCoordinator + ) -> None: + """Initialize the restart button.""" + super().__init__(entry_id=entry_id, coordinator=coordinator) + self._attr_unique_id = f"{self.coordinator.data.info.mac_address}_restart" + + @modernforms_exception_handler + @override + async def async_press(self) -> None: + """Reboot the fan.""" + await self.coordinator.modern_forms.reboot() diff --git a/homeassistant/components/modern_forms/const.py b/homeassistant/components/modern_forms/const.py index 70164ed415832b..e5125a451d216d 100644 --- a/homeassistant/components/modern_forms/const.py +++ b/homeassistant/components/modern_forms/const.py @@ -6,6 +6,7 @@ OPT_SPEED = "speed" OPT_BRIGHTNESS = "brightness" OPT_WIND = "wind" +OPT_COLOR_TEMP_KELVIN = "color_temp_kelvin" # Services SERVICE_SET_LIGHT_SLEEP_TIMER = "set_light_sleep_timer" diff --git a/homeassistant/components/modern_forms/entity.py b/homeassistant/components/modern_forms/entity.py index 84002deed3b4f9..77c43e655d1fc2 100644 --- a/homeassistant/components/modern_forms/entity.py +++ b/homeassistant/components/modern_forms/entity.py @@ -8,6 +8,22 @@ from .const import DOMAIN from .coordinator import ModernFormsDataUpdateCoordinator +_NAME_SEPARATORS = " -_" + + +def strip_device_name_prefix(device_name: str, name: str) -> str | None: + """Strip a leading device-name prefix so has_entity_name doesn't duplicate it. + + Returns None (rather than a name identical to the device name) when + the fixture name adds nothing beyond the device name. + """ + if not device_name or not name.lower().startswith(device_name.lower()): + return name + rest = name[len(device_name) :] + if rest and rest[0] not in _NAME_SEPARATORS: + return name + return rest.lstrip(_NAME_SEPARATORS) or None + class ModernFormsDeviceEntity(CoordinatorEntity[ModernFormsDataUpdateCoordinator]): """Defines a Modern Forms device entity.""" diff --git a/homeassistant/components/modern_forms/fan.py b/homeassistant/components/modern_forms/fan.py index 5fad755792f4e9..e072bcb1bedfc0 100644 --- a/homeassistant/components/modern_forms/fan.py +++ b/homeassistant/components/modern_forms/fan.py @@ -7,6 +7,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( @@ -19,6 +20,7 @@ from .const import ( ATTR_SLEEP_TIME, CLEAR_TIMER, + DOMAIN, OPT_ON, OPT_SPEED, OPT_WIND, @@ -186,6 +188,11 @@ async def async_set_fan_sleep_timer( sleep_time: int, ) -> None: """Set a Modern Forms light sleep timer.""" + if not self.coordinator.data.has_sleep_timer(): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="sleep_timer_not_supported", + ) await self.coordinator.modern_forms.fan(sleep=sleep_time * 60) @modernforms_exception_handler @@ -193,4 +200,9 @@ async def async_clear_fan_sleep_timer( self, ) -> None: """Clear a Modern Forms fan sleep timer.""" + if not self.coordinator.data.has_sleep_timer(): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="sleep_timer_not_supported", + ) await self.coordinator.modern_forms.fan(sleep=CLEAR_TIMER) diff --git a/homeassistant/components/modern_forms/light.py b/homeassistant/components/modern_forms/light.py index 4418368c877a45..bcfc40d61322a4 100644 --- a/homeassistant/components/modern_forms/light.py +++ b/homeassistant/components/modern_forms/light.py @@ -3,10 +3,17 @@ from typing import Any, override from aiomodernforms.const import LIGHT_POWER_OFF, LIGHT_POWER_ON +from aiomodernforms.models import Light import voluptuous as vol -from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ColorMode, + LightEntity, +) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( @@ -18,13 +25,15 @@ from .const import ( ATTR_SLEEP_TIME, CLEAR_TIMER, + DOMAIN, OPT_BRIGHTNESS, + OPT_COLOR_TEMP_KELVIN, OPT_ON, SERVICE_CLEAR_LIGHT_SLEEP_TIMER, SERVICE_SET_LIGHT_SLEEP_TIMER, ) from .coordinator import ModernFormsConfigEntry, ModernFormsDataUpdateCoordinator -from .entity import ModernFormsDeviceEntity +from .entity import ModernFormsDeviceEntity, strip_device_name_prefix BRIGHTNESS_RANGE = (1, 255) @@ -61,65 +70,116 @@ async def async_setup_entry( ) async_add_entities( - [ - ModernFormsLightEntity( - entry_id=config_entry.entry_id, coordinator=coordinator - ) - ] + ModernFormsLightEntity( + entry_id=config_entry.entry_id, + coordinator=coordinator, + light_address=light.address, + ) + for light in coordinator.data.state.light_fixtures ) class ModernFormsLightEntity(ModernFormsDeviceEntity, LightEntity): """Defines a Modern Forms light.""" - _attr_color_mode = ColorMode.BRIGHTNESS - _attr_supported_color_modes = {ColorMode.BRIGHTNESS} _attr_translation_key = "light" def __init__( - self, entry_id: str, coordinator: ModernFormsDataUpdateCoordinator + self, + entry_id: str, + coordinator: ModernFormsDataUpdateCoordinator, + light_address: int | None, ) -> None: """Initialize Modern Forms light.""" - super().__init__( - entry_id=entry_id, - coordinator=coordinator, - ) - self._attr_unique_id = f"{self.coordinator.data.info.mac_address}" + super().__init__(entry_id=entry_id, coordinator=coordinator) + self._address = light_address + mac_address = self.coordinator.data.info.mac_address + + if light_address is None: + self._attr_unique_id = mac_address + self._attr_color_mode = ColorMode.BRIGHTNESS + self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} + else: + # Real Gen4 fixtures are named by the user, so the device-name + # prefix strip below is per-device data rather than static. + self._attr_unique_id = f"{mac_address}_{light_address}" + fixture = next( + light + for light in coordinator.data.state.light_fixtures + if light.address == light_address + ) + self._attr_name = strip_device_name_prefix( + self.coordinator.data.info.device_name, fixture.name + ) + + if ( + fixture.min_color_temp_kelvin is not None + and fixture.max_color_temp_kelvin is not None + ): + self._attr_color_mode = ColorMode.COLOR_TEMP + self._attr_supported_color_modes = {ColorMode.COLOR_TEMP} + self._attr_min_color_temp_kelvin = fixture.min_color_temp_kelvin + self._attr_max_color_temp_kelvin = fixture.max_color_temp_kelvin + else: + self._attr_color_mode = ColorMode.BRIGHTNESS + self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} + + @property + def _light(self) -> Light | None: + """Return this entity's current fixture data, if it still exists.""" + for light in self.coordinator.data.state.light_fixtures: + if light.address == self._address: + return light + return None + + @property + @override + def available(self) -> bool: + """Return True if the fixture this entity represents still exists.""" + return super().available and self._light is not None @property @override def brightness(self) -> int | None: """Return the brightness of this light between 1..255.""" + if self._light is None: + return None return round( - percentage_to_ranged_value( - BRIGHTNESS_RANGE, self.coordinator.data.state.light_brightness - ) + percentage_to_ranged_value(BRIGHTNESS_RANGE, self._light.brightness) ) @property @override def is_on(self) -> bool: """Return the state of the light.""" - return bool(self.coordinator.data.state.light_on) + return self._light is not None and bool(self._light.on) + + @property + @override + def color_temp_kelvin(self) -> int | None: + """Return the color temperature of this light in Kelvin.""" + return self._light.color_temp_kelvin if self._light is not None else None @modernforms_exception_handler @override async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" - await self.coordinator.modern_forms.light(on=LIGHT_POWER_OFF) + await self._async_control_light(on=LIGHT_POWER_OFF) @modernforms_exception_handler @override async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" - data = {OPT_ON: LIGHT_POWER_ON} + data: dict[str, Any] = {OPT_ON: LIGHT_POWER_ON} if ATTR_BRIGHTNESS in kwargs: data[OPT_BRIGHTNESS] = ranged_value_to_percentage( BRIGHTNESS_RANGE, kwargs[ATTR_BRIGHTNESS] ) + if ATTR_COLOR_TEMP_KELVIN in kwargs: + data[OPT_COLOR_TEMP_KELVIN] = kwargs[ATTR_COLOR_TEMP_KELVIN] - await self.coordinator.modern_forms.light(**data) + await self._async_control_light(**data) @modernforms_exception_handler async def async_set_light_sleep_timer( @@ -127,11 +187,28 @@ async def async_set_light_sleep_timer( sleep_time: int, ) -> None: """Set a Modern Forms light sleep timer.""" - await self.coordinator.modern_forms.light(sleep=sleep_time * 60) + if not self.coordinator.data.has_sleep_timer(): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="sleep_timer_not_supported", + ) + await self._async_control_light(sleep=sleep_time * 60) @modernforms_exception_handler async def async_clear_light_sleep_timer( self, ) -> None: """Clear a Modern Forms light sleep timer.""" - await self.coordinator.modern_forms.light(sleep=CLEAR_TIMER) + if not self.coordinator.data.has_sleep_timer(): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="sleep_timer_not_supported", + ) + await self._async_control_light(sleep=CLEAR_TIMER) + + async def _async_control_light(self, **kwargs: Any) -> None: + """Send a control command to this entity's fixture.""" + if self._address is None: + await self.coordinator.modern_forms.light(**kwargs) + else: + await self.coordinator.modern_forms.light_fixture(self._address, **kwargs) diff --git a/homeassistant/components/modern_forms/sensor.py b/homeassistant/components/modern_forms/sensor.py index 8ba086d4dfe0e2..ee8b0d61ab8c99 100644 --- a/homeassistant/components/modern_forms/sensor.py +++ b/homeassistant/components/modern_forms/sensor.py @@ -22,16 +22,19 @@ async def async_setup_entry( """Set up Modern Forms sensor based on a config entry.""" coordinator = entry.runtime_data - sensors: list[ModernFormsSensor] = [ - ModernFormsFanTimerRemainingTimeSensor(entry.entry_id, coordinator), - ] + sensors: list[ModernFormsSensor] = [] - # Only setup light sleep timer sensor if light unit installed - if coordinator.data.info.light_type: + if coordinator.data.has_sleep_timer(): sensors.append( - ModernFormsLightTimerRemainingTimeSensor(entry.entry_id, coordinator) + ModernFormsFanTimerRemainingTimeSensor(entry.entry_id, coordinator) ) + # Only setup light sleep timer sensor if light unit installed + if coordinator.data.info.light_type: + sensors.append( + ModernFormsLightTimerRemainingTimeSensor(entry.entry_id, coordinator) + ) + async_add_entities(sensors) diff --git a/homeassistant/components/modern_forms/strings.json b/homeassistant/components/modern_forms/strings.json index 0e864abda3f26c..2cf7ed00be9877 100644 --- a/homeassistant/components/modern_forms/strings.json +++ b/homeassistant/components/modern_forms/strings.json @@ -71,6 +71,9 @@ }, "invalid_response": { "message": "Invalid response from the Modern Forms device" + }, + "sleep_timer_not_supported": { + "message": "This fan does not support sleep timers" } }, "services": { diff --git a/homeassistant/components/modern_forms/switch.py b/homeassistant/components/modern_forms/switch.py index 93a59d83657bb1..f08a6bdef400cd 100644 --- a/homeassistant/components/modern_forms/switch.py +++ b/homeassistant/components/modern_forms/switch.py @@ -19,10 +19,13 @@ async def async_setup_entry( """Set up Modern Forms switch based on a config entry.""" coordinator = entry.runtime_data - switches = [ + switches: list[ModernFormsSwitch] = [ ModernFormsAwaySwitch(entry.entry_id, coordinator), - ModernFormsAdaptiveLearningSwitch(entry.entry_id, coordinator), ] + + if coordinator.data.has_adaptive_learning(): + switches.append(ModernFormsAdaptiveLearningSwitch(entry.entry_id, coordinator)) + async_add_entities(switches) diff --git a/homeassistant/components/nextbus/__init__.py b/homeassistant/components/nextbus/__init__.py index 5d0029f4448eee..02d5a8cd1e2f4c 100644 --- a/homeassistant/components/nextbus/__init__.py +++ b/homeassistant/components/nextbus/__init__.py @@ -4,31 +4,33 @@ from homeassistant.const import CONF_STOP, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.util.hass_dict import HassKey from .const import CONF_AGENCY, CONF_ROUTE, DOMAIN from .coordinator import NextBusDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] +type NextBusConfigEntry = ConfigEntry[NextBusDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +# Coordinators are shared between entries with the same agency and stop; the +# synchronous check and store below must stay free of awaits so concurrent +# entry setups cannot create duplicates. +NEXTBUS_KEY: HassKey[dict[str, NextBusDataUpdateCoordinator]] = HassKey(DOMAIN) + + +async def async_setup_entry(hass: HomeAssistant, entry: NextBusConfigEntry) -> bool: """Set up platforms for NextBus.""" entry_agency = entry.data[CONF_AGENCY] entry_stop = entry.data[CONF_STOP] coordinator_key = f"{entry_agency}-{entry_stop}" - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=home-assistant-use-runtime-data - coordinator: NextBusDataUpdateCoordinator | None = hass.data.setdefault( - DOMAIN, {} - ).get( - coordinator_key, - ) + coordinators = hass.data.setdefault(NEXTBUS_KEY, {}) + coordinator = coordinators.get(coordinator_key) if coordinator is None: coordinator = NextBusDataUpdateCoordinator(hass, entry_agency) - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=home-assistant-use-runtime-data - hass.data[DOMAIN][coordinator_key] = coordinator + coordinators[coordinator_key] = coordinator + entry.runtime_data = coordinator coordinator.add_stop_route(entry_stop, entry.data[CONF_ROUTE]) @@ -41,19 +43,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: NextBusConfigEntry) -> bool: """Unload a config entry.""" if await hass.config_entries.async_unload_platforms(entry, PLATFORMS): entry_agency = entry.data[CONF_AGENCY] entry_stop = entry.data[CONF_STOP] - coordinator_key = f"{entry_agency}-{entry_stop}" - - coordinator: NextBusDataUpdateCoordinator = hass.data[DOMAIN][coordinator_key] + coordinator = entry.runtime_data coordinator.remove_stop_route(entry_stop, entry.data[CONF_ROUTE]) if not coordinator.has_routes(): await coordinator.async_shutdown() - hass.data[DOMAIN].pop(coordinator_key) + hass.data[NEXTBUS_KEY].pop(f"{entry_agency}-{entry_stop}") return True diff --git a/homeassistant/components/nextbus/sensor.py b/homeassistant/components/nextbus/sensor.py index 8d09055a7b0bcb..3160b1101037b0 100644 --- a/homeassistant/components/nextbus/sensor.py +++ b/homeassistant/components/nextbus/sensor.py @@ -4,14 +4,14 @@ from typing import cast, override from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_STOP from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utc_from_timestamp -from .const import CONF_AGENCY, CONF_ROUTE, DOMAIN +from . import NextBusConfigEntry +from .const import CONF_AGENCY, CONF_ROUTE from .coordinator import NextBusDataUpdateCoordinator from .util import maybe_first @@ -20,18 +20,12 @@ async def async_setup_entry( hass: HomeAssistant, - config: ConfigEntry, + config: NextBusConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Load values from configuration and initialize the platform.""" _LOGGER.debug(config.data) - entry_agency = config.data[CONF_AGENCY] - entry_stop = config.data[CONF_STOP] - coordinator_key = f"{entry_agency}-{entry_stop}" - - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=home-assistant-use-runtime-data - coordinator: NextBusDataUpdateCoordinator = hass.data[DOMAIN].get(coordinator_key) + coordinator = config.runtime_data async_add_entities( ( diff --git a/homeassistant/components/peblar/manifest.json b/homeassistant/components/peblar/manifest.json index faaa5b05a12c85..f8c42ad38c2764 100644 --- a/homeassistant/components/peblar/manifest.json +++ b/homeassistant/components/peblar/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["peblar==1.0.1"], + "requirements": ["peblar==2.0.0"], "zeroconf": [{ "name": "pblr-*", "type": "_http._tcp.local." }] } diff --git a/homeassistant/components/proxmoxve/entity.py b/homeassistant/components/proxmoxve/entity.py index 2c9d80ec92557c..d79b87dfa35a08 100644 --- a/homeassistant/components/proxmoxve/entity.py +++ b/homeassistant/components/proxmoxve/entity.py @@ -3,7 +3,7 @@ from typing import Any, override from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -90,6 +90,7 @@ def __init__( ), config_entry_id=coordinator.config_entry.entry_id, ), + entry_type=DeviceEntryType.SERVICE, ) self._attr_unique_id = ( @@ -149,6 +150,7 @@ def __init__( ), config_entry_id=coordinator.config_entry.entry_id, ), + entry_type=DeviceEntryType.SERVICE, ) self._attr_unique_id = ( @@ -211,6 +213,7 @@ def __init__( ), config_entry_id=coordinator.config_entry.entry_id, ), + entry_type=DeviceEntryType.SERVICE, ) self._attr_unique_id = ( diff --git a/homeassistant/components/pushover/notify.py b/homeassistant/components/pushover/notify.py index ee4a9583789878..868083a3879e81 100644 --- a/homeassistant/components/pushover/notify.py +++ b/homeassistant/components/pushover/notify.py @@ -13,7 +13,7 @@ BaseNotificationService, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -30,6 +30,7 @@ ATTR_URL, ATTR_URL_TITLE, CONF_USER_KEY, + DOMAIN, ) _LOGGER = logging.getLogger(__name__) @@ -97,22 +98,23 @@ def send_message(self, message: str = "", **kwargs: Any) -> None: # Check for attachment if (image := data.get(ATTR_ATTACHMENT)) is not None: # Only allow attachments from whitelisted paths, check valid path - if self._hass.config.is_allowed_path(data[ATTR_ATTACHMENT]): - # try to open it as a normal file. - try: - # pylint: disable-next=consider-using-with - file_handle = open(data[ATTR_ATTACHMENT], "rb") - # Replace the attachment identifier with file object. - image = file_handle - # pylint: disable-next=home-assistant-action-swallowed-exception - except OSError as ex_val: - _LOGGER.error(ex_val) - # Remove attachment key to send without attachment. - image = None - else: - _LOGGER.error("Path is not whitelisted") - # Remove attachment key to send without attachment. - image = None + if not self._hass.config.is_allowed_path(data[ATTR_ATTACHMENT]): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="attachment_not_allowed", + translation_placeholders={"attachment": data[ATTR_ATTACHMENT]}, + ) + try: + # pylint: disable-next=consider-using-with + file_handle = open(data[ATTR_ATTACHMENT], "rb") + except OSError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="attachment_open_failed", + translation_placeholders={"attachment": data[ATTR_ATTACHMENT]}, + ) from err + # Replace the attachment identifier with file object. + image = file_handle try: result = self.pushover.send_message( diff --git a/homeassistant/components/pushover/strings.json b/homeassistant/components/pushover/strings.json index 24c9d58440b845..ad7adaaf527b3e 100644 --- a/homeassistant/components/pushover/strings.json +++ b/homeassistant/components/pushover/strings.json @@ -25,6 +25,14 @@ } } }, + "exceptions": { + "attachment_not_allowed": { + "message": "Attachment path {attachment} is not allowed." + }, + "attachment_open_failed": { + "message": "Failed to open attachment {attachment}." + } + }, "services": { "cancel": { "description": "Cancels one or more Pushover emergency notifications (priority 2) that were previously sent through the targeted Pushover account.", diff --git a/homeassistant/components/rainforest_raven/coordinator.py b/homeassistant/components/rainforest_raven/coordinator.py index 8b5a4729efee25..cf8e3c0a00b822 100644 --- a/homeassistant/components/rainforest_raven/coordinator.py +++ b/homeassistant/components/rainforest_raven/coordinator.py @@ -138,7 +138,7 @@ async def _get_device(self) -> RAVEnSerialDevice: await device.open() await device.synchronize() self._device_info = await device.get_device_info() - except: + except BaseException: await device.abort() raise diff --git a/homeassistant/components/recorder/history/__init__.py b/homeassistant/components/recorder/history/__init__.py index 454662121f8332..c2e325e1ddd01e 100644 --- a/homeassistant/components/recorder/history/__init__.py +++ b/homeassistant/components/recorder/history/__init__.py @@ -83,6 +83,28 @@ def _stmt_and_join_attributes( return _select +def _row_field_indices( + no_attributes: bool, + include_last_changed: bool, + include_last_reported: bool, +) -> tuple[int | None, int | None, int | None]: + """Return the (last_changed_ts, last_reported_ts, attributes) row indices. + + Must match the column order selected by _stmt_and_join_attributes. + """ + next_idx = len(_FIELD_MAP) + last_changed_ts_idx = last_reported_ts_idx = attributes_idx = None + if include_last_changed: + last_changed_ts_idx = next_idx + next_idx += 1 + if include_last_reported: + last_reported_ts_idx = next_idx + next_idx += 1 + if not no_attributes: + attributes_idx = next_idx + return last_changed_ts_idx, last_reported_ts_idx, attributes_idx + + def _stmt_and_join_attributes_for_start_state( no_attributes: bool, include_last_changed: bool, @@ -316,6 +338,9 @@ def get_significant_states_with_session( minimal_response, compressed_state_format, no_attributes=no_attributes, + field_indices=_row_field_indices( + no_attributes, not significant_changes_only, False + ), ) @@ -513,6 +538,7 @@ def state_changes_during_period( entity_id_to_metadata_id, descending=descending, no_attributes=no_attributes, + field_indices=_row_field_indices(no_attributes, False, True), ), ) @@ -607,6 +633,7 @@ def get_last_state_changes( entity_ids, entity_id_to_metadata_id, no_attributes=False, + field_indices=_row_field_indices(False, False, number_of_states > 1), ), ) @@ -796,6 +823,8 @@ def _sorted_states_to_dict( compressed_state_format: bool = False, descending: bool = False, no_attributes: bool = False, + *, + field_indices: tuple[int | None, int | None, int | None], ) -> dict[str, list[State | dict[str, Any]]]: """Convert SQL results into JSON friendly data structure. @@ -809,8 +838,19 @@ def _sorted_states_to_dict( axis correctly. """ field_map = _FIELD_MAP + last_changed_ts_idx, last_reported_ts_idx, attributes_idx = field_indices state_class: Callable[ - [Row, dict[str, dict[str, Any]], float | None, str, str, float | None, bool], + [ + dict[str, dict[str, Any]], + float | None, + str, + str, + float | None, + Any, + float | None, + float | None, + bool, + ], State | dict[str, Any], ] if compressed_state_format: @@ -856,12 +896,18 @@ def _sorted_states_to_dict( ent_results.extend( [ state_class( - db_state, attr_cache, start_time_ts, entity_id, db_state[state_idx], db_state[last_updated_ts_idx], + None if attributes_idx is None else db_state[attributes_idx], + None + if last_changed_ts_idx is None + else db_state[last_changed_ts_idx], + None + if last_reported_ts_idx is None + else db_state[last_reported_ts_idx], False, ) for db_state in group @@ -880,12 +926,18 @@ def _sorted_states_to_dict( prev_state = first_state[state_idx] ent_results.append( state_class( - first_state, attr_cache, start_time_ts, entity_id, prev_state, first_state[last_updated_ts_idx], + None if attributes_idx is None else first_state[attributes_idx], + None + if last_changed_ts_idx is None + else first_state[last_changed_ts_idx], + None + if last_reported_ts_idx is None + else first_state[last_reported_ts_idx], no_attributes, ) ) diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py index 991ef309aadc3c..f80f53111e3437 100644 --- a/homeassistant/components/recorder/models/state.py +++ b/homeassistant/components/recorder/models/state.py @@ -5,7 +5,6 @@ from typing import TYPE_CHECKING, Any, override from propcache.api import cached_property -from sqlalchemy.engine.row import Row from homeassistant.const import ( COMPRESSED_STATE_ATTRIBUTES, @@ -39,20 +38,24 @@ class LazyState(State): def __init__( # pylint: disable=super-init-not-called self, - row: Row, attr_cache: dict[str, dict[str, Any]], start_time_ts: float | None, entity_id: str, state: str, last_updated_ts: float | None, - no_attributes: bool, + attributes_source: Any = None, + last_changed_ts: float | None = None, + last_reported_ts: float | None = None, + no_attributes: bool = False, ) -> None: """Init the lazy state.""" - self._row = row + self._attributes_source = attributes_source self.entity_id = entity_id self.state = state or "" self._attributes: dict[str, Any] | None = None self._last_updated_ts: float | None = last_updated_ts or start_time_ts + self._last_changed_ts = last_changed_ts + self._last_reported_ts = last_reported_ts self.attr_cache = attr_cache self.context = EMPTY_CONTEXT @@ -60,14 +63,7 @@ def __init__( # pylint: disable=super-init-not-called @override def attributes(self) -> dict[str, Any]: # type: ignore[override] """State attributes.""" - return decode_attributes_from_source( - getattr(self._row, "attributes", None), self.attr_cache - ) - - @cached_property - def _last_changed_ts(self) -> float | None: - """Last changed timestamp.""" - return getattr(self._row, "last_changed_ts", None) + return decode_attributes_from_source(self._attributes_source, self.attr_cache) @cached_property @override @@ -77,11 +73,6 @@ def last_changed(self) -> datetime: # type: ignore[override] self._last_changed_ts or self._last_updated_ts # type: ignore[arg-type] ) - @cached_property - def _last_reported_ts(self) -> float | None: - """Last reported timestamp.""" - return getattr(self._row, "last_reported_ts", None) - @cached_property @override def last_reported(self) -> datetime: # type: ignore[override] @@ -147,26 +138,24 @@ def as_dict(self) -> dict[str, Any]: # type: ignore[override] def row_to_compressed_state( - row: Row, attr_cache: dict[str, dict[str, Any]], start_time_ts: float | None, entity_id: str, state: str, last_updated_ts: float | None, - no_attributes: bool, + attributes_source: Any = None, + last_changed_ts: float | None = None, + last_reported_ts: float | None = None, + no_attributes: bool = False, ) -> dict[str, Any]: """Convert a database row to a compressed state schema 41 and later.""" comp_state: dict[str, Any] = {COMPRESSED_STATE_STATE: state} if not no_attributes: comp_state[COMPRESSED_STATE_ATTRIBUTES] = decode_attributes_from_source( - getattr(row, "attributes", None), attr_cache + attributes_source, attr_cache ) row_last_updated_ts: float = last_updated_ts or start_time_ts # type: ignore[assignment] comp_state[COMPRESSED_STATE_LAST_UPDATED] = row_last_updated_ts - if ( - (row_last_changed_ts := getattr(row, "last_changed_ts", None)) - and row_last_changed_ts - and row_last_updated_ts != row_last_changed_ts - ): - comp_state[COMPRESSED_STATE_LAST_CHANGED] = row_last_changed_ts + if last_changed_ts and row_last_updated_ts != last_changed_ts: + comp_state[COMPRESSED_STATE_LAST_CHANGED] = last_changed_ts return comp_state diff --git a/homeassistant/components/samsung_infrared/__init__.py b/homeassistant/components/samsung_infrared/__init__.py index 0d46c49a68760a..fb37358dd756ba 100644 --- a/homeassistant/components/samsung_infrared/__init__.py +++ b/homeassistant/components/samsung_infrared/__init__.py @@ -4,7 +4,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant -PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER] +PLATFORMS = [Platform.BUTTON, Platform.CLIMATE, Platform.MEDIA_PLAYER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/samsung_infrared/button.py b/homeassistant/components/samsung_infrared/button.py index e1fb78c8d635c0..e25f4ebaa16455 100644 --- a/homeassistant/components/samsung_infrared/button.py +++ b/homeassistant/components/samsung_infrared/button.py @@ -168,7 +168,9 @@ def __init__( description: SamsungIrButtonEntityDescription, ) -> None: """Initialize Samsung IR button.""" - super().__init__(entry, unique_id_suffix=description.key) + super().__init__( + entry, unique_id_suffix=description.key, device_name="Samsung TV" + ) self._infrared_emitter_entity_id = infrared_emitter_entity_id self.entity_description = description diff --git a/homeassistant/components/samsung_infrared/climate.py b/homeassistant/components/samsung_infrared/climate.py new file mode 100644 index 00000000000000..a973438d376d4a --- /dev/null +++ b/homeassistant/components/samsung_infrared/climate.py @@ -0,0 +1,241 @@ +"""Climate platform for Samsung IR integration.""" + +from dataclasses import dataclass +from typing import Any, override + +from infrared_protocols.commands.samsung_ac import ( + SamsungAC0292Command, + SamsungAC0292HvacMode, + SamsungACFanMode, + SamsungACSwingMode, +) + +from homeassistant.components.climate import ( + ATTR_FAN_MODE, + ATTR_HVAC_MODE, + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.components.infrared import InfraredEmitterConsumerEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_TEMPERATURE, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity + +from .const import CONF_DEVICE_TYPE, CONF_INFRARED_EMITTER_ENTITY_ID, SamsungDeviceType +from .entity import SamsungIrEntity + +PARALLEL_UPDATES = 1 + + +HA_TO_LIB_HVAC = { + HVACMode.OFF: SamsungAC0292HvacMode.OFF, + HVACMode.COOL: SamsungAC0292HvacMode.COOL, + HVACMode.HEAT: SamsungAC0292HvacMode.HEAT, + HVACMode.DRY: SamsungAC0292HvacMode.DRY, + HVACMode.FAN_ONLY: SamsungAC0292HvacMode.FAN_ONLY, + HVACMode.AUTO: SamsungAC0292HvacMode.AUTO, +} + + +HA_TO_LIB_FAN = { + FAN_AUTO: SamsungACFanMode.AUTO, + FAN_LOW: SamsungACFanMode.LOW, + FAN_MEDIUM: SamsungACFanMode.MEDIUM, + FAN_HIGH: SamsungACFanMode.HIGH, +} + + +@dataclass +class _SamsungAcExtraStoredData(ExtraStoredData): + """Extra data restored alongside the entity's visible state. + + Holds the last non-OFF HVAC mode, which isn't part of the visible state (the + entity may currently be OFF) but is needed by turn_on to know which mode to + resume, so it can't be recovered from last_state.state alone when that state + is OFF. + """ + + last_on_hvac_mode: str + + @override + def as_dict(self) -> dict[str, Any]: + """Return a dict representation for storage.""" + return {"last_on_hvac_mode": self.last_on_hvac_mode} + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> _SamsungAcExtraStoredData | None: + """Build from a stored dict, or None if it doesn't look valid.""" + last_on_hvac_mode = restored.get("last_on_hvac_mode") + if not isinstance(last_on_hvac_mode, str): + return None + return cls(last_on_hvac_mode=last_on_hvac_mode) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Samsung IR climate from a config entry.""" + infrared_emitter_entity_id = entry.data[CONF_INFRARED_EMITTER_ENTITY_ID] + device_type = entry.data[CONF_DEVICE_TYPE] + + if device_type == SamsungDeviceType.AC: + async_add_entities( + [SamsungIrClimate(entry, infrared_emitter_entity_id, device_type)] + ) + + +class SamsungIrClimate( + SamsungIrEntity, InfraredEmitterConsumerEntity, ClimateEntity, RestoreEntity +): + """Samsung IR climate entity.""" + + _attr_name = None + _attr_assumed_state = True + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_fan_modes = [FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH] + _attr_hvac_mode = HVACMode.OFF + _attr_target_temperature = 24.0 + _attr_min_temp = 16.0 + _attr_max_temp = 30.0 + _attr_target_temperature_step = 1.0 + _attr_fan_mode = FAN_AUTO + _attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.DRY, + HVACMode.FAN_ONLY, + ] + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + ) + + def __init__( + self, entry: ConfigEntry, infrared_emitter_entity_id: str, device_type: str + ) -> None: + """Initialize the climate entity.""" + super().__init__(entry, unique_id_suffix="climate", device_name="Samsung AC") + self._infrared_emitter_entity_id = infrared_emitter_entity_id + self._device_type = device_type + + self._last_on_hvac_mode = HVACMode.COOL + + @override + async def async_added_to_hass(self) -> None: + """Restore the assumed state, as infrared cannot read it back from the AC.""" + await super().async_added_to_hass() + + last_state = await self.async_get_last_state() + if last_state is None or last_state.state in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + return + + if last_state.state in self._attr_hvac_modes: + self._attr_hvac_mode = HVACMode(last_state.state) + if (fan_mode := last_state.attributes.get(ATTR_FAN_MODE)) in HA_TO_LIB_FAN: + self._attr_fan_mode = fan_mode + if (temperature := last_state.attributes.get(ATTR_TEMPERATURE)) is not None: + self._attr_target_temperature = float(temperature) + + if self._attr_hvac_mode != HVACMode.OFF: + self._last_on_hvac_mode = self._attr_hvac_mode + elif (last_extra_data := await self.async_get_last_extra_data()) is not None: + restored = _SamsungAcExtraStoredData.from_dict(last_extra_data.as_dict()) + if restored is not None and restored.last_on_hvac_mode in ( + mode.value for mode in self._attr_hvac_modes if mode != HVACMode.OFF + ): + self._last_on_hvac_mode = HVACMode(restored.last_on_hvac_mode) + + @property + @override + def extra_restore_state_data(self) -> ExtraStoredData: + """Return extra data to be restored alongside the entity's state.""" + return _SamsungAcExtraStoredData( + last_on_hvac_mode=self._last_on_hvac_mode.value + ) + + async def _async_send_command(self) -> None: + """Generate the logical state and delegate transmission to the infrared platform.""" + hvac_mode = HA_TO_LIB_HVAC.get(self._attr_hvac_mode, SamsungAC0292HvacMode.OFF) + + if hvac_mode is SamsungAC0292HvacMode.OFF: + command = SamsungAC0292Command(hvac_mode=hvac_mode) + else: + fan_mode = HA_TO_LIB_FAN.get(self._attr_fan_mode, SamsungACFanMode.AUTO) + command = SamsungAC0292Command( + hvac_mode=hvac_mode, + target_temperature=int(self._attr_target_temperature), + fan_mode=fan_mode, + swing_mode=SamsungACSwingMode.OFF, + ) + + await self._send_command(command) + + @override + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set HVAC mode.""" + self._attr_hvac_mode = hvac_mode + if hvac_mode != HVACMode.OFF: + self._last_on_hvac_mode = hvac_mode + # The unit always transmits a fixed fan value in auto mode, regardless of + # what was previously selected; keep the reported state consistent with + # what's actually being sent. + if hvac_mode == HVACMode.AUTO: + self._attr_fan_mode = FAN_AUTO + + await self._async_send_command() + self.async_write_ha_state() + + @override + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set fan mode.""" + self._attr_fan_mode = fan_mode + await self._async_send_command() + self.async_write_ha_state() + + @override + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set temperature.""" + if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None: + self._attr_hvac_mode = hvac_mode + if hvac_mode != HVACMode.OFF: + self._last_on_hvac_mode = hvac_mode + if hvac_mode == HVACMode.AUTO: + self._attr_fan_mode = FAN_AUTO + + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None: + self._attr_target_temperature = round(temperature) + + if ATTR_HVAC_MODE in kwargs or ATTR_TEMPERATURE in kwargs: + await self._async_send_command() + self.async_write_ha_state() + + @override + async def async_turn_on(self) -> None: + """Turn the entity on.""" + await self.async_set_hvac_mode(self._last_on_hvac_mode) + + @override + async def async_turn_off(self) -> None: + """Turn the entity off.""" + await self.async_set_hvac_mode(HVACMode.OFF) diff --git a/homeassistant/components/samsung_infrared/config_flow.py b/homeassistant/components/samsung_infrared/config_flow.py index 7a5a223afbe29a..471354b05de726 100644 --- a/homeassistant/components/samsung_infrared/config_flow.py +++ b/homeassistant/components/samsung_infrared/config_flow.py @@ -9,7 +9,7 @@ async_get_emitters, ) from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, translation from homeassistant.helpers.selector import ( EntitySelector, EntitySelectorConfig, @@ -25,10 +25,6 @@ SamsungDeviceType, ) -DEVICE_TYPE_NAMES: dict[SamsungDeviceType, str] = { - SamsungDeviceType.TV: "TV", -} - class SamsungIrConfigFlow(ConfigFlow, domain=DOMAIN): """Handle config flow for Samsung IR.""" @@ -52,14 +48,19 @@ async def async_step_user( f"samsung_infrared_{device_type}_{entity_id}" ) self._abort_if_unique_id_configured() - - # Get entity name for the title ent_reg = er.async_get(self.hass) entry = ent_reg.async_get(entity_id) entity_name = ( entry.name or entry.original_name or entity_id if entry else entity_id ) - device_type_name = DEVICE_TYPE_NAMES[SamsungDeviceType(device_type)] + device_type_key = SamsungDeviceType(device_type).value + translations = await translation.async_get_translations( + self.hass, self.hass.config.language, "selector", {DOMAIN} + ) + device_type_name = translations.get( + f"component.{DOMAIN}.selector.device_type.options.{device_type_key}", + device_type_key, + ) title = f"Samsung {device_type_name} via {entity_name}" return self.async_create_entry(title=title, data=user_input) diff --git a/homeassistant/components/samsung_infrared/const.py b/homeassistant/components/samsung_infrared/const.py index 94bf2937874823..9079e0eae4abda 100644 --- a/homeassistant/components/samsung_infrared/const.py +++ b/homeassistant/components/samsung_infrared/const.py @@ -11,3 +11,4 @@ class SamsungDeviceType(StrEnum): """Samsung device types.""" TV = "tv" + AC = "ac" diff --git a/homeassistant/components/samsung_infrared/entity.py b/homeassistant/components/samsung_infrared/entity.py index 92eb814416d9a3..fa602898ea22fb 100644 --- a/homeassistant/components/samsung_infrared/entity.py +++ b/homeassistant/components/samsung_infrared/entity.py @@ -12,11 +12,16 @@ class SamsungIrEntity(Entity): _attr_has_entity_name = True - def __init__(self, entry: ConfigEntry, unique_id_suffix: str) -> None: + def __init__( + self, + entry: ConfigEntry, + unique_id_suffix: str, + device_name: str = "Samsung Device", + ) -> None: """Initialize Samsung IR entity.""" self._attr_unique_id = f"{entry.entry_id}_{unique_id_suffix}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, - name="Samsung TV", + name=device_name, manufacturer="Samsung", ) diff --git a/homeassistant/components/samsung_infrared/media_player.py b/homeassistant/components/samsung_infrared/media_player.py index 27a6aa108dc728..feb7a3eb1d9e28 100644 --- a/homeassistant/components/samsung_infrared/media_player.py +++ b/homeassistant/components/samsung_infrared/media_player.py @@ -74,7 +74,9 @@ class SamsungIrTvMediaPlayer( def __init__(self, entry: ConfigEntry, infrared_emitter_entity_id: str) -> None: """Initialize Samsung IR media player.""" - super().__init__(entry, unique_id_suffix="media_player") + super().__init__( + entry, unique_id_suffix="media_player", device_name="Samsung TV" + ) self._infrared_emitter_entity_id = infrared_emitter_entity_id @override diff --git a/homeassistant/components/samsung_infrared/strings.json b/homeassistant/components/samsung_infrared/strings.json index 464876d4122097..fed0e56c16bab1 100644 --- a/homeassistant/components/samsung_infrared/strings.json +++ b/homeassistant/components/samsung_infrared/strings.json @@ -148,6 +148,7 @@ "selector": { "device_type": { "options": { + "ac": "Air Conditioner", "tv": "TV" } } diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index d7dafca90e68b2..a020453335028f 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -120,13 +120,33 @@ def __init__( self.control_result: dict[str, Any] | None = None self._attr_name = None # Main device entity self._attr_unique_id: str = f"{coordinator.mac}-{block.description}" - if self.coordinator.device.settings["rollers"][0]["positioning"]: + self._positioning: bool = self.coordinator.device.settings["rollers"][0][ + "positioning" + ] + # Without positioning the direction it last travelled in is all there is, + # and that says nothing about where it stopped + self._attr_assumed_state = not self._positioning + if self._positioning: self._attr_supported_features |= CoverEntityFeature.SET_POSITION @property @override - def is_closed(self) -> bool: + def is_closed(self) -> bool | None: """If cover is closed.""" + if not self._positioning: + # An uncalibrated roller parks its position on 101, so the direction + # it last travelled in is all there is to go on + last_direction = self.coordinator.device.status["rollers"][0].get( + "last_direction" + ) + if self.control_result: + last_direction = self.control_result.get( + "last_direction", last_direction + ) + if not last_direction: + return None + return cast(str, last_direction) == "close" + if self.control_result: return cast(bool, self.control_result["current_pos"] == 0) @@ -134,8 +154,11 @@ def is_closed(self) -> bool: @property @override - def current_cover_position(self) -> int: + def current_cover_position(self) -> int | None: """Position of the cover.""" + if not self._positioning: + return None + if self.control_result: return cast(int, self.control_result["current_pos"]) diff --git a/homeassistant/components/sofar/__init__.py b/homeassistant/components/sofar/__init__.py index 3d3ae5dc2f0f04..b3740591935936 100644 --- a/homeassistant/components/sofar/__init__.py +++ b/homeassistant/components/sofar/__init__.py @@ -27,7 +27,12 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SELECT, Platform.SENSOR] +PLATFORMS: list[Platform] = [ + Platform.BUTTON, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] _IDENTITY_ATTEMPTS = 3 diff --git a/homeassistant/components/sofar/config_flow.py b/homeassistant/components/sofar/config_flow.py index ab3c1c9cb2bd17..446de1a9b46f14 100644 --- a/homeassistant/components/sofar/config_flow.py +++ b/homeassistant/components/sofar/config_flow.py @@ -10,6 +10,7 @@ from homeassistant.components.modbus import async_get_temporary_unit from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.selector import ( NumberSelector, @@ -41,6 +42,17 @@ ) +async def _async_probe( + hass: HomeAssistant, host: str, port: int, unit_id: int +) -> SofarInverter: + """Connect to the inverter and read its identity, or raise.""" + params = ModbusTcpParams(host=host, port=port) + async with async_get_temporary_unit(hass, params, unit_id) as unit: + device = SofarInverter(unit) + await device.async_update() + return device + + class SofarConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a Sofar config flow.""" @@ -54,15 +66,13 @@ async def async_step_user( errors: dict[str, str] = {} description_placeholders: dict[str, str] = {} if user_input is not None: - params = ModbusTcpParams( - host=user_input[CONF_HOST], port=user_input[CONF_PORT] - ) try: - async with async_get_temporary_unit( - self.hass, params, user_input[CONF_UNIT_ID] - ) as unit: - device = SofarInverter(unit) - await device.async_update() + device = await _async_probe( + self.hass, + user_input[CONF_HOST], + user_input[CONF_PORT], + user_input[CONF_UNIT_ID], + ) except (ModbusError, HomeAssistantError) as err: errors["base"] = "cannot_connect" description_placeholders["error"] = str(err) @@ -84,3 +94,41 @@ async def async_step_user( errors=errors, description_placeholders=description_placeholders, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle updating an existing entry's connection details.""" + reconfigure_entry = self._get_reconfigure_entry() + errors: dict[str, str] = {} + description_placeholders: dict[str, str] = {} + if user_input is not None: + try: + device = await _async_probe( + self.hass, + user_input[CONF_HOST], + user_input[CONF_PORT], + user_input[CONF_UNIT_ID], + ) + except (ModbusError, HomeAssistantError) as err: + errors["base"] = "cannot_connect" + description_placeholders["error"] = str(err) + else: + assert device.serial_number is not None + if not device.inverter_type: + errors["base"] = "unrecognized_inverter" + else: + await self.async_set_unique_id(device.serial_number) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + reconfigure_entry, data_updates=user_input + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input or reconfigure_entry.data + ), + errors=errors, + description_placeholders=description_placeholders, + ) diff --git a/homeassistant/components/sofar/diagnostics.py b/homeassistant/components/sofar/diagnostics.py new file mode 100644 index 00000000000000..8a4b2044aacf72 --- /dev/null +++ b/homeassistant/components/sofar/diagnostics.py @@ -0,0 +1,35 @@ +"""Diagnostics support for Sofar.""" + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from .coordinator import SofarConfigEntry + +TO_REDACT = {"serial_number"} + +_SERIAL_NUMBER_REGISTERS = range(0x0445, 0x044C) + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: SofarConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + device = entry.runtime_data.readings.device + raw = await device.async_read_raw() + if (holding := raw.get("holding")) is not None: + for address in _SERIAL_NUMBER_REGISTERS: + holding.pop(address, None) + + return async_redact_data( + { + "model": device.model, + "inverter_type": device.inverter_type, + "serial_number": device.serial_number, + "readings_components": device.readings_components, + "settings_components": device.settings_components, + "raw": raw, + }, + TO_REDACT, + ) diff --git a/homeassistant/components/sofar/quality_scale.yaml b/homeassistant/components/sofar/quality_scale.yaml index dc2bd37d87bdc7..12e6a96b089c61 100644 --- a/homeassistant/components/sofar/quality_scale.yaml +++ b/homeassistant/components/sofar/quality_scale.yaml @@ -49,7 +49,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: Modbus TCP gateways have no discovery protocol to update network info from. @@ -70,7 +70,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: todo @@ -79,4 +79,4 @@ rules: inject-websession: status: exempt comment: This integration communicates over Modbus TCP, not HTTP. - strict-typing: todo + strict-typing: done diff --git a/homeassistant/components/sofar/strings.json b/homeassistant/components/sofar/strings.json index cd21b53efd044a..06348487f51e59 100644 --- a/homeassistant/components/sofar/strings.json +++ b/homeassistant/components/sofar/strings.json @@ -1,13 +1,27 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "Please reconfigure the same inverter you originally set up." }, "error": { "cannot_connect": "Failed to connect: {error}", "unrecognized_inverter": "The device answered, but its serial number doesn't match a known Sofar model." }, "step": { + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "unit_id": "Modbus unit ID" + }, + "data_description": { + "host": "[%key:component::sofar::config::step::user::data_description::host%]", + "port": "[%key:component::sofar::config::step::user::data_description::port%]", + "unit_id": "[%key:component::sofar::config::step::user::data_description::unit_id%]" + } + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", diff --git a/homeassistant/components/sofar/switch.py b/homeassistant/components/sofar/switch.py new file mode 100644 index 00000000000000..86c59524799698 --- /dev/null +++ b/homeassistant/components/sofar/switch.py @@ -0,0 +1,81 @@ +"""Support for Sofar switches.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any, override + +from sofar_modbus.modern.device import SofarInverter +from sofar_modbus.modern.enums import RemoteSwitchOnOff + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SofarConfigEntry +from .entity import SofarEntity, SofarEntityDescription + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class SofarSwitchEntityDescription(SwitchEntityDescription, SofarEntityDescription): + """Describe a Sofar switch entity.""" + + write_fn: Callable[[SofarInverter, bool], Awaitable[None]] + + +SWITCH_DESCRIPTIONS: tuple[SofarSwitchEntityDescription, ...] = ( + SofarSwitchEntityDescription( + key="remote_switch_on_off", + component="remote", + name=None, + write_fn=lambda device, value: device.remote.write( + "remote_switch_on_off", + RemoteSwitchOnOff.ON if value else RemoteSwitchOnOff.OFF, + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SofarConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Sofar Inverter Modbus switch platform.""" + runtime_data = entry.runtime_data + served = runtime_data.served_components + async_add_entities( + SofarSwitch(runtime_data, description) + for description in SWITCH_DESCRIPTIONS + if description.component in served + ) + + +class SofarSwitch(SofarEntity, SwitchEntity): + """Defines a Sofar switch entity.""" + + entity_description: SofarSwitchEntityDescription + + @property + @override + def is_on(self) -> bool | None: + """Return whether the remote switch is on.""" + component = getattr(self.coordinator.device, self.entity_description.component) + value = getattr(component, self.entity_description.key) + return None if value is None else bool(value) + + async def _async_write(self, value: bool) -> None: + """Write the switch state to the device.""" + await self.entity_description.write_fn(self.coordinator.device, value) + await self.coordinator.async_request_refresh() + + @override + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the remote switch on.""" + await self._async_write(True) + + @override + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the remote switch off.""" + await self._async_write(False) diff --git a/homeassistant/components/solaredge/manifest.json b/homeassistant/components/solaredge/manifest.json index 3bb86ac9da1a9f..e1d91c12141eaf 100644 --- a/homeassistant/components/solaredge/manifest.json +++ b/homeassistant/components/solaredge/manifest.json @@ -14,5 +14,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "loggers": ["aiosolaredge", "solaredge_web"], - "requirements": ["aiosolaredge==1.0.2", "solaredge-web==0.3.1"] + "requirements": ["aiosolaredge==1.0.2", "solaredge-web==0.4.0"] } diff --git a/homeassistant/components/solaredge_modbus/__init__.py b/homeassistant/components/solaredge_modbus/__init__.py index 1b5d5e3bfe73cc..00aec9cd431e4a 100644 --- a/homeassistant/components/solaredge_modbus/__init__.py +++ b/homeassistant/components/solaredge_modbus/__init__.py @@ -7,8 +7,11 @@ """ from collections.abc import Set as AbstractSet +from datetime import datetime +from functools import partial from typing import TYPE_CHECKING +from modbus_connection import ModbusUnit from solaredged import SolarEdge, SolarEdgeConnectionError, SolarEdgeError from homeassistant.components.modbus import async_get_unit @@ -20,8 +23,10 @@ HomeAssistantError, ) from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.event import async_track_time_interval from .const import ( + ATTACHMENT_SCAN_INTERVAL, CONF_UNIT_ID, DOMAIN, LOGGER, @@ -45,6 +50,7 @@ Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SWITCH, ] @@ -139,6 +145,7 @@ async def async_setup_entry( settings=settings, device_info=device_info, inverter_device_id=inverter.id, + attachments=_attachment_identities(solaredge), ) if silent := solaredge.unresponsive_blocks & { @@ -156,9 +163,85 @@ async def async_setup_entry( await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # What is wired to the inverter is read while setting up, so a meter or + # battery added or removed later needs the entry to load again to be seen. + entry.async_on_unload( + async_track_time_interval( + hass, + partial(_async_reload_when_attachments_change, hass, entry, unit), + ATTACHMENT_SCAN_INTERVAL, + ) + ) + return True +def _attachment_identities(solaredge: SolarEdge) -> frozenset[str]: + """Return what the meters and batteries attached right now are known by.""" + return frozenset( + [ + *( + f"meter_{attachment_identity(meter, index)}" + for index, meter in enumerate(solaredge.meters, 1) + ), + *( + f"battery_{attachment_identity(battery, index)}" + for index, battery in enumerate(solaredge.batteries, 1) + ), + ] + ) + + +async def _async_reload_when_attachments_change( + hass: HomeAssistant, + entry: SolarEdgeModbusConfigEntry, + unit: ModbusUnit, + _now: datetime, +) -> None: + """Reload the entry when the hardware wired to the inverter changed.""" + solaredge = entry.runtime_data.solaredge + + # Swapping one meter for another leaves the count alone, but the polls have + # been reading the new one's serial number since it was wired in. + if _attachment_identities(solaredge) != entry.runtime_data.attachments: + LOGGER.info( + "%s: what is attached changed, reloading to pick that up", + entry.title, + ) + hass.config_entries.async_schedule_reload(entry.entry_id) + return + + try: + probed = await SolarEdge.async_probe(unit) + except SolarEdgeError as err: + # Nothing to conclude from a probe that did not finish; the coordinators + # report an inverter that stopped answering. + LOGGER.debug("%s: could not probe for attached hardware: %s", entry.title, err) + return + + for name, found, known in ( + (SUBSYSTEM_METERS, len(probed.meters), len(solaredge.meters)), + (SUBSYSTEM_BATTERIES, len(probed.batteries), len(solaredge.batteries)), + ): + if found == known: + continue + # A block that stayed silent is taken for absent, which is not the same + # as the inverter saying it is gone, and reloading on that would drop a + # device over one timeout. + if found < known and name in probed.unresponsive_blocks: + continue + + LOGGER.info( + "%s: %s went from %s to %s, reloading to pick that up", + entry.title, + name, + known, + found, + ) + hass.config_entries.async_schedule_reload(entry.entry_id) + return + + def _async_remove_stale_devices( hass: HomeAssistant, entry: SolarEdgeModbusConfigEntry, diff --git a/homeassistant/components/solaredge_modbus/const.py b/homeassistant/components/solaredge_modbus/const.py index 5453110499cfe4..076bf373cdfe70 100644 --- a/homeassistant/components/solaredge_modbus/const.py +++ b/homeassistant/components/solaredge_modbus/const.py @@ -39,3 +39,7 @@ # The control blocks hold what the site was told to do; they only move when # something writes them, so they do not need a live measurement's cadence. SETTINGS_SCAN_INTERVAL: Final = timedelta(minutes=5) + +# Meters and batteries are wired to an inverter by hand, usually with the power +# off, so looking for a change now and then is often enough. +ATTACHMENT_SCAN_INTERVAL: Final = timedelta(minutes=15) diff --git a/homeassistant/components/solaredge_modbus/coordinator.py b/homeassistant/components/solaredge_modbus/coordinator.py index 98f08e2a440c11..ab62ab75b7c561 100644 --- a/homeassistant/components/solaredge_modbus/coordinator.py +++ b/homeassistant/components/solaredge_modbus/coordinator.py @@ -1,7 +1,8 @@ """DataUpdateCoordinators for the SolarEdge Modbus integration.""" +import asyncio from collections.abc import Awaitable, Callable -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import timedelta from typing import Final, override @@ -168,6 +169,16 @@ class SolarEdgeModbusRuntimeData: settings: SolarEdgeModbusDataUpdateCoordinator device_info: DeviceInfo inverter_device_id: str + # What was attached when this entry was built, to notice a swap: a meter + # replaced by another one leaves the count alone. + attachments: frozenset[str] + + # The export mode and its flags share one register, which the library + # changes by taking its cached value, flipping bits and writing it back. + # Every platform has its own parallel-update semaphore, so a select and a + # switch can reach that read-modify-write at once and one loses the other's + # change; every write goes through this lock instead. + write_lock: asyncio.Lock = field(default_factory=asyncio.Lock) @property def solaredge(self) -> SolarEdge: diff --git a/homeassistant/components/solaredge_modbus/helpers.py b/homeassistant/components/solaredge_modbus/helpers.py index 7ffa0764ae3e9a..f1117ee87a61b0 100644 --- a/homeassistant/components/solaredge_modbus/helpers.py +++ b/homeassistant/components/solaredge_modbus/helpers.py @@ -31,7 +31,7 @@ def create_modbus_params( def solaredge_exception_handler[_EntityT: SolarEdgeModbusEntity, **_P]( func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]], ) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: - """Decorate SolarEdge writes to translate what the library raises. + """Decorate SolarEdge writes to serialize them and translate library errors. A successful write updates the library's decoded cache, so listeners are nudged to re-read entity state without waiting for the next poll. @@ -39,7 +39,8 @@ def solaredge_exception_handler[_EntityT: SolarEdgeModbusEntity, **_P]( async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: try: - await func(self, *args, **kwargs) + async with self.coordinator.config_entry.runtime_data.write_lock: + await func(self, *args, **kwargs) self.coordinator.async_update_listeners() except SolarEdgeConnectionError as error: diff --git a/homeassistant/components/solaredge_modbus/icons.json b/homeassistant/components/solaredge_modbus/icons.json index f9a6ceec5827d8..f1c361ec633633 100644 --- a/homeassistant/components/solaredge_modbus/icons.json +++ b/homeassistant/components/solaredge_modbus/icons.json @@ -61,6 +61,14 @@ "state_of_health": { "default": "mdi:battery-heart-variant" } + }, + "switch": { + "external_production": { + "default": "mdi:solar-power-variant" + }, + "negative_site_limit": { + "default": "mdi:transmission-tower-import" + } } } } diff --git a/homeassistant/components/solaredge_modbus/quality_scale.yaml b/homeassistant/components/solaredge_modbus/quality_scale.yaml index 332c05eaa4dcae..7cb530fe3c04c1 100644 --- a/homeassistant/components/solaredge_modbus/quality_scale.yaml +++ b/homeassistant/components/solaredge_modbus/quality_scale.yaml @@ -61,7 +61,7 @@ rules: docs-supported-functions: todo docs-troubleshooting: todo docs-use-cases: todo - dynamic-devices: todo + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: done @@ -72,7 +72,7 @@ rules: repair-issues: status: exempt comment: No repairable issues are raised. - stale-devices: todo + stale-devices: done # Platinum async-dependency: done diff --git a/homeassistant/components/solaredge_modbus/strings.json b/homeassistant/components/solaredge_modbus/strings.json index c1d1e14712c54b..a86e3e6f2977e3 100644 --- a/homeassistant/components/solaredge_modbus/strings.json +++ b/homeassistant/components/solaredge_modbus/strings.json @@ -320,6 +320,14 @@ "voltage_phase_cn": { "name": "Voltage phase C-N" } + }, + "switch": { + "external_production": { + "name": "External production" + }, + "negative_site_limit": { + "name": "Negative site limit" + } } }, "exceptions": { diff --git a/homeassistant/components/solaredge_modbus/switch.py b/homeassistant/components/solaredge_modbus/switch.py new file mode 100644 index 00000000000000..437d33f4c78656 --- /dev/null +++ b/homeassistant/components/solaredge_modbus/switch.py @@ -0,0 +1,93 @@ +"""Support for SolarEdge Modbus switch entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any, override + +from solaredged import ExportControl + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SolarEdgeModbusConfigEntry +from .entity import SolarEdgeModbusControlEntity +from .helpers import solaredge_exception_handler + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class SolarEdgeModbusSwitchEntityDescription(SwitchEntityDescription): + """Describes a SolarEdge Modbus switch entity.""" + + is_on_fn: Callable[[ExportControl], bool | None] + set_fn: Callable[[ExportControl, bool], Awaitable[Any]] + + +EXPORT_SWITCHES: tuple[SolarEdgeModbusSwitchEntityDescription, ...] = ( + SolarEdgeModbusSwitchEntityDescription( + key="external_production", + translation_key="external_production", + entity_category=EntityCategory.CONFIG, + # An export-control flag the installer sets, and which needs a meter + # configuration this integration cannot see, so it has to be asked for. + entity_registry_enabled_default=False, + is_on_fn=lambda export: export.external_production, + set_fn=lambda export, enabled: export.set_external_production(enabled=enabled), + ), + SolarEdgeModbusSwitchEntityDescription( + key="negative_site_limit", + translation_key="negative_site_limit", + entity_category=EntityCategory.CONFIG, + # An export-control flag the installer sets, and which needs a meter + # configuration this integration cannot see, so it has to be asked for. + entity_registry_enabled_default=False, + is_on_fn=lambda export: export.negative_site_limit, + set_fn=lambda export, enabled: export.set_negative_site_limit(enabled=enabled), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SolarEdgeModbusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SolarEdge Modbus switch entities based on a config entry.""" + if (export := entry.runtime_data.solaredge.export_control) is None: + return + + async_add_entities( + SolarEdgeModbusSwitchEntity( + entry=entry, description=description, component=export + ) + for description in EXPORT_SWITCHES + ) + + +class SolarEdgeModbusSwitchEntity( + SolarEdgeModbusControlEntity[ExportControl], SwitchEntity +): + """Defines a SolarEdge Modbus switch entity.""" + + entity_description: SolarEdgeModbusSwitchEntityDescription + + @property + @override + def is_on(self) -> bool | None: + """Return the state of the switch.""" + return self.entity_description.is_on_fn(self._component) + + @solaredge_exception_handler + @override + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the switch.""" + await self.entity_description.set_fn(self._component, True) + + @solaredge_exception_handler + @override + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the switch.""" + await self.entity_description.set_fn(self._component, False) diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py index 6eddb716dd42dd..35f50d4cb44fc0 100644 --- a/homeassistant/components/songpal/media_player.py +++ b/homeassistant/components/songpal/media_player.py @@ -161,6 +161,11 @@ async def async_activate_websocket(self): """Activate websocket for listening if wanted.""" _LOGGER.debug("Activating websocket connection") + # Narrowed once here rather than at each call site: the entity is only + # ever added from async_setup_entry, so the platform always has an entry. + entry = self.platform.config_entry + assert entry is not None + async def _volume_changed(volume: VolumeChange): _LOGGER.debug("Volume changed: %s", volume) self._volume = volume.volume @@ -218,7 +223,11 @@ async def _try_reconnect(connect: ConnectChange): # back from a disconnected state. await self.async_update_ha_state(force_refresh=True) - self.hass.loop.create_task(self._dev.listen_notifications()) + entry.async_create_background_task( + self.hass, + self._dev.listen_notifications(), + "songpal-listen-notifications", + ) _LOGGER.warning( "[%s(%s)] Connection reestablished", self.name, self._dev.endpoint ) @@ -234,7 +243,9 @@ async def handle_stop(event): self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, handle_stop) - self.hass.loop.create_task(self._dev.listen_notifications()) + entry.async_create_background_task( + self.hass, self._dev.listen_notifications(), "songpal-listen-notifications" + ) @property @override diff --git a/homeassistant/components/switchbot_cloud/binary_sensor.py b/homeassistant/components/switchbot_cloud/binary_sensor.py index 55141e5b832e92..6fb36c5d1c2344 100644 --- a/homeassistant/components/switchbot_cloud/binary_sensor.py +++ b/homeassistant/components/switchbot_cloud/binary_sensor.py @@ -92,6 +92,10 @@ class SwitchBotCloudBinarySensorEntityDescription(BinarySensorEntityDescription) CALIBRATION_DESCRIPTION, DOOR_OPEN_DESCRIPTION, ), + "Smart Lock Ultra Max": ( + CALIBRATION_DESCRIPTION, + DOOR_OPEN_DESCRIPTION, + ), "Smart Lock Vision": ( CALIBRATION_DESCRIPTION, DOOR_OPEN_DESCRIPTION, diff --git a/homeassistant/components/switchbot_cloud/const.py b/homeassistant/components/switchbot_cloud/const.py index 7f1ae51f1988ba..c88ffd3dc85f06 100644 --- a/homeassistant/components/switchbot_cloud/const.py +++ b/homeassistant/components/switchbot_cloud/const.py @@ -94,6 +94,9 @@ class SwitchbotCloudDeviceConfig: "Smart Lock Ultra": SwitchbotCloudDeviceConfig( True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK) ), + "Smart Lock Ultra Max": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK) + ), "Smart Lock Vision": SwitchbotCloudDeviceConfig( True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK) ), diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index 376f69eb0ec916..75e63cf0f4c6e9 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -245,6 +245,10 @@ class SwitchbotCloudSensorEntityDescription(SensorEntityDescription): BATTERY_DESCRIPTION, LOCK_SENSOR_TYPE_LOCK_STATE_DESCRIPTION, ), + "Smart Lock Ultra Max": ( + BATTERY_DESCRIPTION, + LOCK_SENSOR_TYPE_LOCK_STATE_DESCRIPTION, + ), "Smart Lock Vision": (BATTERY_DESCRIPTION,), "Smart Lock Vision Pro": (BATTERY_DESCRIPTION,), "Lock Vision": (BATTERY_DESCRIPTION,), diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index 56dcd2282c576a..e7f24bad01234d 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -8,12 +8,17 @@ from velbusaio.controller import Velbus from velbusaio.exceptions import VelbusConnectionFailed +from velbusaio.helpers import get_property_key_map from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, PlatformNotReady -from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import ConfigType @@ -87,6 +92,47 @@ def _migrate_device_identifiers(hass: HomeAssistant, entry_id: str) -> None: dev_reg.async_update_device(device.id, new_identifiers=new_identifier) +async def _migrate_property_unique_ids(hass: HomeAssistant, entry_id: str) -> None: + """Ensure property entity unique_ids use {serial}-{property_key} format.""" + ent_reg = er.async_get(hass) + + property_key_map = await hass.async_add_executor_job(get_property_key_map) + for entry in er.async_entries_for_config_entry(ent_reg, entry_id): + if not entry.original_name: + continue + property_key = property_key_map.get(entry.original_name) + if property_key is None: + continue + # Derive the serial from the entity's own unique_id, not from the device + # registry, which another integration could overwrite. The program select + # historically used `{serial}-{channel}-program_select`; every other property + # uses channel number 0 (`{serial}-0`). Regular channels are always >=1, so a + # `-0` suffix and the `-program_select` suffix only ever belong to properties. + if entry.unique_id.endswith("-program_select"): + serial = entry.unique_id.removesuffix("-program_select").rsplit("-", 1)[0] + elif entry.unique_id.endswith("-0"): + serial = entry.unique_id.removesuffix("-0") + else: + continue + + expected_unique_id = f"{serial}-{property_key}" + if ent_reg.async_get_entity_id(entry.domain, DOMAIN, expected_unique_id): + # Target unique_id already exists (created by new code) — remove stale entry + _LOGGER.debug( + "Removing stale entity %s with outdated unique_id %s", + entry.entity_id, + entry.unique_id, + ) + ent_reg.async_remove(entry.entity_id) + else: + _LOGGER.debug( + "Migrating unique_id %s → %s", entry.unique_id, expected_unique_id + ) + ent_reg.async_update_entity( + entry.entity_id, new_unique_id=expected_unique_id + ) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the actions for the Velbus component.""" async_setup_services(hass) @@ -108,11 +154,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: VelbusConfigEntry) -> bo translation_key="connection_failed", ) from error + _migrate_device_identifiers(hass, entry.entry_id) + # Migrate unique ids before the bus scan to preserve entity history + await _migrate_property_unique_ids(hass, entry.entry_id) + task = hass.async_create_task(velbus_scan_task(controller, hass, entry.entry_id)) entry.runtime_data = VelbusData(controller=controller, scan_task=task) - _migrate_device_identifiers(hass, entry.entry_id) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/velbus/entity.py b/homeassistant/components/velbus/entity.py index f7374caa504f0e..bb16ed3d4c1370 100644 --- a/homeassistant/components/velbus/entity.py +++ b/homeassistant/components/velbus/entity.py @@ -32,8 +32,15 @@ def __init__(self, channel: VelbusChannel | VelbusProperty) -> None: self._channel = channel self._module_address = str(channel.get_module_address()) self._attr_name = channel.get_name() - serial = channel.get_module_serial() or self._module_address - self._attr_unique_id = f"{serial}-{channel.get_channel_number()}" + serial = channel.get_module_serial() + # Modules like the VMB4RY report a serial of "0"; fall back to the module + # address so entities on multiple such modules keep distinct unique ids. + if serial in (None, "", "0"): + serial = self._module_address + if isinstance(channel, VelbusProperty): + self._attr_unique_id = f"{serial}-{channel.get_property_key()}" + else: + self._attr_unique_id = f"{serial}-{channel.get_channel_number()}" def _get_identifier(self) -> str: """Return the identifier of the entity.""" diff --git a/homeassistant/components/velbus/select.py b/homeassistant/components/velbus/select.py index 4fd4b253faa2a2..a5b9f76fdd9d54 100644 --- a/homeassistant/components/velbus/select.py +++ b/homeassistant/components/velbus/select.py @@ -42,7 +42,6 @@ def __init__( """Initialize a select Velbus entity.""" super().__init__(channel) self._attr_options = self._channel.get_options() - self._attr_unique_id = f"{self._attr_unique_id}-program_select" # pylint: disable=home-assistant-entity-unique-id-redundant-platform @api_call @override diff --git a/homeassistant/components/vistapool/manifest.json b/homeassistant/components/vistapool/manifest.json index 42a944c3defe9d..68af542a7c3727 100644 --- a/homeassistant/components/vistapool/manifest.json +++ b/homeassistant/components/vistapool/manifest.json @@ -13,5 +13,5 @@ "iot_class": "cloud_push", "loggers": ["aioaquarite"], "quality_scale": "bronze", - "requirements": ["aioaquarite==0.9.2"] + "requirements": ["aioaquarite==0.11.0"] } diff --git a/homeassistant/components/vivotek/camera.py b/homeassistant/components/vivotek/camera.py index 6fde80808829b2..a726d3de6e6eac 100644 --- a/homeassistant/components/vivotek/camera.py +++ b/homeassistant/components/vivotek/camera.py @@ -1,14 +1,17 @@ """Support for Vivotek IP Cameras.""" +from collections.abc import Callable +from functools import partial import logging -from typing import TYPE_CHECKING, Final, override +from typing import TYPE_CHECKING, Any, Final, override -from libpyvivotek.vivotek import VivotekCamera +from libpyvivotek.vivotek import VivotekCamera, VivotekCameraError from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VivotekConfigEntry @@ -26,6 +29,40 @@ PLATFORM_SCHEMA: Final = cv.removed(DOMAIN, raise_if_present=False) +def _fetch_str_metadata( + fetcher: Callable[[], Any], + log_message: str, +) -> str | None: + """Fetch optional string metadata from the camera.""" + try: + value: Any = fetcher() + except VivotekCameraError: + _LOGGER.debug(log_message) + return None + + return value if isinstance(value, str) else None + + +def _fetch_metadata( + cam_client: VivotekCamera, entry_title: str +) -> tuple[str | None, ...]: + """Fetch optional metadata from the camera in a single executor job.""" + return ( + _fetch_str_metadata( + cam_client.get_serial, + f"Failed to fetch serial number for {entry_title}", + ), + _fetch_str_metadata( + partial(cam_client.get_param, "system_info_firmwareversion"), + f"Failed to fetch firmware version for {entry_title}", + ), + _fetch_str_metadata( + partial(cam_client.get_param, "system_info_modelname"), + f"Failed to fetch model for {entry_title}", + ), + ) + + async def async_setup_entry( hass: HomeAssistant, entry: VivotekConfigEntry, @@ -38,6 +75,12 @@ async def async_setup_entry( f"rtsp://{creds}@{config[CONF_IP_ADDRESS]}:554/{config[CONF_STREAM_PATH]}" ) cam_client = entry.runtime_data + serial_number, sw_version, model = await hass.async_add_executor_job( + _fetch_metadata, + cam_client, + entry.title, + ) + if TYPE_CHECKING: assert entry.unique_id is not None async_add_entities( @@ -46,6 +89,9 @@ async def async_setup_entry( cam_client, stream_source, entry.unique_id, + serial_number, + sw_version, + model, entry.options[CONF_FRAMERATE], entry.title, ) @@ -64,6 +110,9 @@ def __init__( cam_client: VivotekCamera, stream_source: str, unique_id: str, + serial_number: str | None, + sw_version: str | None, + model: str | None, framerate: int, name: str, ) -> None: @@ -73,7 +122,16 @@ def __init__( self._attr_frame_interval = 1 / framerate self._attr_unique_id = unique_id self._attr_name = name + self._attr_available = True self._stream_source = stream_source + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer=DEFAULT_CAMERA_BRAND, + model=model, + name=name, + serial_number=serial_number, + sw_version=sw_version, + ) @override def camera_image( @@ -101,5 +159,9 @@ def enable_motion_detection(self) -> None: def update(self) -> None: """Update entity status.""" - self._attr_model = self._cam.model_name - self._attr_available = self._attr_model is not None + try: + self._cam.get_serial() + except VivotekCameraError: + self._attr_available = False + else: + self._attr_available = True diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c02056a0e11128..cc2938e18598e9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -156,7 +156,7 @@ iso4217!=1.10.20220401 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==6.32.0 +protobuf==6.33.6 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder diff --git a/mypy.ini b/mypy.ini index a235a9ef7e8311..423e23ab14201e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5238,6 +5238,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.sofar.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.solaredge_modbus.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pylint/plugins/README.md b/pylint/plugins/README.md index 9b9942ded199ff..eae640b333b528 100644 --- a/pylint/plugins/README.md +++ b/pylint/plugins/README.md @@ -138,6 +138,7 @@ Every check has a code following the | `W7431` | [`home-assistant-options-flow-field-not-translated`](#w7431-home-assistant-options-flow-field-not-translated) | Options flow form field missing translation in `strings.json` | | `W7432` | [`home-assistant-subentry-flow-field-not-translated`](#w7432-home-assistant-subentry-flow-field-not-translated) | Subentry flow form field missing translation in `strings.json` | | `W7433` | [`home-assistant-missing-test-before-configure`](#w7433-home-assistant-missing-test-before-configure) | Config flow should test the connection before creating an entry | +| `W7435` | [`home-assistant-json-fixture`](#w7435-home-assistant-json-fixture) | Use a JSON fixture helper instead of parsing a loaded fixture | ## `home_assistant_logger` checker @@ -951,3 +952,34 @@ websocket command, which is only registered when the `usb` integration is set up. The selector therefore requires `usb` as a hard dependency (`"dependencies": ["usb"]`); `after_dependencies` is not sufficient because it does not force `usb` to be set up. + + +## `home_assistant_json_fixture` checker + +Detects tests that load a fixture and then parse it as JSON, instead of +using the dedicated JSON fixture helpers from `tests.common`. Only runs on +test modules. `tests.common` itself is exempt, since it defines the JSON +fixture helpers, which legitimately parse a loaded fixture. + +### `W7435`: `home-assistant-json-fixture` + +A fixture loader (`load_fixture`, `load_fixture_bytes`, or +`async_load_fixture`) is wrapped in a JSON-parsing call (`json.loads`, +`json.load`, or the `json_loads` / `json_loads_array` / `json_loads_object` +helpers), e.g.: + +```python +data = json.loads(load_fixture("data.json", DOMAIN)) +data = json_loads_object(await async_load_fixture(hass, "data.json")) +``` + +Use the dedicated helper that loads and parses in one step instead: + +- `load_json_value_fixture` / `async_load_json_object_fixture` for a JSON value, +- `load_json_array_fixture` / `async_load_json_array_fixture` for a JSON array, +- `load_json_object_fixture` / `async_load_json_object_fixture` for a JSON object. + +```python +data = load_json_object_fixture("data.json", DOMAIN) +data = await async_load_json_object_fixture(hass, "data.json", DOMAIN) +``` diff --git a/pylint/plugins/pylint_home_assistant/checkers/json_fixture.py b/pylint/plugins/pylint_home_assistant/checkers/json_fixture.py new file mode 100644 index 00000000000000..4ece464d13b767 --- /dev/null +++ b/pylint/plugins/pylint_home_assistant/checkers/json_fixture.py @@ -0,0 +1,98 @@ +"""Checker for JSON-parsing a fixture instead of using the JSON fixture helpers.""" + +from astroid import nodes +from pylint.checkers import BaseChecker +from pylint.lint import PyLinter + +from pylint_home_assistant.helpers.module_info import is_test_module + +# JSON-parsing helpers imported as bare names (from homeassistant.util.json). +_JSON_PARSE_NAMES = frozenset( + { + "json_loads", + "json_loads_array", + "json_loads_object", + } +) + +# Attribute-form JSON parsers, only when called on the ``json`` module. +_JSON_PARSE_ATTRS = frozenset({"loads", "load"}) + +# Fixture loaders whose result is a raw string/bytes. +_FIXTURE_LOADER_NAMES = frozenset( + { + "load_fixture", + "load_fixture_bytes", + "async_load_fixture", + } +) + + +def _is_json_parse_call(node: nodes.Call) -> bool: + """Return True if the call parses JSON.""" + func = node.func + if isinstance(func, nodes.Attribute): + return ( + func.attrname in _JSON_PARSE_ATTRS + and isinstance(func.expr, nodes.Name) + and func.expr.name == "json" + ) + if isinstance(func, nodes.Name): + return func.name in _JSON_PARSE_NAMES + return False + + +def _is_fixture_loader(node: nodes.NodeNG) -> bool: + """Return True if the node is a call to a fixture loader.""" + if isinstance(node, nodes.Await): + node = node.value + if not isinstance(node, nodes.Call): + return False + func = node.func + if isinstance(func, nodes.Attribute): + return func.attrname in _FIXTURE_LOADER_NAMES + if isinstance(func, nodes.Name): + return func.name in _FIXTURE_LOADER_NAMES + return False + + +class HassJsonFixtureChecker(BaseChecker): + """Checker for JSON-parsing a loaded fixture.""" + + name = "home_assistant_json_fixture" + priority = -1 + msgs = { + "W7435": ( + "Use a JSON fixture helper (e.g. load_json_object_fixture) instead of " + "parsing a loaded fixture", + "home-assistant-json-fixture", + "Used when a fixture is loaded and then parsed as JSON instead of using " + "the dedicated JSON fixture helpers", + ), + } + options = () + + _in_test_module: bool + + def visit_module(self, node: nodes.Module) -> None: + """Visit a module definition.""" + # ``tests.common`` defines the JSON fixture helpers themselves, which + # legitimately parse a loaded fixture. + self._in_test_module = is_test_module(node.name) and node.name != "tests.common" + + def visit_call(self, node: nodes.Call) -> None: + """Check for JSON parsing of a loaded fixture.""" + if ( + not self._in_test_module + or not _is_json_parse_call(node) + or not node.args + or not _is_fixture_loader(node.args[0]) + ): + return + + self.add_message("home-assistant-json-fixture", node=node) + + +def register(linter: PyLinter) -> None: + """Register the checker.""" + linter.register_checker(HassJsonFixtureChecker(linter)) diff --git a/requirements_all.txt b/requirements_all.txt index a70979ecfbf62f..037e4eeef8741b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -206,7 +206,7 @@ aioapcaccess==1.0.0 aioaquacell==1.0.0 # homeassistant.components.vistapool -aioaquarite==0.9.2 +aioaquarite==0.11.0 # homeassistant.components.aseko_pool_live aioaseko==1.0.0 @@ -917,7 +917,7 @@ electrickiwi-api==0.9.14 elevenlabs==2.51.0 # homeassistant.components.elgato -elgato==6.1.0 +elgato==6.1.1 # homeassistant.components.elkm1 elkm1-lib==2.2.15 @@ -1156,7 +1156,7 @@ goodwe==0.4.10 google-api-python-client==2.71.0 # homeassistant.components.google_pubsub -google-cloud-pubsub==2.29.0 +google-cloud-pubsub==2.39.2 # homeassistant.components.google_cloud google-cloud-speech==2.40.0 @@ -1562,7 +1562,7 @@ lw12==0.9.2 lxml==6.1.2 # homeassistant.components.lyngdorf -lyngdorf==1.11.0 +lyngdorf==2.1.0 # homeassistant.components.matrix matrix-nio==0.26.0 @@ -1878,7 +1878,7 @@ panasonic-viera==0.4.4 pdunehd==1.3.3 # homeassistant.components.peblar -peblar==1.0.1 +peblar==2.0.0 # homeassistant.components.peco peco==0.1.2 @@ -2026,7 +2026,7 @@ pyElectra==1.2.4 pyEmby==1.10 # homeassistant.components.hikvision -pyHik==0.4.3 +pyHik==0.4.4 # homeassistant.components.homee pyHomee==1.4.4 @@ -3117,7 +3117,7 @@ sofar-modbus==0.6.0 solaredge-local==0.2.3 # homeassistant.components.solaredge -solaredge-web==0.3.1 +solaredge-web==0.4.0 # homeassistant.components.solaredge_modbus solaredged==0.2.3 diff --git a/requirements_test.txt b/requirements_test.txt index 2ba54f27e62a28..3d59ef0d753a49 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -22,7 +22,7 @@ pydantic==2.13.4 PyGithub==2.9.1 pylint==4.0.7 pylint-per-file-ignores==3.2.1 -pipdeptree==2.26.1 +pipdeptree==4.2.2 pytest-asyncio==1.4.0 pytest-aiohttp==1.1.1 pytest-cov==7.1.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 5304b2be7fbe74..0b335ad5445161 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -140,7 +140,7 @@ # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==6.32.0 +protobuf==6.33.6 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder diff --git a/tests/auth/providers/test_homeassistant.py b/tests/auth/providers/test_homeassistant.py index 07c3062738bdd1..42a4a811b93fc4 100644 --- a/tests/auth/providers/test_homeassistant.py +++ b/tests/auth/providers/test_homeassistant.py @@ -1,7 +1,6 @@ """Test the Home Assistant local auth provider.""" import asyncio -from typing import Any from unittest.mock import Mock, patch import pytest @@ -14,7 +13,6 @@ homeassistant as hass_auth, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component @@ -26,15 +24,6 @@ async def data(hass: HomeAssistant) -> hass_auth.Data: return data -@pytest.fixture -async def legacy_data(hass: HomeAssistant) -> hass_auth.Data: - """Create a loaded legacy data class.""" - data = hass_auth.Data(hass) - await data.async_load() - data.is_legacy = True - return data - - @pytest.fixture async def load_auth_component(hass: HomeAssistant) -> None: """Load the auth component for translations.""" @@ -209,118 +198,6 @@ async def test_get_or_create_credentials( assert credentials1 is credentials2 -# Legacy mode - - -async def test_legacy_adding_user(legacy_data: hass_auth.Data) -> None: - """Test in legacy mode adding a user.""" - legacy_data.add_auth("test-user", "test-pass") - legacy_data.validate_login("test-user", "test-pass") - - -async def test_legacy_validating_password_invalid_password( - legacy_data: hass_auth.Data, -) -> None: - """Test in legacy mode validating an invalid password.""" - legacy_data.add_auth("test-user", "test-pass") - - with pytest.raises(hass_auth.InvalidAuth): - legacy_data.validate_login("test-user", "invalid-pass") - - -async def test_legacy_changing_password(legacy_data: hass_auth.Data) -> None: - """Test in legacy mode adding a user.""" - user = "test-user" - legacy_data.add_auth(user, "test-pass") - legacy_data.change_password(user, "new-pass") - - with pytest.raises(hass_auth.InvalidAuth): - legacy_data.validate_login(user, "test-pass") - - legacy_data.validate_login(user, "new-pass") - - -async def test_legacy_changing_password_raises_invalid_user( - legacy_data: hass_auth.Data, -) -> None: - """Test in legacy mode that we initialize an empty config.""" - with pytest.raises(hass_auth.InvalidUser): - legacy_data.change_password("non-existing", "pw") - - -async def test_legacy_login_flow_validates( - legacy_data: hass_auth.Data, hass: HomeAssistant -) -> None: - """Test in legacy mode login flow.""" - legacy_data.add_auth("test-user", "test-pass") - await legacy_data.async_save() - - provider = hass_auth.HassAuthProvider( - hass, auth_store.AuthStore(hass), {"type": "homeassistant"} - ) - flow = await provider.async_login_flow({}) - result = await flow.async_step_init() - assert result["type"] is data_entry_flow.FlowResultType.FORM - - result = await flow.async_step_init( - {"username": "incorrect-user", "password": "test-pass"} - ) - assert result["type"] is data_entry_flow.FlowResultType.FORM - assert result["errors"]["base"] == "invalid_auth" - - result = await flow.async_step_init( - {"username": "test-user", "password": "incorrect-pass"} - ) - assert result["type"] is data_entry_flow.FlowResultType.FORM - assert result["errors"]["base"] == "invalid_auth" - - result = await flow.async_step_init( - {"username": "test-user", "password": "test-pass"} - ) - assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["data"]["username"] == "test-user" - - -async def test_legacy_saving_loading( - legacy_data: hass_auth.Data, hass: HomeAssistant -) -> None: - """Test in legacy mode saving and loading JSON.""" - legacy_data.add_auth("test-user", "test-pass") - legacy_data.add_auth("second-user", "second-pass") - await legacy_data.async_save() - - legacy_data = hass_auth.Data(hass) - await legacy_data.async_load() - legacy_data.is_legacy = True - legacy_data.validate_login("test-user", "test-pass") - legacy_data.validate_login("second-user", "second-pass") - - with pytest.raises(hass_auth.InvalidAuth): - legacy_data.validate_login("test-user ", "test-pass") - - -async def test_legacy_get_or_create_credentials( - hass: HomeAssistant, legacy_data: hass_auth.Data -) -> None: - """Test in legacy mode that we can get or create credentials.""" - manager = await auth_manager_from_config(hass, [{"type": "homeassistant"}], []) - provider = manager.auth_providers[0] - provider.data = legacy_data - credentials1 = await provider.async_get_or_create_credentials({"username": "hello"}) - - with patch.object(provider, "async_credentials", return_value=[credentials1]): - credentials2 = await provider.async_get_or_create_credentials( - {"username": "hello"} - ) - assert credentials1 is credentials2 - - with patch.object(provider, "async_credentials", return_value=[credentials1]): - credentials3 = await provider.async_get_or_create_credentials( - {"username": "hello "} - ) - assert credentials1 is not credentials3 - - async def test_race_condition_in_data_loading(hass: HomeAssistant) -> None: """Test race condition in the hass_auth.Data loading. @@ -361,28 +238,6 @@ def test_change_username(data: hass_auth.Data) -> None: assert users[0]["username"] == "new-user" -@pytest.mark.parametrize("username", ["test-user ", "TEST-USER"]) -def test_change_username_legacy(legacy_data: hass_auth.Data, username: str) -> None: - """Test changing username.""" - # Cannot use add_auth as it normalizes username - legacy_data.users.append( - { - "username": username, - "password": legacy_data.hash_password("test-pass", True).decode(), - } - ) - - users = legacy_data.users - assert len(users) == 1 - assert users[0]["username"] == username - - legacy_data.change_username(username, "test-user") - - users = legacy_data.users - assert len(users) == 1 - assert users[0]["username"] == "test-user" - - def test_change_username_invalid_user(data: hass_auth.Data) -> None: """Test changing username raises on invalid user.""" data.add_auth("test-user", "test-pass") @@ -409,91 +264,3 @@ async def test_change_username_not_normalized( hass_auth.InvalidUsername, match='Username "TEST-user " is not normalized' ): data.change_username("test-user", "TEST-user ") - - -@pytest.mark.parametrize( - ("usernames_in_storage", "usernames_in_repair"), - [ - (["Uppercase"], '- "Uppercase"'), - ([" leading"], '- " leading"'), - (["trailing "], '- "trailing "'), - (["Test", "test", "Fritz "], '- "Fritz "\n- "Test"'), - ], -) -async def test_create_repair_on_legacy_usernames( - hass: HomeAssistant, - hass_storage: dict[str, Any], - issue_registry: ir.IssueRegistry, - usernames_in_storage: list[str], - usernames_in_repair: str, -) -> None: - """Test that we create a repair issue for legacy usernames.""" - assert not issue_registry.issues.get( - ("auth", "homeassistant_provider_not_normalized_usernames") - ), "Repair issue already exists" - - hass_storage[hass_auth.STORAGE_KEY] = { - "version": 1, - "minor_version": 1, - "key": "auth_provider.homeassistant", - "data": { - "users": [ - { - "username": username, - "password": "onlyherebecauseweneedapasswordstring", - } - for username in usernames_in_storage - ] - }, - } - data = hass_auth.Data(hass) - await data.async_load() - issue = issue_registry.issues.get( - ("auth", "homeassistant_provider_not_normalized_usernames") - ) - assert issue, "Repair issue not created" - assert issue.translation_placeholders == {"usernames": usernames_in_repair} - - -async def test_delete_repair_after_fixing_usernames( - hass: HomeAssistant, - hass_storage: dict[str, Any], - issue_registry: ir.IssueRegistry, -) -> None: - """Test that the repair is deleted after fixing the usernames.""" - hass_storage[hass_auth.STORAGE_KEY] = { - "version": 1, - "minor_version": 1, - "key": "auth_provider.homeassistant", - "data": { - "users": [ - { - "username": "Test", - "password": "onlyherebecauseweneedapasswordstring", - }, - { - "username": "bla ", - "password": "onlyherebecauseweneedapasswordstring", - }, - ] - }, - } - data = hass_auth.Data(hass) - await data.async_load() - issue = issue_registry.issues.get( - ("auth", "homeassistant_provider_not_normalized_usernames") - ) - assert issue, "Repair issue not created" - assert issue.translation_placeholders == {"usernames": '- "Test"\n- "bla "'} - - data.change_username("Test", "test") - issue = issue_registry.issues.get( - ("auth", "homeassistant_provider_not_normalized_usernames") - ) - assert issue - assert issue.translation_placeholders == {"usernames": '- "bla "'} - - data.change_username("bla ", "bla") - assert not issue_registry.issues.get( - ("auth", "homeassistant_provider_not_normalized_usernames") - ), "Repair issue should be deleted" diff --git a/tests/components/abode/test_lock.py b/tests/components/abode/test_lock.py index 8ca91dabc15b6b..bb041fe9ff19a5 100644 --- a/tests/components/abode/test_lock.py +++ b/tests/components/abode/test_lock.py @@ -19,7 +19,7 @@ from .common import setup_platform -from tests.common import async_load_fixture +from tests.common import async_load_json_array_fixture DEVICE_ID = "lock.test_lock" @@ -75,7 +75,7 @@ async def test_retrofit_lock_discovered( hass: HomeAssistant, requests_mock: Mocker ) -> None: """Test retrofit locks are discovered as lock entities.""" - devices = json.loads(await async_load_fixture(hass, "devices.json", "abode")) + devices = await async_load_json_array_fixture(hass, "devices.json", "abode") for device in devices: if device["type_tag"] == "device_type.door_lock": device["type_tag"] = "device_type.retrofit_lock" diff --git a/tests/components/actron_air/conftest.py b/tests/components/actron_air/conftest.py index 79a31d4a12d9c3..d604101428fcc9 100644 --- a/tests/components/actron_air/conftest.py +++ b/tests/components/actron_air/conftest.py @@ -2,7 +2,6 @@ import asyncio from collections.abc import Generator -import json from unittest.mock import AsyncMock, MagicMock, patch from actron_neo_api.models.auth import ActronAirDeviceCode, ActronAirUserInfo @@ -17,7 +16,7 @@ from . import setup_integration -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture @@ -105,7 +104,7 @@ async def slow_poll_for_token(device_code): # Build status from fixture JSON status = ActronAirStatus.model_validate( - json.loads(load_fixture("status.json", DOMAIN)) + load_json_object_fixture("status.json", DOMAIN) ) status.set_api(api) diff --git a/tests/components/altruist/conftest.py b/tests/components/altruist/conftest.py index a8107b0837b0db..595f4ca2152627 100644 --- a/tests/components/altruist/conftest.py +++ b/tests/components/altruist/conftest.py @@ -1,7 +1,6 @@ """Altruist tests configuration.""" from collections.abc import Generator -import json from unittest.mock import AsyncMock, Mock, patch from altruistclient import AltruistDeviceModel, AltruistError @@ -10,7 +9,7 @@ from homeassistant.components.altruist.const import DOMAIN from homeassistant.const import CONF_HOST -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_array_fixture @pytest.fixture @@ -61,11 +60,11 @@ def mock_altruist_client(mock_altruist_device: Mock) -> Generator[AsyncMock]: mock_instance = AsyncMock() mock_instance.device = mock_altruist_device mock_instance.device_id = mock_altruist_device.id - mock_instance.sensor_names = json.loads( - load_fixture("sensor_names.json", DOMAIN) + mock_instance.sensor_names = load_json_array_fixture( + "sensor_names.json", DOMAIN ) - mock_instance.fetch_data.return_value = json.loads( - load_fixture("real_data.json", DOMAIN) + mock_instance.fetch_data.return_value = load_json_array_fixture( + "real_data.json", DOMAIN ) mock_client_class.from_ip_address = AsyncMock(return_value=mock_instance) diff --git a/tests/components/apple_tv/conftest.py b/tests/components/apple_tv/conftest.py index 4b791d645e605d..3c079656b0296d 100644 --- a/tests/components/apple_tv/conftest.py +++ b/tests/components/apple_tv/conftest.py @@ -61,9 +61,11 @@ async def _pair(config, protocol, loop, session=None, **kwargs): await http.create_session(session), config.get_service(protocol) ) handler.always_fail = mock_pair.always_fail + mock_pair.handler = handler return handler mock_pair.always_fail = False + mock_pair.handler = None mock_pair.side_effect = _pair yield mock_pair diff --git a/tests/components/apple_tv/test_config_flow.py b/tests/components/apple_tv/test_config_flow.py index c8289debdc98dc..d896cd59f3ab28 100644 --- a/tests/components/apple_tv/test_config_flow.py +++ b/tests/components/apple_tv/test_config_flow.py @@ -155,6 +155,59 @@ async def test_user_adds_full_device(hass: HomeAssistant) -> None: } +@pytest.mark.usefixtures("mrp_device") +async def test_user_pair_leading_zero_pin( + hass: HomeAssistant, pairing: AsyncMock +) -> None: + """Test that a pairing PIN with a leading zero is passed through as a string.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"device_input": "MRP Device"}, + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "pair_with_pin" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"pin": "0123"} + ) + assert pairing.handler.pin_code == "0123" + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mrp_device") +@pytest.mark.parametrize("invalid_pin", ["abcd", "12ab", "١٢٣٤", "123\n"]) +async def test_user_pair_non_numeric_pin( + hass: HomeAssistant, pairing: AsyncMock, invalid_pin: str +) -> None: + """Test that a non-numeric PIN is rejected at the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"device_input": "MRP Device"}, + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "pair_with_pin" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"pin": invalid_pin} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"pin": "invalid_pin"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"pin": "0123"} + ) + assert pairing.handler.pin_code == "0123" + assert result["type"] is FlowResultType.CREATE_ENTRY + + @pytest.mark.usefixtures("dmap_device", "dmap_pin", "pairing") async def test_user_adds_dmap_device(hass: HomeAssistant) -> None: """Test adding device with only DMAP service.""" diff --git a/tests/components/awair/conftest.py b/tests/components/awair/conftest.py index 91c3d31e35bf54..9bb58588515710 100644 --- a/tests/components/awair/conftest.py +++ b/tests/components/awair/conftest.py @@ -1,73 +1,71 @@ """Fixtures for testing Awair integration.""" -import json - import pytest -from tests.common import load_fixture +from tests.common import load_json_object_fixture @pytest.fixture(name="cloud_devices", scope="package") def cloud_devices_fixture(): """Fixture representing devices returned by Awair Cloud API.""" - return json.loads(load_fixture("awair/cloud_devices.json")) + return load_json_object_fixture("awair/cloud_devices.json") @pytest.fixture(name="local_devices", scope="package") def local_devices_fixture(): """Fixture representing devices returned by Awair local API.""" - return json.loads(load_fixture("awair/local_devices.json")) + return load_json_object_fixture("awair/local_devices.json") @pytest.fixture(name="gen1_data", scope="package") def gen1_data_fixture(): """Fixture representing data returned from Gen1 Awair device.""" - return json.loads(load_fixture("awair/awair.json")) + return load_json_object_fixture("awair/awair.json") @pytest.fixture(name="gen2_data", scope="package") def gen2_data_fixture(): """Fixture representing data returned from Gen2 Awair device.""" - return json.loads(load_fixture("awair/awair-r2.json")) + return load_json_object_fixture("awair/awair-r2.json") @pytest.fixture(name="glow_data", scope="package") def glow_data_fixture(): """Fixture representing data returned from Awair glow device.""" - return json.loads(load_fixture("awair/glow.json")) + return load_json_object_fixture("awair/glow.json") @pytest.fixture(name="mint_data", scope="package") def mint_data_fixture(): """Fixture representing data returned from Awair mint device.""" - return json.loads(load_fixture("awair/mint.json")) + return load_json_object_fixture("awair/mint.json") @pytest.fixture(name="no_devices", scope="package") def no_devicess_fixture(): """Fixture representing when no devices are found in Awair's cloud API.""" - return json.loads(load_fixture("awair/no_devices.json")) + return load_json_object_fixture("awair/no_devices.json") @pytest.fixture(name="awair_offline", scope="package") def awair_offline_fixture(): """Fixture representing when Awair devices are offline.""" - return json.loads(load_fixture("awair/awair-offline.json")) + return load_json_object_fixture("awair/awair-offline.json") @pytest.fixture(name="omni_data", scope="package") def omni_data_fixture(): """Fixture representing data returned from Awair omni device.""" - return json.loads(load_fixture("awair/omni.json")) + return load_json_object_fixture("awair/omni.json") @pytest.fixture(name="user", scope="package") def user_fixture(): """Fixture representing the User object returned from Awair's Cloud API.""" - return json.loads(load_fixture("awair/user.json")) + return load_json_object_fixture("awair/user.json") @pytest.fixture(name="local_data", scope="package") def local_data_fixture(): """Fixture representing data returned from Awair local device.""" - return json.loads(load_fixture("awair/awair-local.json")) + return load_json_object_fixture("awair/awair-local.json") diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index e4e54737bb0906..83752aa6f5b1aa 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -28,7 +28,6 @@ from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from homeassistant.util.json import json_loads from . import ( FakeRemoteScanner as FakeScanner, @@ -39,7 +38,11 @@ patch_bluetooth_time, ) -from tests.common import MockConfigEntry, async_fire_time_changed, async_load_fixture +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_load_json_object_fixture, +) @pytest.mark.parametrize("name_2", [None, "w"]) @@ -304,8 +307,10 @@ async def test_restore_history_remote_adapter( ) -> None: """Test we can restore history for a remote adapter.""" - data = hass_storage[storage.REMOTE_SCANNER_STORAGE_KEY] = json_loads( - await async_load_fixture(hass, "bluetooth.remote_scanners", bluetooth.DOMAIN) + data = hass_storage[ + storage.REMOTE_SCANNER_STORAGE_KEY + ] = await async_load_json_object_fixture( + hass, "bluetooth.remote_scanners", bluetooth.DOMAIN ) now = time.time() timestamps = data["data"]["atom-bluetooth-proxy-ceaac4"][ diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 7f6dd563340527..190eda8aa32eab 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -44,7 +44,6 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from homeassistant.util.dt import utcnow -from homeassistant.util.json import json_loads from . import ( HCI0_SOURCE_ADDRESS, @@ -66,7 +65,7 @@ MockModule, async_call_logger_set_level, async_fire_time_changed, - async_load_fixture, + async_load_json_object_fixture, mock_integration, ) @@ -466,8 +465,10 @@ async def test_restore_history_from_dbus_and_remote_adapters( """Test we can restore history from dbus along with remote adapters.""" address = "AA:BB:CC:CC:CC:FF" - data = hass_storage[storage.REMOTE_SCANNER_STORAGE_KEY] = json_loads( - await async_load_fixture(hass, "bluetooth.remote_scanners", bluetooth.DOMAIN) + data = hass_storage[ + storage.REMOTE_SCANNER_STORAGE_KEY + ] = await async_load_json_object_fixture( + hass, "bluetooth.remote_scanners", bluetooth.DOMAIN ) now = time.time() timestamps = data["data"]["atom-bluetooth-proxy-ceaac4"][ @@ -508,10 +509,10 @@ async def test_restore_history_from_dbus_and_corrupted_remote_adapters( """Test history restore when remote adapters data is corrupted.""" address = "AA:BB:CC:CC:CC:FF" - data = hass_storage[storage.REMOTE_SCANNER_STORAGE_KEY] = json_loads( - await async_load_fixture( - hass, "bluetooth.remote_scanners.corrupt", bluetooth.DOMAIN - ) + data = hass_storage[ + storage.REMOTE_SCANNER_STORAGE_KEY + ] = await async_load_json_object_fixture( + hass, "bluetooth.remote_scanners.corrupt", bluetooth.DOMAIN ) now = time.time() timestamps = data["data"]["atom-bluetooth-proxy-ceaac4"][ diff --git a/tests/components/control4/conftest.py b/tests/components/control4/conftest.py index de66348dfcc2df..bb235436bb7801 100644 --- a/tests/components/control4/conftest.py +++ b/tests/components/control4/conftest.py @@ -1,7 +1,6 @@ """Common fixtures for the Control4 tests.""" from collections.abc import AsyncGenerator, Generator -import json from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -9,7 +8,11 @@ from homeassistant.components.control4.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform -from tests.common import MockConfigEntry, load_fixture +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) MOCK_HOST = "192.168.1.100" MOCK_USERNAME = "test-username" @@ -83,10 +86,10 @@ def mock_c4_director() -> Generator[MagicMock]: ), ): mock_director = mock_director_class.return_value - all_items = json.loads(load_fixture("director_all_items.json", DOMAIN)) + all_items = load_json_array_fixture("director_all_items.json", DOMAIN) mock_director.get_all_item_info = AsyncMock(return_value=all_items) mock_director.get_ui_configuration = AsyncMock( - return_value=json.loads(load_fixture("ui_configuration.json", DOMAIN)) + return_value=load_json_object_fixture("ui_configuration.json", DOMAIN) ) mock_director.get_item_variables = AsyncMock(return_value=[]) yield mock_director diff --git a/tests/components/devialet/test_diagnostics.py b/tests/components/devialet/test_diagnostics.py index 4bf74d114600c7..690c9f14756683 100644 --- a/tests/components/devialet/test_diagnostics.py +++ b/tests/components/devialet/test_diagnostics.py @@ -1,13 +1,11 @@ """Test the Devialet diagnostics.""" -import json - from homeassistant.components.devialet.const import DOMAIN from homeassistant.core import HomeAssistant from . import setup_integration -from tests.common import async_load_fixture +from tests.common import async_load_json_object_fixture from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -23,19 +21,19 @@ async def test_diagnostics( assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == { "is_available": True, - "general_info": json.loads( - await async_load_fixture(hass, "general_info.json", DOMAIN) + "general_info": await async_load_json_object_fixture( + hass, "general_info.json", DOMAIN ), - "sources": json.loads(await async_load_fixture(hass, "sources.json", DOMAIN)), - "source_state": json.loads( - await async_load_fixture(hass, "source_state.json", DOMAIN) + "sources": await async_load_json_object_fixture(hass, "sources.json", DOMAIN), + "source_state": await async_load_json_object_fixture( + hass, "source_state.json", DOMAIN ), - "volume": json.loads(await async_load_fixture(hass, "volume.json", DOMAIN)), - "night_mode": json.loads( - await async_load_fixture(hass, "night_mode.json", DOMAIN) + "volume": await async_load_json_object_fixture(hass, "volume.json", DOMAIN), + "night_mode": await async_load_json_object_fixture( + hass, "night_mode.json", DOMAIN ), - "equalizer": json.loads( - await async_load_fixture(hass, "equalizer.json", DOMAIN) + "equalizer": await async_load_json_object_fixture( + hass, "equalizer.json", DOMAIN ), "source_list": [ "Airplay", diff --git a/tests/components/dexcom/__init__.py b/tests/components/dexcom/__init__.py index 9046c83fb87669..ebc32c77224072 100644 --- a/tests/components/dexcom/__init__.py +++ b/tests/components/dexcom/__init__.py @@ -1,6 +1,5 @@ """Tests for the Dexcom integration.""" -import json from typing import Any from unittest.mock import patch @@ -10,7 +9,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_object_fixture CONFIG = { CONF_USERNAME: "test_username", @@ -18,7 +17,7 @@ CONF_SERVER: SERVER_US, } -GLUCOSE_READING = GlucoseReading(json.loads(load_fixture("data.json", "dexcom"))) +GLUCOSE_READING = GlucoseReading(load_json_object_fixture("data.json", "dexcom")) TEST_ACCOUNT_ID = "99999999-9999-9999-9999-999999999999" TEST_SESSION_ID = "55555555-5555-5555-5555-555555555555" diff --git a/tests/components/elgato/conftest.py b/tests/components/elgato/conftest.py index afa89f8eb27739..fa4379f2665270 100644 --- a/tests/components/elgato/conftest.py +++ b/tests/components/elgato/conftest.py @@ -3,7 +3,15 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from elgato import BatteryInfo, ElgatoNoBatteryError, Info, Settings, State +from elgato import ( + BatteryInfo, + ElgatoNoBatteryError, + FirmwareImage, + FirmwareVersion, + Info, + Settings, + State, +) import pytest from homeassistant.components.elgato.const import DOMAIN @@ -92,6 +100,31 @@ def mock_elgato(device_fixtures: str, state_variant: str) -> Generator[MagicMock yield elgato +@pytest.fixture(autouse=True) +def mock_firmware_catalog() -> Generator[MagicMock]: + """Return a mocked Elgato firmware catalog. + + The catalog reads Elgato's servers, so this is autouse: no test gets to + reach them. The builds here are ahead of what the device fixtures + report, which leaves an update waiting by default. + """ + with patch( + "homeassistant.components.elgato.coordinator.FirmwareCatalog", autospec=True + ) as catalog_mock: + catalog = catalog_mock.return_value + catalog.versions.return_value = { + 53: FirmwareVersion(board_type=53, build_number=222, version="1.0.3"), + 202: FirmwareVersion(board_type=202, build_number=240, version="1.0.4"), + } + catalog.download.return_value = FirmwareImage( + board_type=53, + build_number=222, + version="1.0.3", + data=b"\x00" * 8192, + ) + yield catalog + + @pytest.fixture async def init_integration( hass: HomeAssistant, diff --git a/tests/components/elgato/snapshots/test_update.ambr b/tests/components/elgato/snapshots/test_update.ambr new file mode 100644 index 00000000000000..53c40e49189465 --- /dev/null +++ b/tests/components/elgato/snapshots/test_update.ambr @@ -0,0 +1,98 @@ +# serializer version: 1 +# name: test_update[key-light] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 'firmware', + : 0, + : '/api/brands/integration/elgato/icon.png', + : 'Frenck Firmware', + : False, + : '1.0.3.192', + : '1.0.3.222', + : None, + : None, + : None, + : , + : None, + : None, + }), + 'context': , + 'entity_id': 'update.frenck_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update[key-light].1 + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.frenck_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Firmware', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'elgato', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'CN11A1A00001', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[key-light].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '53', + 'id': , + 'identifiers': set({ + tuple( + 'elgato', + 'CN11A1A00001', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Elgato', + 'model': 'Elgato Key Light', + 'model_id': None, + 'name': 'Frenck', + 'name_by_user': None, + 'serial_number': 'CN11A1A00001', + 'sw_version': '1.0.3 (192)', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/elgato/test_init.py b/tests/components/elgato/test_init.py index a6ff923beeddcb..c7d2140b5459f5 100644 --- a/tests/components/elgato/test_init.py +++ b/tests/components/elgato/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from elgato import ElgatoConnectionError +from elgato import ElgatoConnectionError, ElgatoError from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -43,3 +43,19 @@ async def test_config_entry_not_ready( assert len(mock_elgato.state.mock_calls) == 1 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_unknown_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_elgato: MagicMock, +) -> None: + """Test the Elgato configuration entry failing on something else.""" + mock_elgato.state.side_effect = ElgatoError + + 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 len(mock_elgato.state.mock_calls) == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/elgato/test_update.py b/tests/components/elgato/test_update.py new file mode 100644 index 00000000000000..3b217cfb2f6d35 --- /dev/null +++ b/tests/components/elgato/test_update.py @@ -0,0 +1,630 @@ +"""Tests for the Elgato update platform.""" + +import asyncio +from collections.abc import Callable +from datetime import timedelta +from typing import Any +from unittest.mock import MagicMock + +from elgato import ( + ElgatoConnectionError, + ElgatoError, + ElgatoFirmwareError, + FirmwareImage, + FirmwareVersion, +) +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.elgato import ELGATO_KEY +from homeassistant.components.elgato.const import ( + DOMAIN, + FIRMWARE_SCAN_INTERVAL, + SCAN_INTERVAL, +) +from homeassistant.components.elgato.update import REBOOT_TIMEOUT +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.update import ( + ATTR_IN_PROGRESS, + ATTR_UPDATE_PERCENTAGE, + DOMAIN as UPDATE_DOMAIN, + SERVICE_INSTALL, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_HOST, + CONF_MAC, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, async_fire_time_changed + +ENTITY_ID = "update.frenck_firmware" + +pytestmark = [ + pytest.mark.parametrize("device_fixtures", ["key-light"]), + pytest.mark.usefixtures("device_fixtures", "init_integration"), +] + + +async def test_update( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Elgato firmware update entity.""" + assert (state := hass.states.get(ENTITY_ID)) + assert state == snapshot + assert state.state == STATE_ON + + assert (entry := entity_registry.async_get(ENTITY_ID)) + assert entry == snapshot + + assert entry.device_id + assert (device_entry := device_registry.async_get(entry.device_id)) + assert device_entry == snapshot + + +async def test_up_to_date( + hass: HomeAssistant, + mock_firmware_catalog: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a device already running what Elgato ships. + + The device fixture reports build 192, so the catalog is pulled back to + match it. + """ + mock_firmware_catalog.versions.return_value = { + 53: FirmwareVersion(board_type=53, build_number=192, version="1.0.3") + } + freezer.tick(FIRMWARE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OFF + + +async def test_elgato_ships_nothing_for_this_board( + hass: HomeAssistant, + mock_firmware_catalog: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a board Elgato publishes no firmware for. + + Nothing to compare against is not an error, it just leaves the entity + with no opinion. + """ + mock_firmware_catalog.versions.return_value = {} + freezer.tick(FIRMWARE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNKNOWN + + +async def test_install( + hass: HomeAssistant, + mock_elgato: MagicMock, + mock_firmware_catalog: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test installing the firmware Elgato ships.""" + reported: list[int | None] = [] + in_progress_while_downloading = None + + async def download(board_type: int) -> FirmwareImage: + """Stand in for fetching the image off Elgato's servers.""" + nonlocal in_progress_while_downloading + in_progress_while_downloading = hass.states.get(ENTITY_ID).attributes[ + ATTR_IN_PROGRESS + ] + return FirmwareImage( + board_type=board_type, + build_number=222, + version="1.0.3", + data=b"\x00" * 8192, + ) + + async def install( + image: FirmwareImage, + *, + on_progress: Callable[[int, int], None] | None = None, + ) -> None: + """Stand in for a device taking a firmware image.""" + assert on_progress is not None + for sent in (4096, 8192): + on_progress(sent, len(image.data)) + reported.append( + hass.states.get(ENTITY_ID).attributes[ATTR_UPDATE_PERCENTAGE] + ) + + mock_firmware_catalog.download.side_effect = download + mock_elgato.update_firmware.side_effect = install + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + # Fetching the image is part of the install, so the entity says so + # before it starts rather than after. + assert in_progress_while_downloading is True + + mock_firmware_catalog.download.assert_called_once_with(53) + mock_elgato.update_firmware.assert_called_once() + assert reported == [50, 100] + + # Still installing: the device took the firmware and is restarting. + assert (state := hass.states.get(ENTITY_ID)) + assert state.attributes[ATTR_IN_PROGRESS] is True + + # It comes back on the build it was given. + mock_elgato.info.return_value.firmware_build_number = 222 + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.attributes[ATTR_IN_PROGRESS] is False + assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None + assert state.state == STATE_OFF + + +async def test_install_that_never_comes_back( + hass: HomeAssistant, + mock_elgato: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a device that takes the firmware and never reports it. + + Without a way out, the entity would sit there saying it is installing + for as long as Home Assistant runs. + """ + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.attributes[ATTR_IN_PROGRESS] is True + + freezer.tick(timedelta(seconds=REBOOT_TIMEOUT + 1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.attributes[ATTR_IN_PROGRESS] is False + + +async def test_install_on_a_device_that_stays_away( + hass: HomeAssistant, + mock_elgato: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a device that takes the firmware and never answers again. + + It does not sit there claiming to install. An entity whose device is + gone is unavailable, and that is what it says. + """ + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + mock_elgato.state.side_effect = ElgatoConnectionError + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNAVAILABLE + + +async def test_a_second_install_is_turned_away( + hass: HomeAssistant, + mock_elgato: MagicMock, +) -> None: + """Test two installs at once do not both reach the device.""" + + async def slow(image: FirmwareImage, **kwargs: Any) -> None: + """Take long enough for the second call to arrive.""" + await asyncio.sleep(0) + + mock_elgato.update_firmware.side_effect = slow + + results = await asyncio.gather( + *[ + hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + for _ in range(2) + ], + return_exceptions=True, + ) + + assert sum(isinstance(result, HomeAssistantError) for result in results) == 1 + assert mock_elgato.update_firmware.call_count == 1 + + +async def test_catalog_refresh_during_an_install( + hass: HomeAssistant, + mock_elgato: MagicMock, + mock_firmware_catalog: MagicMock, +) -> None: + """Test the catalog refreshing while an install is running. + + What Elgato ships says nothing about whether this device is done, so a + refresh in the middle must not report the install as finished. + """ + + async def install(image: FirmwareImage, **kwargs: Any) -> None: + """Let Elgato publish something while the device is busy.""" + await hass.data[ELGATO_KEY].async_refresh() + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.attributes[ATTR_IN_PROGRESS] is True + + mock_elgato.update_firmware.side_effect = install + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert mock_elgato.update_firmware.call_count == 1 + + +async def test_download_does_not_hold_the_device( + hass: HomeAssistant, + mock_elgato: MagicMock, + mock_firmware_catalog: MagicMock, +) -> None: + """Test fetching the image leaves the device free. + + Downloading talks to Elgato. If it held the device lock, a slow or + unreachable Elgato would park every light command behind it for the + length of their timeout. + """ + coordinator = hass.config_entries.async_entries(DOMAIN)[0].runtime_data + locked_while_downloading = None + locked_while_uploading = None + + async def download(board_type: int) -> FirmwareImage: + nonlocal locked_while_downloading + locked_while_downloading = coordinator.device_lock.locked() + return FirmwareImage( + board_type=board_type, + build_number=222, + version="1.0.3", + data=b"\x00" * 8192, + ) + + async def install(image: FirmwareImage, **kwargs: Any) -> None: + nonlocal locked_while_uploading + locked_while_uploading = coordinator.device_lock.locked() + + mock_firmware_catalog.download.side_effect = download + mock_elgato.update_firmware.side_effect = install + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert locked_while_downloading is False + assert locked_while_uploading is True + + +async def test_device_page_follows_the_firmware( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_elgato: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the device page shows the firmware after an install. + + DeviceInfo is read when an entity is added and not again, so the version + someone reads right after installing would otherwise be the old one. + """ + device = device_registry.async_get_device_by_identifier( + (DOMAIN, "CN11A1A00001"), + hass.config_entries.async_entries(DOMAIN)[0].entry_id, + ) + assert device is not None + assert device.sw_version == "1.0.3 (192)" + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + mock_elgato.info.return_value.firmware_build_number = 222 + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + device = device_registry.async_get_device_by_identifier( + (DOMAIN, "CN11A1A00001"), + hass.config_entries.async_entries(DOMAIN)[0].entry_id, + ) + assert device is not None + assert device.sw_version == "1.0.3 (222)" + + +async def test_install_error( + hass: HomeAssistant, + mock_elgato: MagicMock, +) -> None: + """Test a device refusing the firmware it was handed.""" + mock_elgato.update_firmware.side_effect = ElgatoError + + with pytest.raises( + HomeAssistantError, + match="An unknown error occurred while communicating with the Elgato device", + ): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.attributes[ATTR_IN_PROGRESS] is False + + +@pytest.mark.parametrize( + "side_effect", + [ElgatoConnectionError, ElgatoError], +) +async def test_elgato_unreachable( + hass: HomeAssistant, + mock_firmware_catalog: MagicMock, + freezer: FrozenDateTimeFactory, + side_effect: type[Exception], +) -> None: + """Test Elgato's servers being unreachable. + + The light is on the local network and Elgato is not, so a bad day at + their end costs the latest version and nothing else. The light and its + other entities carry on. + """ + mock_firmware_catalog.versions.side_effect = side_effect + freezer.tick(FIRMWARE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNAVAILABLE + + assert (light := hass.states.get("light.frenck")) + assert light.state != STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("side_effect", "message", "still_reachable"), + [ + ( + ElgatoConnectionError, + "An error occurred while downloading the firmware from Elgato", + False, + ), + ( + ElgatoFirmwareError, + "An unknown error occurred while downloading the firmware from Elgato", + True, + ), + ], +) +async def test_download_failure_leaves_the_light_alone( + hass: HomeAssistant, + mock_elgato: MagicMock, + mock_firmware_catalog: MagicMock, + side_effect: type[Exception], + message: str, + still_reachable: bool, +) -> None: + """Test Elgato failing to hand over the image. + + Only reaching Elgato says anything about this entity; an image that + arrives and fails to verify means Elgato answered, just badly. + """ + mock_firmware_catalog.download.side_effect = side_effect + + with pytest.raises(HomeAssistantError, match=message): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + mock_elgato.update_firmware.assert_not_called() + + assert (light := hass.states.get("light.frenck")) + assert light.state != STATE_UNAVAILABLE + + assert (state := hass.states.get(ENTITY_ID)) + assert (state.state != STATE_UNAVAILABLE) is still_reachable + + +async def test_install_rejected_by_the_device( + hass: HomeAssistant, + mock_elgato: MagicMock, +) -> None: + """Test a device turning the firmware away for a reason worth reading.""" + mock_elgato.update_firmware.side_effect = ElgatoFirmwareError( + "Battery is at 11%, connect the device to power before updating its firmware" + ) + + with pytest.raises(HomeAssistantError, match="Battery is at 11%"): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + +async def test_install_keeps_the_device_to_itself( + hass: HomeAssistant, + mock_elgato: MagicMock, +) -> None: + """Test a poll cannot land in the middle of an install. + + A device stops answering while it erases a flash slot, and enough traffic + during that window takes its HTTP server down and restarts the light. + """ + coordinator = hass.config_entries.async_entries(DOMAIN)[0].runtime_data + refresh: asyncio.Task[None] | None = None + polls_during_install = 0 + + async def install(image: FirmwareImage, **kwargs: Any) -> None: + """Ask for a refresh while the device is busy taking firmware.""" + nonlocal refresh, polls_during_install + before = mock_elgato.state.call_count + refresh = hass.async_create_task(coordinator.async_refresh()) + for _ in range(5): + await asyncio.sleep(0) + polls_during_install = mock_elgato.state.call_count - before + + mock_elgato.update_firmware.side_effect = install + polls_before = mock_elgato.state.call_count + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert refresh is not None + await refresh + + assert polls_during_install == 0 + # And it is not blocked forever; the poll lands once the install is done. + assert mock_elgato.state.call_count > polls_before + + +async def test_manual_update_check( + hass: HomeAssistant, + mock_firmware_catalog: MagicMock, +) -> None: + """Test asking for an update check reaches Elgato. + + The device coordinator knows what the light runs; only the catalog knows + what Elgato ships, and that is the half being asked about. + """ + await async_setup_component(hass, HA_DOMAIN, {}) + checks_before = mock_firmware_catalog.versions.call_count + + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert mock_firmware_catalog.versions.call_count > checks_before + + +async def test_install_keeps_the_device_from_everyone( + hass: HomeAssistant, + mock_elgato: MagicMock, +) -> None: + """Test a light command cannot land in the middle of an install either. + + Polling is not the only thing that talks to the device; every button, + switch and light action does too. + """ + turn_on: asyncio.Task[None] | None = None + commands_during_install = 0 + + async def install(image: FirmwareImage, **kwargs: Any) -> None: + """Ask the light to turn on while the device is taking firmware.""" + nonlocal turn_on, commands_during_install + before = mock_elgato.light.call_count + turn_on = hass.async_create_task( + hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.frenck"}, + blocking=True, + ) + ) + for _ in range(5): + await asyncio.sleep(0) + commands_during_install = mock_elgato.light.call_count - before + + mock_elgato.update_firmware.side_effect = install + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert turn_on is not None + await turn_on + + assert commands_during_install == 0 + assert mock_elgato.light.call_count == 1 + + +async def test_one_catalog_for_every_device( + hass: HomeAssistant, + mock_firmware_catalog: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a second device does not fetch the catalog all over again. + + Elgato publishes one catalog covering every model, so it is read once + and shared, not once per config entry. + """ + calls_for_one_device = mock_firmware_catalog.versions.call_count + + second = MockConfigEntry( + title="CN11A1A00002", + domain=DOMAIN, + data={CONF_HOST: "127.0.0.2", CONF_MAC: "AA:BB:CC:DD:EE:00"}, + unique_id="CN11A1A00002", + ) + second.add_to_hass(hass) + await hass.config_entries.async_setup(second.entry_id) + await hass.async_block_till_done() + + assert mock_firmware_catalog.versions.call_count == calls_for_one_device diff --git a/tests/components/elmax/conftest.py b/tests/components/elmax/conftest.py index 9fc42bbcd9cc98..d757be8e451774 100644 --- a/tests/components/elmax/conftest.py +++ b/tests/components/elmax/conftest.py @@ -2,7 +2,6 @@ from collections.abc import Generator from datetime import timedelta -import json from unittest.mock import AsyncMock, patch from elmax_api.constants import ( @@ -27,7 +26,7 @@ MOCK_PANEL_PIN, ) -from tests.common import load_fixture +from tests.common import load_fixture, load_json_array_fixture, load_json_object_fixture TOKEN_SIGNING_KEY = "elmax-test-token-signing-key-0123" @@ -44,13 +43,13 @@ def httpx_mock_cloud_fixture() -> Generator[respx.MockRouter]: # Mock Login POST. login_route = respx_mock.post(f"/{ENDPOINT_LOGIN}", name="login") login_route.return_value = Response( - 200, json=json.loads(load_fixture("cloud/login.json", "elmax")) + 200, json=load_json_object_fixture("cloud/login.json", "elmax") ) # Mock Device list GET. list_devices_route = respx_mock.get(f"/{ENDPOINT_DEVICES}", name="list_devices") list_devices_route.return_value = Response( - 200, json=json.loads(load_fixture("cloud/list_devices.json", "elmax")) + 200, json=load_json_array_fixture("cloud/list_devices.json", "elmax") ) # Mock Panel GET. @@ -58,7 +57,7 @@ def httpx_mock_cloud_fixture() -> Generator[respx.MockRouter]: f"/{ENDPOINT_DISCOVERY}/{MOCK_PANEL_ID}/{MOCK_PANEL_PIN}", name="get_panel" ) get_panel_route.return_value = Response( - 200, json=json.loads(load_fixture("cloud/get_panel.json", "elmax")) + 200, json=load_json_object_fixture("cloud/get_panel.json", "elmax") ) yield respx_mock @@ -77,7 +76,7 @@ def httpx_mock_direct_fixture(base_uri: str) -> Generator[respx.MockRouter]: # Mock Login POST. login_route = respx_mock.post(f"/api/v2/{ENDPOINT_LOGIN}", name="login") - login_json = json.loads(load_fixture("direct/login.json", "elmax")) + login_json = load_json_object_fixture("direct/login.json", "elmax") decoded_jwt = jwt.decode_complete( login_json["token"].split(" ")[1], algorithms="HS256", @@ -96,7 +95,8 @@ def httpx_mock_direct_fixture(base_uri: str) -> Generator[respx.MockRouter]: f"/api/v2/{ENDPOINT_DISCOVERY}", name="discovery_panel" ) list_devices_route.return_value = Response( - 200, json=json.loads(load_fixture("direct/discovery_panel.json", "elmax")) + 200, + json=load_json_object_fixture("direct/discovery_panel.json", "elmax"), ) yield respx_mock diff --git a/tests/components/environment_canada/conftest.py b/tests/components/environment_canada/conftest.py index df8637946b38bc..ea0de1c8bfe32f 100644 --- a/tests/components/environment_canada/conftest.py +++ b/tests/components/environment_canada/conftest.py @@ -41,7 +41,5 @@ def data_hook(weather): weather["metadata"] = MetaData(**t) return weather - return json.loads( - load_fixture("environment_canada/current_conditions_data.json"), - object_hook=data_hook, - ) + fixture = load_fixture("environment_canada/current_conditions_data.json") + return json.loads(fixture, object_hook=data_hook) diff --git a/tests/components/fully_kiosk/conftest.py b/tests/components/fully_kiosk/conftest.py index f555cd81bc7551..7ec362f00b32e5 100644 --- a/tests/components/fully_kiosk/conftest.py +++ b/tests/components/fully_kiosk/conftest.py @@ -1,7 +1,6 @@ """Fixtures for the Fully Kiosk Browser integration tests.""" from collections.abc import Generator -import json from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -16,7 +15,7 @@ ) from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture @@ -69,11 +68,11 @@ def mock_fully_kiosk() -> Generator[MagicMock]: autospec=True, ) as client_mock: client = client_mock.return_value - client.getDeviceInfo.return_value = json.loads( - load_fixture("deviceinfo.json", DOMAIN) + client.getDeviceInfo.return_value = load_json_object_fixture( + "deviceinfo.json", DOMAIN ) - client.getSettings.return_value = json.loads( - load_fixture("listsettings.json", DOMAIN) + client.getSettings.return_value = load_json_object_fixture( + "listsettings.json", DOMAIN ) yield client diff --git a/tests/components/fully_kiosk/test_init.py b/tests/components/fully_kiosk/test_init.py index 9a0953298296e0..fcd7f4c5f6e26c 100644 --- a/tests/components/fully_kiosk/test_init.py +++ b/tests/components/fully_kiosk/test_init.py @@ -1,6 +1,5 @@ """Tests for the Fully Kiosk Browser integration.""" -import json from unittest.mock import MagicMock, patch from fullykiosk import FullyKioskError @@ -19,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry, async_load_fixture +from tests.common import MockConfigEntry, async_load_json_object_fixture async def test_load_unload_config_entry( @@ -73,11 +72,11 @@ async def _load_config( autospec=True, ) as client_mock: client = client_mock.return_value - client.getDeviceInfo.return_value = json.loads( - await async_load_fixture(hass, device_info_fixture, DOMAIN) + client.getDeviceInfo.return_value = await async_load_json_object_fixture( + hass, device_info_fixture, DOMAIN ) - client.getSettings.return_value = json.loads( - await async_load_fixture(hass, "listsettings.json", DOMAIN) + client.getSettings.return_value = await async_load_json_object_fixture( + hass, "listsettings.json", DOMAIN ) config_entry.add_to_hass(hass) diff --git a/tests/components/google_air_quality/test_services.py b/tests/components/google_air_quality/test_services.py index 28af291c26969c..b6d009a49b5287 100644 --- a/tests/components/google_air_quality/test_services.py +++ b/tests/components/google_air_quality/test_services.py @@ -1,7 +1,6 @@ """Test services for Google Air Quality.""" from datetime import timedelta -import json from unittest.mock import AsyncMock from google_air_quality_api.model import AirQualityForecastData @@ -18,7 +17,7 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr -from tests.common import MockConfigEntry, async_load_fixture +from tests.common import MockConfigEntry, async_load_json_object_fixture @pytest.mark.usefixtures("setup_integration") @@ -36,7 +35,7 @@ async def test_get_forecast_service( assert device is not None forecast = AirQualityForecastData.from_dict( - json.loads(await async_load_fixture(hass, "air_quality_forecast.json", DOMAIN)) + await async_load_json_object_fixture(hass, "air_quality_forecast.json", DOMAIN) ) mock_api.async_get_forecast.return_value = forecast diff --git a/tests/components/google_assistant/test_data_redaction.py b/tests/components/google_assistant/test_data_redaction.py index 9ec8393ad2596d..33b5c5939d7fff 100644 --- a/tests/components/google_assistant/test_data_redaction.py +++ b/tests/components/google_assistant/test_data_redaction.py @@ -1,15 +1,13 @@ """Test data redaction helpers.""" -import json - from homeassistant.components.google_assistant.data_redaction import async_redact_msg -from tests.common import load_fixture +from tests.common import load_json_array_fixture def test_redact_msg() -> None: """Test async_redact_msg.""" - messages = json.loads(load_fixture("data_redaction.json", "google_assistant")) + messages = load_json_array_fixture("data_redaction.json", "google_assistant") agent_user_id = "333dee20-1234-1234-1234-2225a0d70d4c" for item in messages: assert async_redact_msg(item["raw"], agent_user_id) == item["redacted"] diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index 086183d5868cb2..3fec08da345675 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -87,6 +87,26 @@ async def models_pager(): return models_pager() +def get_prefix_collision_models_pager(): + """Return a pager of model ids that start with letters from "models/".""" + models = [] + for name in ( + "models/gemini-2.5-pro", + "models/embedding-001", + "models/learnlm-2.0-flash-experimental", + "models/lyria-realtime-exp", + ): + model = Mock(supported_actions=["generateContent"]) + model.name = name + models.append(model) + + async def models_pager(): + for model in models: + yield model + + return models_pager() + + async def test_form(hass: HomeAssistant) -> None: """Test we get the form.""" # Pretend we already set up a config entry. @@ -793,3 +813,42 @@ async def test_reconfigure_conversation_subentry_llm_api_schema( assert [ opt["value"] for opt in field_schema.config.get("options") ] == expected_options + + +async def test_subentry_chat_model_labels_keep_the_full_model_id( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test the chat model selector labels only drop the "models/" prefix.""" + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_prefix_collision_models_pager(), + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "set_options" + + # Uncheck recommended so the model selector is built + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_prefix_collision_models_pager(), + ): + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + result["data_schema"]({CONF_RECOMMENDED: False}), + ) + + assert result["type"] is FlowResultType.FORM + schema_dict = result["data_schema"].schema + chat_model_key = next(key for key in schema_dict if key.schema == CONF_CHAT_MODEL) + assert [opt["label"] for opt in schema_dict[chat_model_key].config["options"]] == [ + "embedding-001", + "gemini-2.5-pro", + "learnlm-2.0-flash-experimental", + "lyria-realtime-exp", + ] diff --git a/tests/components/here_travel_time/conftest.py b/tests/components/here_travel_time/conftest.py index f318016315aae9..36ab894d066887 100644 --- a/tests/components/here_travel_time/conftest.py +++ b/tests/components/here_travel_time/conftest.py @@ -1,20 +1,19 @@ """Fixtures for HERE Travel Time tests.""" -import json from unittest.mock import patch import pytest -from tests.common import load_fixture +from tests.common import load_json_object_fixture -RESPONSE = json.loads(load_fixture("here_travel_time/car_response.json")) -TRANSIT_RESPONSE = json.loads( - load_fixture("here_travel_time/transit_route_response.json") +RESPONSE = load_json_object_fixture("here_travel_time/car_response.json") +TRANSIT_RESPONSE = load_json_object_fixture( + "here_travel_time/transit_route_response.json" ) -NO_ATTRIBUTION_TRANSIT_RESPONSE = json.loads( - load_fixture("here_travel_time/no_attribution_transit_route_response.json") +NO_ATTRIBUTION_TRANSIT_RESPONSE = load_json_object_fixture( + "here_travel_time/no_attribution_transit_route_response.json" ) -BIKE_RESPONSE = json.loads(load_fixture("here_travel_time/bike_response.json")) +BIKE_RESPONSE = load_json_object_fixture("here_travel_time/bike_response.json") @pytest.fixture(name="valid_response") diff --git a/tests/components/hko/conftest.py b/tests/components/hko/conftest.py index 853eca6507b4b5..4c8617d9e02c72 100644 --- a/tests/components/hko/conftest.py +++ b/tests/components/hko/conftest.py @@ -1,11 +1,10 @@ """Configure py.test.""" -import json from unittest.mock import patch import pytest -from tests.common import load_fixture +from tests.common import load_json_object_fixture @pytest.fixture(name="hko_config_flow_connect", autouse=True) @@ -13,6 +12,6 @@ def hko_config_flow_connect(): """Mock valid config flow setup.""" with patch( "homeassistant.components.hko.config_flow.HKO.weather", - return_value=json.loads(load_fixture("hko/rhrread.json")), + return_value=load_json_object_fixture("hko/rhrread.json"), ): yield diff --git a/tests/components/homekit/test_iidmanager.py b/tests/components/homekit/test_iidmanager.py index 592b229f95a3c8..ce14194c0beb24 100644 --- a/tests/components/homekit/test_iidmanager.py +++ b/tests/components/homekit/test_iidmanager.py @@ -9,10 +9,9 @@ get_iid_storage_filename_for_entry_id, ) from homeassistant.core import HomeAssistant -from homeassistant.util.json import json_loads from homeassistant.util.uuid import random_uuid_hex -from tests.common import MockConfigEntry, async_load_fixture +from tests.common import MockConfigEntry, async_load_json_object_fixture async def test_iid_generation_and_restore( @@ -108,8 +107,8 @@ async def test_iid_migration_to_v2( hass: HomeAssistant, iid_storage, hass_storage: dict[str, Any] ) -> None: """Test iid storage migration.""" - v1_iids = json_loads(await async_load_fixture(hass, "iids_v1", DOMAIN)) - v2_iids = json_loads(await async_load_fixture(hass, "iids_v2", DOMAIN)) + v1_iids = await async_load_json_object_fixture(hass, "iids_v1", DOMAIN) + v2_iids = await async_load_json_object_fixture(hass, "iids_v2", DOMAIN) hass_storage["homekit.v1.iids"] = v1_iids hass_storage["homekit.v2.iids"] = v2_iids @@ -132,11 +131,11 @@ async def test_iid_migration_to_v2_with_underscore( hass: HomeAssistant, iid_storage, hass_storage: dict[str, Any] ) -> None: """Test iid storage migration with underscore.""" - v1_iids = json_loads( - await async_load_fixture(hass, "iids_v1_with_underscore", DOMAIN) + v1_iids = await async_load_json_object_fixture( + hass, "iids_v1_with_underscore", DOMAIN ) - v2_iids = json_loads( - await async_load_fixture(hass, "iids_v2_with_underscore", DOMAIN) + v2_iids = await async_load_json_object_fixture( + hass, "iids_v2_with_underscore", DOMAIN ) hass_storage["homekit.v1_with_underscore.iids"] = v1_iids hass_storage["homekit.v2_with_underscore.iids"] = v2_iids diff --git a/tests/components/homevolt/conftest.py b/tests/components/homevolt/conftest.py index 323efac5f8d01e..016291e461d77f 100644 --- a/tests/components/homevolt/conftest.py +++ b/tests/components/homevolt/conftest.py @@ -1,7 +1,6 @@ """Common fixtures for the Homevolt tests.""" from collections.abc import Generator -import json from unittest.mock import AsyncMock, MagicMock, patch from homevolt import DeviceMetadata, Sensor @@ -11,7 +10,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_object_fixture DEVICE_IDENTIFIER = "ems_40580137858664" @@ -60,7 +59,7 @@ def mock_homevolt_client() -> Generator[MagicMock]: client.unique_id = "40580137858664" # Load sensor data from fixture and convert to Sensor objects - sensors_data = json.loads(load_fixture("sensors.json", DOMAIN)) + sensors_data = load_json_object_fixture("sensors.json", DOMAIN) client.sensors = { key: Sensor( value=value, @@ -71,7 +70,7 @@ def mock_homevolt_client() -> Generator[MagicMock]: } # Load device metadata from fixture and convert to DeviceMetadata objects - metadata_data = json.loads(load_fixture("device_metadata.json", DOMAIN)) + metadata_data = load_json_object_fixture("device_metadata.json", DOMAIN) client.device_metadata = { key: DeviceMetadata( name=metadata["name"], @@ -81,7 +80,7 @@ def mock_homevolt_client() -> Generator[MagicMock]: } # Load schedule data from fixture - client.current_schedule = json.loads(load_fixture("schedule.json", DOMAIN)) + client.current_schedule = load_json_object_fixture("schedule.json", DOMAIN) # Switch (local mode) support client.local_mode_enabled = False diff --git a/tests/components/hortimax/conftest.py b/tests/components/hortimax/conftest.py index aae20152f2ab3d..9d37caf65cca34 100644 --- a/tests/components/hortimax/conftest.py +++ b/tests/components/hortimax/conftest.py @@ -2,7 +2,6 @@ from collections.abc import Generator from datetime import UTC, datetime, timedelta -import json from unittest.mock import AsyncMock, patch from aiohortos import Device, Organisation, Readout, TokenPair @@ -11,7 +10,7 @@ from homeassistant.components.hortimax.const import DOMAIN from homeassistant.const import CONF_API_KEY -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_array_fixture API_KEY = "test-api-key" DEVICE = "HOR00000000.000" @@ -23,7 +22,7 @@ def load_readouts() -> list[Readout]: """Return the fixture readouts, parsed the way the library parses them.""" return [ readout - for raw in json.loads(load_fixture("readouts.json", DOMAIN)) + for raw in load_json_array_fixture("readouts.json", DOMAIN) if (readout := Readout.from_api(raw)) is not None ] diff --git a/tests/components/hvv_departures/test_config_flow.py b/tests/components/hvv_departures/test_config_flow.py index 6fa8ee9fc9e528..c27f6920d910b5 100644 --- a/tests/components/hvv_departures/test_config_flow.py +++ b/tests/components/hvv_departures/test_config_flow.py @@ -1,6 +1,5 @@ """Test the HVV Departures config flow.""" -import json from unittest.mock import MagicMock, patch from aiohttp import ClientConnectorError @@ -18,16 +17,16 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_object_fixture -FIXTURE_INIT = json.loads(load_fixture("hvv_departures/init.json")) -FIXTURE_CHECK_NAME = json.loads(load_fixture("hvv_departures/check_name.json")) -FIXTURE_STATION_INFORMATION = json.loads( - load_fixture("hvv_departures/station_information.json") +FIXTURE_INIT = load_json_object_fixture("hvv_departures/init.json") +FIXTURE_CHECK_NAME = load_json_object_fixture("hvv_departures/check_name.json") +FIXTURE_STATION_INFORMATION = load_json_object_fixture( + "hvv_departures/station_information.json" ) -FIXTURE_CONFIG_ENTRY = json.loads(load_fixture("hvv_departures/config_entry.json")) -FIXTURE_OPTIONS = json.loads(load_fixture("hvv_departures/options.json")) -FIXTURE_DEPARTURE_LIST = json.loads(load_fixture("hvv_departures/departure_list.json")) +FIXTURE_CONFIG_ENTRY = load_json_object_fixture("hvv_departures/config_entry.json") +FIXTURE_OPTIONS = load_json_object_fixture("hvv_departures/options.json") +FIXTURE_DEPARTURE_LIST = load_json_object_fixture("hvv_departures/departure_list.json") async def test_user_flow(hass: HomeAssistant) -> None: diff --git a/tests/components/insteon/test_api_aldb.py b/tests/components/insteon/test_api_aldb.py index 682bbc2c3bc716..5060808db2b8b9 100644 --- a/tests/components/insteon/test_api_aldb.py +++ b/tests/components/insteon/test_api_aldb.py @@ -1,7 +1,6 @@ """Test the Insteon All-Link Database APIs.""" import asyncio -import json from typing import Any from unittest.mock import patch @@ -26,14 +25,14 @@ from .const import MOCK_USER_INPUT_PLM from .mock_devices import MockDevices -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_object_fixture from tests.typing import MockHAClientWebSocket, WebSocketGenerator @pytest.fixture(name="aldb_data", scope="module") def aldb_data_fixture(): """Load the controller state fixture data.""" - return json.loads(load_fixture("insteon/aldb_data.json")) + return load_json_object_fixture("insteon/aldb_data.json") async def _setup( diff --git a/tests/components/insteon/test_api_config.py b/tests/components/insteon/test_api_config.py index a4967a70d3a410..b3eb5e302929df 100644 --- a/tests/components/insteon/test_api_config.py +++ b/tests/components/insteon/test_api_config.py @@ -1,7 +1,6 @@ """Test the Insteon APIs for configuring the integration.""" import asyncio -import json from unittest.mock import patch from homeassistant.components import insteon @@ -25,7 +24,7 @@ from .mock_devices import MockDevices from .mock_setup import async_mock_setup -from tests.common import async_load_fixture +from tests.common import async_load_json_object_fixture from tests.typing import WebSocketGenerator @@ -405,7 +404,7 @@ async def test_get_broken_links( ws_client, _, _, _ = await async_mock_setup(hass, hass_ws_client) devices = MockDevices() await devices.async_load() - aldb_data = json.loads(await async_load_fixture(hass, "aldb_data.json", DOMAIN)) + aldb_data = await async_load_json_object_fixture(hass, "aldb_data.json", DOMAIN) devices.fill_aldb("33.33.33", aldb_data) await asyncio.sleep(1) with patch.object(insteon.api.config, "devices", devices): diff --git a/tests/components/insteon/test_api_properties.py b/tests/components/insteon/test_api_properties.py index 793933564456c1..b792066271da8d 100644 --- a/tests/components/insteon/test_api_properties.py +++ b/tests/components/insteon/test_api_properties.py @@ -1,6 +1,5 @@ """Test the Insteon properties APIs.""" -import json from typing import Any from unittest.mock import AsyncMock, patch @@ -26,20 +25,20 @@ from .mock_devices import MockDevices -from tests.common import load_fixture +from tests.common import load_json_object_fixture from tests.typing import MockHAClientWebSocket, WebSocketGenerator @pytest.fixture(name="kpl_properties_data", scope="module") def kpl_properties_data_fixture(): """Load the controller state fixture data.""" - return json.loads(load_fixture("insteon/kpl_properties.json")) + return load_json_object_fixture("insteon/kpl_properties.json") @pytest.fixture(name="iolinc_properties_data", scope="module") def iolinc_properties_data_fixture(): """Load the controller state fixture data.""" - return json.loads(load_fixture("insteon/iolinc_properties.json")) + return load_json_object_fixture("insteon/iolinc_properties.json") async def _setup( diff --git a/tests/components/iometer/test_binary_sensor.py b/tests/components/iometer/test_binary_sensor.py index 404063ed230204..6f5e6710a2b77a 100644 --- a/tests/components/iometer/test_binary_sensor.py +++ b/tests/components/iometer/test_binary_sensor.py @@ -14,7 +14,11 @@ from . import get_status_callback, setup_platform -from tests.common import MockConfigEntry, async_load_fixture, snapshot_platform +from tests.common import ( + MockConfigEntry, + async_load_json_object_fixture, + snapshot_platform, +) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -47,7 +51,7 @@ async def test_connection_status_sensors( == STATE_ON ) - status_data = json.loads(await async_load_fixture(hass, "status.json", DOMAIN)) + status_data = await async_load_json_object_fixture(hass, "status.json", DOMAIN) status_data["device"]["core"]["connectionStatus"] = "disconnected" get_status_callback(mock_iometer_client)(Status.from_json(json.dumps(status_data))) await hass.async_block_till_done() @@ -76,7 +80,7 @@ async def test_attachment_status_sensors( == STATE_ON ) - status_data = json.loads(await async_load_fixture(hass, "status.json", DOMAIN)) + status_data = await async_load_json_object_fixture(hass, "status.json", DOMAIN) status_data["device"]["core"]["attachmentStatus"] = "detached" get_status_callback(mock_iometer_client)(Status.from_json(json.dumps(status_data))) await hass.async_block_till_done() @@ -105,7 +109,7 @@ async def test_attachment_status_sensors_unknown( == STATE_ON ) - status_data = json.loads(await async_load_fixture(hass, "status.json", DOMAIN)) + status_data = await async_load_json_object_fixture(hass, "status.json", DOMAIN) del status_data["device"]["core"]["attachmentStatus"] get_status_callback(mock_iometer_client)(Status.from_json(json.dumps(status_data))) await hass.async_block_till_done() diff --git a/tests/components/iometer/test_init.py b/tests/components/iometer/test_init.py index 8861f5216fa405..3d824662f79ba6 100644 --- a/tests/components/iometer/test_init.py +++ b/tests/components/iometer/test_init.py @@ -28,7 +28,7 @@ setup_platform, ) -from tests.common import MockConfigEntry, async_load_fixture +from tests.common import MockConfigEntry, async_load_json_object_fixture async def test_new_firmware_version( @@ -47,7 +47,7 @@ async def test_new_firmware_version( assert device_entry is not None assert device_entry.sw_version == "build-58/build-65" - status_data = json.loads(await async_load_fixture(hass, "status.json", DOMAIN)) + status_data = await async_load_json_object_fixture(hass, "status.json", DOMAIN) status_data["device"]["core"]["version"] = "build-62" status_data["device"]["bridge"]["version"] = "build-69" get_status_callback(mock_iometer_client)(Status.from_json(json.dumps(status_data))) diff --git a/tests/components/ipp/conftest.py b/tests/components/ipp/conftest.py index 54b8ed60452753..79da770f1bbf57 100644 --- a/tests/components/ipp/conftest.py +++ b/tests/components/ipp/conftest.py @@ -1,7 +1,6 @@ """Fixtures for IPP integration tests.""" from collections.abc import Generator -import json from unittest.mock import AsyncMock, MagicMock, patch from pyipp import Printer @@ -17,7 +16,7 @@ ) from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, async_load_fixture +from tests.common import MockConfigEntry, async_load_json_object_fixture @pytest.fixture @@ -57,7 +56,7 @@ async def mock_printer( if hasattr(request, "param") and request.param: fixture = request.param - return Printer.from_dict(json.loads(await async_load_fixture(hass, fixture))) + return Printer.from_dict(await async_load_json_object_fixture(hass, fixture)) @pytest.fixture diff --git a/tests/components/irm_kmi/conftest.py b/tests/components/irm_kmi/conftest.py index 40ed5fa14f67cf..078c7bc5955003 100644 --- a/tests/components/irm_kmi/conftest.py +++ b/tests/components/irm_kmi/conftest.py @@ -1,7 +1,6 @@ """Fixtures for the IRM KMI integration tests.""" from collections.abc import Generator -import json from unittest.mock import MagicMock, patch from irm_kmi_api import IrmKmiApiError @@ -15,7 +14,7 @@ CONF_UNIQUE_ID, ) -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture @@ -77,7 +76,7 @@ def mock_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[MagicMock]: """Return a mocked IrmKmi api client.""" fixture: str = "forecast.json" - forecast = json.loads(load_fixture(fixture, "irm_kmi")) + forecast = load_json_object_fixture(fixture, "irm_kmi") with patch( "homeassistant.components.irm_kmi.IrmKmiApiClientHa", autospec=True ) as irm_kmi_api_mock: @@ -90,7 +89,7 @@ def mock_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[MagicMock]: def mock_irm_kmi_api_nl(): """Mock get_forecasts_coord() to return a Netherlands forecast.""" fixture: str = "forecast_nl.json" - forecast = json.loads(load_fixture(fixture, "irm_kmi")) + forecast = load_json_object_fixture(fixture, "irm_kmi") with patch( "homeassistant.components.irm_kmi.coordinator.IrmKmiApiClientHa.get_forecasts_coord", return_value=forecast, @@ -102,7 +101,7 @@ def mock_irm_kmi_api_nl(): def mock_irm_kmi_api_high_low_temp(): """Mock get_forecasts_coord() to return high_low_temp forecast.""" fixture: str = "high_low_temp.json" - forecast = json.loads(load_fixture(fixture, "irm_kmi")) + forecast = load_json_object_fixture(fixture, "irm_kmi") with patch( "homeassistant.components.irm_kmi.coordinator.IrmKmiApiClientHa.get_forecasts_coord", return_value=forecast, diff --git a/tests/components/jellyfin/__init__.py b/tests/components/jellyfin/__init__.py index 7db0ba2d8a3393..e8fca2d5c3c6fb 100644 --- a/tests/components/jellyfin/__init__.py +++ b/tests/components/jellyfin/__init__.py @@ -1,16 +1,15 @@ """Tests for the jellyfin integration.""" -import json from typing import Any from homeassistant.core import HomeAssistant -from tests.common import load_fixture +from tests.common import load_json_value_fixture def load_json_fixture(filename: str) -> Any: """Load JSON fixture on-demand.""" - return json.loads(load_fixture(f"jellyfin/{filename}")) + return load_json_value_fixture(f"jellyfin/{filename}") async def async_load_json_fixture(hass: HomeAssistant, filename: str) -> Any: diff --git a/tests/components/laundrify/conftest.py b/tests/components/laundrify/conftest.py index 75df96d30b0490..424dc1227a1417 100644 --- a/tests/components/laundrify/conftest.py +++ b/tests/components/laundrify/conftest.py @@ -1,6 +1,5 @@ """Configure py.test.""" -import json from unittest.mock import AsyncMock, patch from laundrify_aio import LaundrifyAPI, LaundrifyDevice @@ -12,7 +11,7 @@ from .const import VALID_ACCESS_TOKEN, VALID_ACCOUNT_ID -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_array_fixture from tests.typing import ClientSessionGenerator @@ -20,7 +19,7 @@ def laundrify_sensor_fixture() -> LaundrifyDevice: """Return a default Laundrify power sensor mock.""" # Load test data from machines.json - machine_data = json.loads(load_fixture("laundrify/machines.json"))[0] + machine_data = load_json_array_fixture("laundrify/machines.json")[0] mock_device = AsyncMock(spec=LaundrifyDevice) mock_device.id = machine_data["id"] @@ -70,7 +69,7 @@ def laundrify_api_fixture(hass_client: ClientSessionGenerator): "laundrify_aio.LaundrifyAPI.get_machines", return_value=[ LaundrifyDevice(machine, LaundrifyAPI) - for machine in json.loads(load_fixture("laundrify/machines.json")) + for machine in load_json_array_fixture("laundrify/machines.json") ], ), ): diff --git a/tests/components/lcn/conftest.py b/tests/components/lcn/conftest.py index f17f1b4eee11e3..c05854ac6321b4 100644 --- a/tests/components/lcn/conftest.py +++ b/tests/components/lcn/conftest.py @@ -1,6 +1,5 @@ """Test configuration and mocks for LCN component.""" -import json from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -17,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_object_fixture LATEST_CONFIG_ENTRY_VERSION = (LcnFlowHandler.VERSION, LcnFlowHandler.MINOR_VERSION) @@ -75,7 +74,7 @@ def create_config_entry( ) -> MockConfigEntry: """Set up config entries with configuration data.""" fixture_filename = f"lcn/config_entry_{name}.json" - entry_data = json.loads(load_fixture(fixture_filename)) + entry_data = load_json_object_fixture(fixture_filename) for device in entry_data[CONF_DEVICES]: device[CONF_ADDRESS] = tuple(device[CONF_ADDRESS]) for entity in entry_data[CONF_ENTITIES]: diff --git a/tests/components/lektrico/conftest.py b/tests/components/lektrico/conftest.py index 0b120cd6e232f9..9afde4b9fe7809 100644 --- a/tests/components/lektrico/conftest.py +++ b/tests/components/lektrico/conftest.py @@ -2,7 +2,6 @@ from collections.abc import Generator from ipaddress import ip_address -import json from unittest.mock import AsyncMock, patch import pytest @@ -16,7 +15,7 @@ ) from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_object_fixture MOCKED_DEVICE_IP_ADDRESS = "192.168.100.10" MOCKED_DEVICE_SERIAL_NUMBER = "500006" @@ -58,11 +57,11 @@ def mock_device() -> Generator[AsyncMock]: ): device = mock_device.return_value - device.device_config.return_value = json.loads( - load_fixture("get_config.json", DOMAIN) + device.device_config.return_value = load_json_object_fixture( + "get_config.json", DOMAIN ) - device.device_info.return_value = json.loads( - load_fixture("get_info.json", DOMAIN) + device.device_info.return_value = load_json_object_fixture( + "get_info.json", DOMAIN ) yield device diff --git a/tests/components/loqed/conftest.py b/tests/components/loqed/conftest.py index 59b0deb3bc13da..1fab4b5efa887f 100644 --- a/tests/components/loqed/conftest.py +++ b/tests/components/loqed/conftest.py @@ -2,7 +2,6 @@ from collections.abc import AsyncGenerator, Callable from contextlib import contextmanager -import json from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -14,7 +13,11 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, async_load_fixture +from tests.common import ( + MockConfigEntry, + async_load_json_array_fixture, + async_load_json_object_fixture, +) type PatchLockCreationFlow = Callable[[dict[str, Any], loqed.Lock, str], Any] @@ -23,8 +26,9 @@ async def config_entry_fixture(hass: HomeAssistant) -> MockConfigEntry: """Mock config entry.""" - config = await async_load_fixture(hass, "integration_config.json", DOMAIN) - json_config = json.loads(config) + json_config = await async_load_json_object_fixture( + hass, "integration_config.json", DOMAIN + ) return MockConfigEntry( version=1, domain=DOMAIN, @@ -46,11 +50,12 @@ async def config_entry_fixture(hass: HomeAssistant) -> MockConfigEntry: async def cloud_config_entry_fixture(hass: HomeAssistant) -> MockConfigEntry: """Mock config entry.""" - config = await async_load_fixture(hass, "integration_config.json", DOMAIN) - webhooks_fixture = json.loads( - await async_load_fixture(hass, "get_all_webhooks.json", DOMAIN) + json_config = await async_load_json_object_fixture( + hass, "integration_config.json", DOMAIN + ) + webhooks_fixture = await async_load_json_array_fixture( + hass, "get_all_webhooks.json", DOMAIN ) - json_config = json.loads(config) return MockConfigEntry( version=1, domain=DOMAIN, @@ -72,8 +77,8 @@ async def cloud_config_entry_fixture(hass: HomeAssistant) -> MockConfigEntry: @pytest.fixture(name="lock") async def lock_fixture(hass: HomeAssistant) -> loqed.Lock: """Set up a mock implementation of a Lock.""" - webhooks_fixture = json.loads( - await async_load_fixture(hass, "get_all_webhooks.json", DOMAIN) + webhooks_fixture = await async_load_json_array_fixture( + hass, "get_all_webhooks.json", DOMAIN ) mock_lock = Mock(spec=loqed.Lock, id="Foo", last_key_id=2) @@ -92,7 +97,7 @@ async def integration_fixture( config: dict[str, Any] = {DOMAIN: {CONF_API_TOKEN: ""}} config_entry.add_to_hass(hass) - lock_status = json.loads(await async_load_fixture(hass, "status_ok.json", DOMAIN)) + lock_status = await async_load_json_object_fixture(hass, "status_ok.json", DOMAIN) with ( patch("loqedAPI.loqed.LoqedAPI.async_get_lock", return_value=lock), diff --git a/tests/components/loqed/test_config_flow.py b/tests/components/loqed/test_config_flow.py index ecac353e349753..3229bd8901ab2b 100644 --- a/tests/components/loqed/test_config_flow.py +++ b/tests/components/loqed/test_config_flow.py @@ -2,7 +2,6 @@ from collections.abc import Callable from ipaddress import ip_address -import json from typing import Any from unittest.mock import Mock, patch @@ -16,7 +15,7 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from tests.common import async_load_fixture +from tests.common import async_load_json_object_fixture from tests.test_util.aiohttp import AiohttpClientMocker TEST_API_TOKEN = "eyadiuyfasiuasf" @@ -35,7 +34,7 @@ async def _async_init_zeroconf_flow(hass: HomeAssistant) -> dict[str, Any]: """Initialize a zeroconf flow and return the form result.""" - lock_result = json.loads(await async_load_fixture(hass, "status_ok.json", DOMAIN)) + lock_result = await async_load_json_object_fixture(hass, "status_ok.json", DOMAIN) with patch( "loqedAPI.loqed.LoqedAPI.async_get_lock_details", @@ -65,7 +64,7 @@ async def test_create_entry_zeroconf( patch_lock_creation_flow: Callable[[dict[str, Any], loqed.Lock, str], Any], ) -> None: """Test we get can create a lock via zeroconf.""" - lock_result = json.loads(await async_load_fixture(hass, "status_ok.json", DOMAIN)) + lock_result = await async_load_json_object_fixture(hass, "status_ok.json", DOMAIN) with patch( "loqedAPI.loqed.LoqedAPI.async_get_lock_details", @@ -82,8 +81,8 @@ async def test_create_entry_zeroconf( mock_lock = Mock(spec=loqed.Lock, id="Foo") webhook_id = "Webhook_ID" - all_locks_response = json.loads( - await async_load_fixture(hass, "get_all_locks.json", DOMAIN) + all_locks_response = await async_load_json_object_fixture( + hass, "get_all_locks.json", DOMAIN ) with patch_lock_creation_flow(all_locks_response, mock_lock, webhook_id): @@ -122,8 +121,8 @@ async def test_create_entry_user( mock_lock = Mock(spec=loqed.Lock, id="Foo") webhook_id = TEST_WEBHOOK_ID - all_locks_response = json.loads( - await async_load_fixture(hass, "get_all_locks.json", DOMAIN) + all_locks_response = await async_load_json_object_fixture( + hass, "get_all_locks.json", DOMAIN ) found_lock = all_locks_response["data"][0] @@ -160,8 +159,8 @@ async def test_create_entry_user_with_pick_lock( mock_lock = Mock(spec=loqed.Lock, id="Foo") webhook_id = TEST_WEBHOOK_ID - all_locks_response = json.loads( - await async_load_fixture(hass, "get_all_locks.json", DOMAIN) + all_locks_response = await async_load_json_object_fixture( + hass, "get_all_locks.json", DOMAIN ) second_lock = all_locks_response["data"][0].copy() second_lock["id"] = "Bar" @@ -242,8 +241,8 @@ async def test_recover_after_cannot_connect( mock_lock = Mock(spec=loqed.Lock, id="Foo") webhook_id = TEST_WEBHOOK_ID - all_locks_response = json.loads( - await async_load_fixture(hass, "get_all_locks.json", DOMAIN) + all_locks_response = await async_load_json_object_fixture( + hass, "get_all_locks.json", DOMAIN ) found_lock = all_locks_response["data"][0] @@ -342,8 +341,8 @@ async def test_cannot_connect_when_lock_not_reachable( """Test we handle a situation where the lock is not reachable.""" result = await _async_init_user_flow(hass) - all_locks_response = json.loads( - await async_load_fixture(hass, "get_all_locks.json", DOMAIN) + all_locks_response = await async_load_json_object_fixture( + hass, "get_all_locks.json", DOMAIN ) with ( diff --git a/tests/components/loqed/test_init.py b/tests/components/loqed/test_init.py index 39161fd911acdb..56a01873eef9fb 100644 --- a/tests/components/loqed/test_init.py +++ b/tests/components/loqed/test_init.py @@ -1,7 +1,6 @@ """Tests the init part of the Loqed integration.""" from datetime import timedelta -import json from typing import Any from unittest.mock import AsyncMock, call, patch @@ -22,6 +21,7 @@ MockConfigEntry, async_fire_time_changed, async_load_fixture, + async_load_json_array_fixture, async_load_json_object_fixture, ) from tests.typing import ClientSessionGenerator @@ -36,8 +36,8 @@ async def test_webhook_accepts_valid_message( """Test webhook called with valid message.""" await async_setup_component(hass, "http", {"http": {}}) client = await hass_client_no_auth() - processed_message = json.loads( - await async_load_fixture(hass, "lock_going_to_nightlock.json", DOMAIN) + processed_message = await async_load_json_object_fixture( + hass, "lock_going_to_nightlock.json", DOMAIN ) lock.receiveWebhook = AsyncMock(return_value=processed_message) @@ -58,9 +58,9 @@ async def test_setup_webhook_in_bridge( config: dict[str, Any] = {DOMAIN: {}} config_entry.add_to_hass(hass) - lock_status = json.loads(await async_load_fixture(hass, "status_ok.json", DOMAIN)) - webhooks_fixture = json.loads( - await async_load_fixture(hass, "get_all_webhooks.json", DOMAIN) + lock_status = await async_load_json_object_fixture(hass, "status_ok.json", DOMAIN) + webhooks_fixture = await async_load_json_array_fixture( + hass, "get_all_webhooks.json", DOMAIN ) lock.getWebhooks = AsyncMock(side_effect=[[], webhooks_fixture]) @@ -89,8 +89,8 @@ async def test_webhook_prefers_internal_url( lock_status = await async_load_json_object_fixture(hass, "status_ok.json", DOMAIN) - webhooks_fixture = json.loads( - await async_load_fixture(hass, "get_all_webhooks.json", DOMAIN) + webhooks_fixture = await async_load_json_array_fixture( + hass, "get_all_webhooks.json", DOMAIN ) webhooks_fixture[0]["url"] = f"{hass.config.internal_url}/api/webhook/Webhook_id" @@ -230,9 +230,9 @@ async def test_setup_retry_after_bridge_webhook_failure( """ config_entry.add_to_hass(hass) - lock_status = json.loads(await async_load_fixture(hass, "status_ok.json", DOMAIN)) - webhooks_fixture = json.loads( - await async_load_fixture(hass, "get_all_webhooks.json", DOMAIN) + lock_status = await async_load_json_object_fixture(hass, "status_ok.json", DOMAIN) + webhooks_fixture = await async_load_json_array_fixture( + hass, "get_all_webhooks.json", DOMAIN ) lock.getWebhooks = AsyncMock( side_effect=[ConfigEntryNotReady, webhooks_fixture, webhooks_fixture] @@ -264,9 +264,9 @@ async def test_setup_cloudhook_in_bridge( config: dict[str, Any] = {DOMAIN: {}} config_entry.add_to_hass(hass) - lock_status = json.loads(await async_load_fixture(hass, "status_ok.json", DOMAIN)) - webhooks_fixture = json.loads( - await async_load_fixture(hass, "get_all_webhooks.json", DOMAIN) + lock_status = await async_load_json_object_fixture(hass, "status_ok.json", DOMAIN) + webhooks_fixture = await async_load_json_array_fixture( + hass, "get_all_webhooks.json", DOMAIN ) lock.getWebhooks = AsyncMock(side_effect=[[], webhooks_fixture]) @@ -294,14 +294,14 @@ async def test_setup_cloudhook_from_entry_in_bridge( hass: HomeAssistant, cloud_config_entry: MockConfigEntry, lock: loqed.Lock ) -> None: """Test webhook setup in loqed bridge.""" - webhooks_fixture = json.loads( - await async_load_fixture(hass, "get_all_webhooks.json", DOMAIN) + webhooks_fixture = await async_load_json_array_fixture( + hass, "get_all_webhooks.json", DOMAIN ) config: dict[str, Any] = {DOMAIN: {}} cloud_config_entry.add_to_hass(hass) - lock_status = json.loads(await async_load_fixture(hass, "status_ok.json", DOMAIN)) + lock_status = await async_load_json_object_fixture(hass, "status_ok.json", DOMAIN) lock.getWebhooks = AsyncMock(side_effect=[[], webhooks_fixture]) diff --git a/tests/components/lyngdorf/conftest.py b/tests/components/lyngdorf/conftest.py index c8114dd077aa91..5dbf74f2d27c94 100644 --- a/tests/components/lyngdorf/conftest.py +++ b/tests/components/lyngdorf/conftest.py @@ -115,7 +115,6 @@ def mock_receiver(mock_create_receiver: MagicMock) -> MagicMock: # Diagnostics reports the whole receiver, so every property it reads # needs a value here; an unset one is a mock the response cannot encode. receiver.model = LyngdorfModel.MP_60 - receiver.max_volume = 0.0 receiver.room_perfect_position = "Focus 1" receiver.available_room_perfect_positions = ["Global", "Focus 1"] receiver.room_perfect_positions = ["Global", "Focus 1"] diff --git a/tests/components/lyngdorf/snapshots/test_diagnostics.ambr b/tests/components/lyngdorf/snapshots/test_diagnostics.ambr index ae8f67c0dfabc4..d5bf1a0ff5ccf6 100644 --- a/tests/components/lyngdorf/snapshots/test_diagnostics.ambr +++ b/tests/components/lyngdorf/snapshots/test_diagnostics.ambr @@ -90,7 +90,6 @@ ]), 'connected': True, 'lipsync': 50.0, - 'max_volume': 0.0, 'model': 'MP_60', 'mute_enabled': False, 'power_on': False, diff --git a/tests/components/matter/test_diagnostics.py b/tests/components/matter/test_diagnostics.py index f040a31a16feb1..018e4aaf6b7be6 100644 --- a/tests/components/matter/test_diagnostics.py +++ b/tests/components/matter/test_diagnostics.py @@ -1,6 +1,5 @@ """Test the Matter diagnostics platform.""" -import json from typing import Any from unittest.mock import MagicMock @@ -18,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_object_fixture from tests.components.diagnostics import ( get_diagnostics_for_config_entry, get_diagnostics_for_device, @@ -29,19 +28,19 @@ @pytest.fixture(name="config_entry_diagnostics") def config_entry_diagnostics_fixture() -> dict[str, Any]: """Fixture for config entry diagnostics.""" - return json.loads(load_fixture("config_entry_diagnostics.json", DOMAIN)) + return load_json_object_fixture("config_entry_diagnostics.json", DOMAIN) @pytest.fixture(name="config_entry_diagnostics_redacted") def config_entry_diagnostics_redacted_fixture() -> dict[str, Any]: """Fixture for redacted config entry diagnostics.""" - return json.loads(load_fixture("config_entry_diagnostics_redacted.json", DOMAIN)) + return load_json_object_fixture("config_entry_diagnostics_redacted.json", DOMAIN) @pytest.fixture(name="device_diagnostics") def device_diagnostics_fixture() -> dict[str, Any]: """Fixture for device diagnostics.""" - return json.loads(load_fixture("nodes/device_diagnostics.json", DOMAIN)) + return load_json_object_fixture("nodes/device_diagnostics.json", DOMAIN) async def test_matter_attribute_redact(device_diagnostics: dict[str, Any]) -> None: diff --git a/tests/components/metoffice/test_config_flow.py b/tests/components/metoffice/test_config_flow.py index ac04d6fa9b5cdd..d699a7aa25e12c 100644 --- a/tests/components/metoffice/test_config_flow.py +++ b/tests/components/metoffice/test_config_flow.py @@ -22,7 +22,7 @@ TEST_SITE_NAME_WAVERTREE, ) -from tests.common import MockConfigEntry, async_load_fixture +from tests.common import MockConfigEntry, async_load_json_object_fixture async def test_form(hass: HomeAssistant, requests_mock: requests_mock.Mocker) -> None: @@ -31,7 +31,7 @@ async def test_form(hass: HomeAssistant, requests_mock: requests_mock.Mocker) -> hass.config.longitude = TEST_LONGITUDE_WAVERTREE # all metoffice test data encapsulated in here - mock_json = json.loads(await async_load_fixture(hass, "metoffice.json", DOMAIN)) + mock_json = await async_load_json_object_fixture(hass, "metoffice.json", DOMAIN) wavertree_daily = json.dumps(mock_json["wavertree_daily"]) requests_mock.get( "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/daily", @@ -72,7 +72,7 @@ async def test_form_already_configured( hass.config.longitude = TEST_LONGITUDE_WAVERTREE # all metoffice test data encapsulated in here - mock_json = json.loads(await async_load_fixture(hass, "metoffice.json", DOMAIN)) + mock_json = await async_load_json_object_fixture(hass, "metoffice.json", DOMAIN) wavertree_daily = json.dumps(mock_json["wavertree_daily"]) requests_mock.get( "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/daily", @@ -156,7 +156,7 @@ async def test_reauth_flow( device_registry: dr.DeviceRegistry, ) -> None: """Test handling authentication errors and reauth flow.""" - mock_json = json.loads(await async_load_fixture(hass, "metoffice.json", DOMAIN)) + mock_json = await async_load_json_object_fixture(hass, "metoffice.json", DOMAIN) wavertree_daily = json.dumps(mock_json["wavertree_daily"]) wavertree_hourly = json.dumps(mock_json["wavertree_hourly"]) requests_mock.get( diff --git a/tests/components/metoffice/test_init.py b/tests/components/metoffice/test_init.py index 47f3d521ef80a8..e76dea21440ae1 100644 --- a/tests/components/metoffice/test_init.py +++ b/tests/components/metoffice/test_init.py @@ -13,7 +13,11 @@ from .const import METOFFICE_CONFIG_WAVERTREE -from tests.common import MockConfigEntry, async_fire_time_changed, async_load_fixture +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_load_json_object_fixture, +) @pytest.mark.freeze_time(datetime.datetime(2024, 11, 23, 12, tzinfo=datetime.UTC)) @@ -23,7 +27,7 @@ async def test_reauth_on_auth_error( device_registry: dr.DeviceRegistry, ) -> None: """Test handling authentication errors and reauth flow.""" - mock_json = json.loads(await async_load_fixture(hass, "metoffice.json", DOMAIN)) + mock_json = await async_load_json_object_fixture(hass, "metoffice.json", DOMAIN) wavertree_daily = json.dumps(mock_json["wavertree_daily"]) wavertree_hourly = json.dumps(mock_json["wavertree_hourly"]) requests_mock.get( diff --git a/tests/components/metoffice/test_sensor.py b/tests/components/metoffice/test_sensor.py index efd60dcee6894c..7d329b2d46e930 100644 --- a/tests/components/metoffice/test_sensor.py +++ b/tests/components/metoffice/test_sensor.py @@ -24,7 +24,11 @@ WAVERTREE_SENSOR_RESULTS, ) -from tests.common import MockConfigEntry, async_load_fixture, get_sensor_display_state +from tests.common import ( + MockConfigEntry, + async_load_json_object_fixture, + get_sensor_display_state, +) @pytest.mark.freeze_time(datetime.datetime(2024, 11, 23, 12, tzinfo=datetime.UTC)) @@ -37,7 +41,7 @@ async def test_one_sensor_site_running( ) -> None: """Test the Met Office sensor platform.""" # all metoffice test data encapsulated in here - mock_json = json.loads(await async_load_fixture(hass, "metoffice.json", DOMAIN)) + mock_json = await async_load_json_object_fixture(hass, "metoffice.json", DOMAIN) wavertree_hourly = json.dumps(mock_json["wavertree_hourly"]) wavertree_daily = json.dumps(mock_json["wavertree_daily"]) @@ -89,7 +93,7 @@ async def test_two_sensor_sites_running( """Test we handle two sets of sensors running for two different sites.""" # all metoffice test data encapsulated in here - mock_json = json.loads(await async_load_fixture(hass, "metoffice.json", DOMAIN)) + mock_json = await async_load_json_object_fixture(hass, "metoffice.json", DOMAIN) wavertree_hourly = json.dumps(mock_json["wavertree_hourly"]) wavertree_daily = json.dumps(mock_json["wavertree_daily"]) kingslynn_hourly = json.dumps(mock_json["kingslynn_hourly"]) @@ -182,7 +186,7 @@ async def test_legacy_entities_are_removed( old_unique_id: str, ) -> None: """Test the expected entities are deleted.""" - mock_json = json.loads(await async_load_fixture(hass, "metoffice.json", DOMAIN)) + mock_json = await async_load_json_object_fixture(hass, "metoffice.json", DOMAIN) wavertree_hourly = json.dumps(mock_json["wavertree_hourly"]) wavertree_daily = json.dumps(mock_json["wavertree_daily"]) diff --git a/tests/components/metoffice/test_weather.py b/tests/components/metoffice/test_weather.py index 3e3581013d63d8..672bdf99d0170c 100644 --- a/tests/components/metoffice/test_weather.py +++ b/tests/components/metoffice/test_weather.py @@ -29,7 +29,11 @@ WAVERTREE_SENSOR_RESULTS, ) -from tests.common import MockConfigEntry, async_fire_time_changed, async_load_fixture +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_load_json_object_fixture, +) from tests.typing import WebSocketGenerator @@ -48,7 +52,7 @@ async def wavertree_data( ) -> dict[str, _Matcher]: """Mock data for the Wavertree location.""" # all metoffice test data encapsulated in here - mock_json = json.loads(await async_load_fixture(hass, "metoffice.json", DOMAIN)) + mock_json = await async_load_json_object_fixture(hass, "metoffice.json", DOMAIN) wavertree_hourly = json.dumps(mock_json["wavertree_hourly"]) wavertree_daily = json.dumps(mock_json["wavertree_daily"]) @@ -196,7 +200,7 @@ async def test_two_weather_sites_running( """Test we handle two different weather sites both running.""" # all metoffice test data encapsulated in here - mock_json = json.loads(await async_load_fixture(hass, "metoffice.json", DOMAIN)) + mock_json = await async_load_json_object_fixture(hass, "metoffice.json", DOMAIN) kingslynn_hourly = json.dumps(mock_json["kingslynn_hourly"]) kingslynn_daily = json.dumps(mock_json["kingslynn_daily"]) diff --git a/tests/components/midea/test_init.py b/tests/components/midea/test_init.py index f524aab80da272..2c1276b44449f7 100644 --- a/tests/components/midea/test_init.py +++ b/tests/components/midea/test_init.py @@ -229,9 +229,24 @@ async def test_migrate_entry_drops_empty_mac_connection( patch( "homeassistant.components.midea.device_selector", return_value=DummyDevice(DeviceType.AC), - ), + ) as device_selector, ): await hass.config_entries.async_setup(entry.entry_id) + device_selector.assert_called_once_with( + entry.data[CONF_NAME], + entry.data[CONF_DEVICE_ID], + entry.data[CONF_TYPE], + TEST_IP_ADDRESS, + entry.data[CONF_PORT], + entry.data[CONF_TOKEN], + entry.data[CONF_KEY], + ProtocolVersion(entry.data[CONF_PROTOCOL]), + entry.data[CONF_MODEL], + entry.data[CONF_SUBTYPE], + "", + entry.data.get(CONF_MAC, None), + entry.data.get(CONF_SN, None), + ) assert entry.state is ConfigEntryState.LOADED device_entry = device_registry.async_get_device_by_identifier( @@ -250,9 +265,24 @@ async def test_migrate_entry_without_discovery_result(hass: HomeAssistant) -> No patch( "homeassistant.components.midea.device_selector", return_value=DummyDevice(DeviceType.AC), - ), + ) as device_selector, ): await hass.config_entries.async_setup(entry.entry_id) + device_selector.assert_called_once_with( + entry.data[CONF_NAME], + entry.data[CONF_DEVICE_ID], + entry.data[CONF_TYPE], + TEST_IP_ADDRESS, + entry.data[CONF_PORT], + entry.data[CONF_TOKEN], + entry.data[CONF_KEY], + ProtocolVersion(entry.data[CONF_PROTOCOL]), + entry.data[CONF_MODEL], + entry.data[CONF_SUBTYPE], + "", + None, + None, + ) assert entry.state is ConfigEntryState.LOADED assert entry.minor_version == 2 diff --git a/tests/components/mitsubishi_comfort/test_config_flow.py b/tests/components/mitsubishi_comfort/test_config_flow.py index b603feb5e4350f..7a0e404b1d6fc8 100644 --- a/tests/components/mitsubishi_comfort/test_config_flow.py +++ b/tests/components/mitsubishi_comfort/test_config_flow.py @@ -3,24 +3,26 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch +from mitsubishi_comfort import DeviceInfo from mitsubishi_comfort.exceptions import AuthenticationError, DeviceConnectionError import pytest from homeassistant import config_entries -from homeassistant.components.mitsubishi_comfort.const import CONF_ADDRESSES, DOMAIN +from homeassistant.components.mitsubishi_comfort.const import ( + CONF_ADDRESSES, + CONF_CREDENTIALS, + DOMAIN, +) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .conftest import MOCK_MAC, MOCK_SERIAL +from .conftest import MOCK_MAC, MOCK_PASSWORD, MOCK_SERIAL, MOCK_USERNAME from tests.common import MockConfigEntry -MOCK_USERNAME = "test@test.com" -MOCK_PASSWORD = "testpass" - @pytest.fixture(autouse=True) def mock_setup_entry() -> Generator[AsyncMock]: @@ -58,13 +60,215 @@ async def test_user_step_success( ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == f"Mitsubishi Comfort ({MOCK_USERNAME})" + # Per-device credentials from discovery are persisted so setup can skip the + # rate-limited Socket.IO fetch. assert result["data"] == { CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD, + CONF_CREDENTIALS: { + MOCK_SERIAL: { + "password": "dGVzdHBhc3M=", + "crypto_serial": "0102030405060708090a", + "mac": MOCK_MAC, + } + }, } mock_setup_entry.assert_called_once() +async def test_user_step_persists_partial_records( + hass: HomeAssistant, + mock_cloud_account: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test partially discovered devices keep their recovered fields. + + discover_devices() consumes the password, cryptoSerial, and MAC + independently, so whatever discovery recovered is seeded for replay, + matching async_setup_entry's caching. + """ + mock_cloud_account.discover_devices.return_value = { + MOCK_SERIAL: DeviceInfo( + serial=MOCK_SERIAL, + label="Living Room", + address="", + mac=MOCK_MAC, + unit_type="ductless", + password="dGVzdHBhc3M=", + crypto_serial="0102030405060708090a", + ), + "SERIAL002": DeviceInfo( + serial="SERIAL002", + label="Bedroom", + address="", + mac="11:22:33:44:55:66", + unit_type="ductless", + password="", + crypto_serial="", + ), + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_CREDENTIALS] == { + MOCK_SERIAL: { + "password": "dGVzdHBhc3M=", + "crypto_serial": "0102030405060708090a", + "mac": MOCK_MAC, + }, + "SERIAL002": { + "password": "", + "crypto_serial": "", + "mac": "11:22:33:44:55:66", + }, + } + + +def _partial_device_info() -> DeviceInfo: + """Build a device with local secrets but no MAC: recoverable, not usable.""" + return DeviceInfo( + serial=MOCK_SERIAL, + label="Living Room", + address="", + mac="", + unit_type="ductless", + password="dGVzdHBhc3M=", + crypto_serial="0102030405060708090a", + ) + + +async def test_user_step_retry_replays_partial_credentials( + hass: HomeAssistant, + mock_cloud_account: AsyncMock, +) -> None: + """Test a retry replays the fields recovered by a failed earlier attempt. + + The Socket.IO password fetch is rate limited: one attempt can recover the + passwords yet miss the MACs, and its retry the reverse. Replaying the + recovered fields means no single attempt has to return everything. + """ + mock_cloud_account.discover_devices.return_value = { + MOCK_SERIAL: _partial_device_info() + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + ) + assert result["errors"] == {"base": "no_usable_devices"} + assert ( + mock_cloud_account.discover_devices.call_args.kwargs["cached_credentials"] == {} + ) + + mock_cloud_account.discover_devices.return_value = { + MOCK_SERIAL: DeviceInfo( + serial=MOCK_SERIAL, + label="Living Room", + address="", + mac=MOCK_MAC, + unit_type="ductless", + password="dGVzdHBhc3M=", + crypto_serial="0102030405060708090a", + ) + } + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_cloud_account.discover_devices.call_args.kwargs[ + "cached_credentials" + ] == { + MOCK_SERIAL: { + "password": "dGVzdHBhc3M=", + "crypto_serial": "0102030405060708090a", + "mac": "", + } + } + + +async def test_user_step_empty_account_response_keeps_cached_credentials( + hass: HomeAssistant, + mock_cloud_account: AsyncMock, +) -> None: + """Test a transient empty device list does not wipe recovered fields. + + The cached password may be unrecoverable, so only a discovery that + returned devices may replace the cache. + """ + mock_cloud_account.discover_devices.return_value = { + MOCK_SERIAL: _partial_device_info() + } + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + ) + assert result["errors"] == {"base": "no_usable_devices"} + + mock_cloud_account.discover_devices.return_value = {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + ) + assert result["errors"] == {"base": "no_devices"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + ) + assert mock_cloud_account.discover_devices.call_args.kwargs[ + "cached_credentials" + ] == { + MOCK_SERIAL: { + "password": "dGVzdHBhc3M=", + "crypto_serial": "0102030405060708090a", + "mac": "", + } + } + + +async def test_user_step_username_change_drops_cached_credentials( + hass: HomeAssistant, + mock_cloud_account: AsyncMock, +) -> None: + """Test fields recovered for one account are not replayed for another.""" + mock_cloud_account.discover_devices.return_value = { + MOCK_SERIAL: _partial_device_info() + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + ) + assert result["errors"] == {"base": "no_usable_devices"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "other@example.com", CONF_PASSWORD: MOCK_PASSWORD}, + ) + assert result["errors"] == {"base": "no_usable_devices"} + assert ( + mock_cloud_account.discover_devices.call_args.kwargs["cached_credentials"] == {} + ) + + @pytest.mark.parametrize( ("side_effect", "discover_return", "expected_error"), [ @@ -72,8 +276,45 @@ async def test_user_step_success( (DeviceConnectionError("nope"), None, "cannot_connect"), (RuntimeError("Unexpected"), None, "unknown"), (None, {}, "no_devices"), + ( + None, + { + "SERIAL002": DeviceInfo( + serial="SERIAL002", + label="Bedroom", + address="", + mac="11:22:33:44:55:66", + unit_type="ductless", + password="", + crypto_serial="", + ) + }, + "no_usable_devices", + ), + ( + None, + { + "SERIAL002": DeviceInfo( + serial="SERIAL002", + label="Bedroom", + address="", + mac="", + unit_type="ductless", + password="dGVzdHBhc3M=", + crypto_serial="0102030405060708090a", + ) + }, + "no_usable_devices", + ), + ], + ids=[ + "invalid_auth", + "cannot_connect", + "unknown_error", + "no_devices", + "no_usable_devices", + "no_usable_devices_mac_less", ], - ids=["invalid_auth", "cannot_connect", "unknown_error", "no_devices"], ) async def test_user_step_errors( hass: HomeAssistant, @@ -210,6 +451,35 @@ async def test_dhcp_unregistered_device_ignored( mock_reload.assert_not_called() +async def test_dhcp_device_without_current_entry_aborts( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test DHCP aborts when the registered device has no current owning entry. + + The device exists in the registry but belongs only to an ignored entry, so + there is nothing to update. + """ + ignored_entry = MockConfigEntry( + domain=DOMAIN, source=config_entries.SOURCE_IGNORE, unique_id="ignored" + ) + ignored_entry.add_to_hass(hass) + _register_device(device_registry, ignored_entry) + + with patch( + "homeassistant.config_entries.ConfigEntries.async_schedule_reload" + ) as mock_reload: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=_dhcp_info("192.168.1.253"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + mock_reload.assert_not_called() + + async def test_dhcp_no_account_aborts(hass: HomeAssistant) -> None: """Test DHCP discovery with no configured account aborts without a flow.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/mitsubishi_comfort/test_init.py b/tests/components/mitsubishi_comfort/test_init.py index 26263b7d88eef0..084466dab1819b 100644 --- a/tests/components/mitsubishi_comfort/test_init.py +++ b/tests/components/mitsubishi_comfort/test_init.py @@ -1,22 +1,40 @@ """Tests for the Mitsubishi Comfort integration setup.""" -from unittest.mock import AsyncMock, MagicMock +import logging +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch from mitsubishi_comfort import DeviceInfo from mitsubishi_comfort.exceptions import AuthenticationError, DeviceConnectionError import pytest -from homeassistant.components.mitsubishi_comfort.const import CONF_ADDRESSES, DOMAIN +from homeassistant.components.mitsubishi_comfort.const import ( + CONF_ADDRESSES, + CONF_CREDENTIALS, + DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .conftest import MOCK_ADDRESS, MOCK_MAC, MOCK_PASSWORD, MOCK_USERNAME +from .conftest import MOCK_ADDRESS, MOCK_MAC, MOCK_PASSWORD, MOCK_SERIAL, MOCK_USERNAME from tests.common import MockConfigEntry +def _cache_entry(ip: str, mac: str = MOCK_MAC) -> DhcpServiceInfo: + """Build a DHCP cache entry (the cache stores MACs without separators).""" + return DhcpServiceInfo( + ip=ip, hostname="kumo", macaddress=mac.replace(":", "").lower() + ) + + async def test_setup_entry_success( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -76,6 +94,7 @@ async def test_setup_entry_no_address_loads_and_registers( hass: HomeAssistant, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, + issue_registry: ir.IssueRegistry, mock_cloud_account: AsyncMock, ) -> None: """Test setup with no known LAN address loads and registers the device. @@ -84,7 +103,8 @@ async def test_setup_entry_no_address_loads_and_registers( resolved address the device cannot be polled, so it creates no entity — but it is registered with its MAC so "registered_devices" DHCP discovery can supply the IP and reload the entry. Setup must not retry (which would hammer - the cloud API) since retrying can never resolve the address. + the cloud API) since retrying can never resolve the address. The missing + address is surfaced as a repair issue rather than failing silently. """ entry = MockConfigEntry( domain=DOMAIN, @@ -101,11 +121,114 @@ async def test_setup_entry_no_address_loads_and_registers( assert device_registry.async_get_device_by_connection( (dr.CONNECTION_NETWORK_MAC, dr.format_mac(MOCK_MAC)), entry.entry_id ) + issue = issue_registry.async_get_issue(DOMAIN, f"missing_address_{entry.entry_id}") + assert issue + assert issue.is_fixable + assert issue.severity is ir.IssueSeverity.ERROR + assert issue.data == {"entry_id": entry.entry_id} + + +@pytest.mark.parametrize( + ("entry_data", "cache", "expected_addresses", "expect_issue"), + [ + pytest.param( + {CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + [_cache_entry(MOCK_ADDRESS)], + {dr.format_mac(MOCK_MAC): MOCK_ADDRESS}, + False, + id="seeds_missing_address", + ), + pytest.param( + { + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_ADDRESSES: {dr.format_mac(MOCK_MAC): MOCK_ADDRESS}, + }, + [_cache_entry("192.168.1.222")], + {dr.format_mac(MOCK_MAC): MOCK_ADDRESS}, + False, + id="stored_address_wins", + ), + pytest.param( + {CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + [_cache_entry("192.168.1.60", mac="99:99:99:99:99:99")], + {}, + True, + id="ignores_unowned_mac", + ), + ], +) +@pytest.mark.usefixtures("mock_setup_integration") +async def test_setup_entry_dhcp_cache_seeding( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + entry_data: dict[str, Any], + cache: list[DhcpServiceInfo], + expected_addresses: dict[str, str], + expect_issue: bool, +) -> None: + """Test setup consults the DHCP discovery cache for missing addresses. + + A device sighted before it was registered never re-fires + registered_devices discovery, so setup looks the sighting cache up instead + of waiting for a new sighting. Stored addresses are never overwritten by + the cache (live discovery handles genuine IP changes), and sightings of + MACs the account does not own are ignored. + """ + entry = MockConfigEntry(domain=DOMAIN, data=entry_data, unique_id="user-12345") + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.mitsubishi_comfort.async_discovered_service_info", + return_value=cache, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert entry.data.get(CONF_ADDRESSES, {}) == expected_addresses + issue = issue_registry.async_get_issue(DOMAIN, f"missing_address_{entry.entry_id}") + assert (issue is not None) is expect_issue + + +async def test_setup_entry_caches_and_replays_credentials( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_integration: tuple[AsyncMock, MagicMock], +) -> None: + """Test credentials are persisted on the entry and replayed to discovery.""" + mock_account, _ = mock_setup_integration + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # The first setup has nothing to replay and persists the discovered + # credentials for the next one. + assert mock_account.discover_devices.call_args.kwargs["cached_credentials"] == {} + credentials = { + MOCK_SERIAL: { + "password": "dGVzdHBhc3M=", + "crypto_serial": "0102030405060708090a", + "mac": MOCK_MAC, + } + } + assert mock_config_entry.data[CONF_CREDENTIALS] == credentials + + # A reload replays the persisted credentials so discovery can skip the + # rate-limited Socket.IO fetch. + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_account.discover_devices.call_args.kwargs["cached_credentials"] == ( + credentials + ) async def test_setup_entry_resolves_address_from_entry( hass: HomeAssistant, entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, mock_config_entry: MockConfigEntry, mock_setup_integration: tuple[AsyncMock, MagicMock], ) -> None: @@ -124,16 +247,56 @@ async def test_setup_entry_resolves_address_from_entry( assert mock_config_entry.data[CONF_ADDRESSES][dr.format_mac(MOCK_MAC)] == ( MOCK_ADDRESS ) + assert not issue_registry.async_get_issue( + DOMAIN, f"missing_address_{mock_config_entry.entry_id}" + ) + + +async def test_setup_entry_prunes_stale_addresses( + hass: HomeAssistant, + mock_setup_integration: tuple[AsyncMock, MagicMock], +) -> None: + """Test a stored address for a device no longer on the account is dropped.""" + stale_mac = dr.format_mac("99:99:99:99:99:99") + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_ADDRESSES: { + dr.format_mac(MOCK_MAC): MOCK_ADDRESS, + stale_mac: "192.168.1.99", + }, + }, + unique_id="user-12345", + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert entry.data[CONF_ADDRESSES] == {dr.format_mac(MOCK_MAC): MOCK_ADDRESS} async def test_setup_entry_skips_incomplete_devices( hass: HomeAssistant, entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, mock_config_entry: MockConfigEntry, mock_device_info: DeviceInfo, mock_setup_integration: tuple[AsyncMock, MagicMock], + caplog: pytest.LogCaptureFixture, ) -> None: - """Test setup skips incomplete devices and creates complete ones.""" + """Test setup skips devices the cloud returned incomplete data for. + + Without a password and cryptoSerial the local API cannot be authenticated, + and without a MAC the device cannot be keyed in the address cache, so the + device is skipped (no coordinator, no entity) and the gap is logged. Any + recovered field is still cached — discover_devices() consumes them + independently, and the password in particular may never be returned by + the throttled Socket.IO fetch again. + """ incomplete_info = DeviceInfo( serial="SERIAL002", label="Bedroom", @@ -143,19 +306,59 @@ async def test_setup_entry_skips_incomplete_devices( password="", crypto_serial="", ) + no_mac_info = DeviceInfo( + serial="SERIAL003", + label="Attic", + address="", + mac="", + unit_type="ductless", + password="dGVzdHBhc3M=", + crypto_serial="0102030405060708090a", + ) mock_account, _ = mock_setup_integration mock_account.discover_devices.return_value = { "SERIAL001": mock_device_info, "SERIAL002": incomplete_info, + "SERIAL003": no_mac_info, } mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + with caplog.at_level( + logging.DEBUG, logger="homeassistant.components.mitsubishi_comfort" + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.LOADED assert entity_registry.async_get_entity_id("climate", DOMAIN, "SERIAL001") assert entity_registry.async_get_entity_id("climate", DOMAIN, "SERIAL002") is None + assert entity_registry.async_get_entity_id("climate", DOMAIN, "SERIAL003") is None + assert ( + "The cloud returned incomplete local connection data for 2 device(s):" + " Attic, Bedroom" in caplog.text + ) + assert mock_config_entry.data[CONF_CREDENTIALS] == { + "SERIAL001": { + "password": "dGVzdHBhc3M=", + "crypto_serial": "0102030405060708090a", + "mac": MOCK_MAC, + }, + "SERIAL002": { + "password": "", + "crypto_serial": "", + "mac": "11:22:33:44:55:66", + }, + "SERIAL003": { + "password": "dGVzdHBhc3M=", + "crypto_serial": "0102030405060708090a", + "mac": "", + }, + } + # Incomplete devices are not addressless: an issue for them would open a + # fix flow with zero fields. + assert not issue_registry.async_get_issue( + DOMAIN, f"missing_address_{mock_config_entry.entry_id}" + ) async def test_unload_entry( @@ -174,3 +377,239 @@ async def test_unload_entry( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_setup_entry_registers_mac_less_devices_separately( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_setup_integration: tuple[AsyncMock, MagicMock], +) -> None: + """Test MAC-less devices get their own registry entries, sans connection. + + Connections are globally indexed, so registering an empty MAC would merge + every MAC-less device into the first one's registry entry. + """ + mock_account, _ = mock_setup_integration + mock_account.discover_devices.return_value = { + "SERIAL002": DeviceInfo( + serial="SERIAL002", + label="Bedroom", + address="", + mac="", + unit_type="ductless", + password="dGVzdHBhc3M=", + crypto_serial="0102030405060708090a", + ), + "SERIAL003": DeviceInfo( + serial="SERIAL003", + label="Attic", + address="", + mac="", + unit_type="ductless", + password="dGVzdHBhc3M=", + crypto_serial="0102030405060708090a", + ), + } + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + unique_id="user-12345", + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + bedroom = device_registry.async_get_device_by_identifier( + (DOMAIN, "SERIAL002"), entry.entry_id + ) + attic = device_registry.async_get_device_by_identifier( + (DOMAIN, "SERIAL003"), entry.entry_id + ) + assert bedroom is not None + assert attic is not None + assert bedroom.id != attic.id + assert not bedroom.connections + assert not attic.connections + + +async def test_failed_unload_keeps_missing_address_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_cloud_account: AsyncMock, +) -> None: + """Test a failed platform unload keeps the actionable repair issue. + + A failed unload leaves the entry active with its addressless devices, so + deleting the issue first would strip the only UI path to fix them. + """ + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + unique_id="user-12345", + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + issue_id = f"missing_address_{entry.entry_id}" + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + with patch( + "homeassistant.config_entries.ConfigEntries.async_unload_platforms", + return_value=False, + ): + assert not await hass.config_entries.async_unload(entry.entry_id) + + assert entry.state is ConfigEntryState.FAILED_UNLOAD + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + +async def test_setup_retry_raises_issue_from_cached_credentials( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_cloud_account: AsyncMock, +) -> None: + """Test a cloud-down setup still offers the fix flow from stored data. + + The issue is not persistent and unload deletes it, so a restart or reload + that cannot reach the cloud must reconcile it from the entry data alone. + """ + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_CREDENTIALS: { + MOCK_SERIAL: { + "password": "dGVzdHBhc3M=", + "crypto_serial": "0102030405060708090a", + "mac": MOCK_MAC, + } + }, + }, + unique_id="user-12345", + ) + entry.add_to_hass(hass) + mock_cloud_account.login.side_effect = DeviceConnectionError("cloud down") + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + assert issue_registry.async_get_issue(DOMAIN, f"missing_address_{entry.entry_id}") + + +async def test_setup_retry_clears_stale_issue_when_all_addressed( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_cloud_account: AsyncMock, +) -> None: + """Test a cloud-down setup clears an issue whose devices got addresses.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_ADDRESSES: {dr.format_mac(MOCK_MAC): MOCK_ADDRESS}, + CONF_CREDENTIALS: { + MOCK_SERIAL: { + "password": "dGVzdHBhc3M=", + "crypto_serial": "0102030405060708090a", + "mac": MOCK_MAC, + }, + # A MAC-only record cannot be probed, so it must not count + # as addressless. + "SERIAL002": { + "password": "", + "crypto_serial": "", + "mac": "11:22:33:44:55:66", + }, + }, + }, + unique_id="user-12345", + ) + entry.add_to_hass(hass) + issue_id = f"missing_address_{entry.entry_id}" + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=True, + severity=ir.IssueSeverity.ERROR, + translation_key="missing_address", + data={"entry_id": entry.entry_id}, + ) + mock_cloud_account.login.side_effect = DeviceConnectionError("cloud down") + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + + +async def test_remove_never_loaded_entry_clears_missing_address_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_setup_integration: tuple[AsyncMock, MagicMock], + mock_device_info: DeviceInfo, + mock_config_entry: MockConfigEntry, +) -> None: + """Test removing an entry stuck in setup retry clears the repair issue. + + Removal never calls async_unload_entry for an entry that failed setup, so + without the remove hook the issue would outlive the entry. + """ + mock_account, mock_device = mock_setup_integration + mock_account.discover_devices.return_value = { + MOCK_SERIAL: mock_device_info, + "SERIAL002": DeviceInfo( + serial="SERIAL002", + label="Bedroom", + address="", + mac="11:22:33:44:55:66", + unit_type="ductless", + password="dGVzdHBhc3M=", + crypto_serial="0102030405060708090a", + ), + } + mock_device.update_status.side_effect = DeviceConnectionError("boom") + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + issue_id = f"missing_address_{mock_config_entry.entry_id}" + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + await hass.config_entries.async_remove(mock_config_entry.entry_id) + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + + +async def test_unload_entry_clears_missing_address_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_cloud_account: AsyncMock, +) -> None: + """Test unloading clears the missing-address repair issue. + + Without the cleanup, removing the integration would leave a stale issue for + a device that never had a resolved LAN address. + """ + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + unique_id="user-12345", + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + issue_id = f"missing_address_{entry.entry_id}" + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) diff --git a/tests/components/mitsubishi_comfort/test_repairs.py b/tests/components/mitsubishi_comfort/test_repairs.py new file mode 100644 index 00000000000000..e5b4b9b3bf0ccc --- /dev/null +++ b/tests/components/mitsubishi_comfort/test_repairs.py @@ -0,0 +1,655 @@ +"""Tests for the Mitsubishi Comfort repairs flow.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from mitsubishi_comfort import DeviceInfo +from mitsubishi_comfort.exceptions import DeviceConnectionError +import pytest + +from homeassistant.components.mitsubishi_comfort.const import ( + CONF_ADDRESSES, + CONF_CREDENTIALS, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.setup import async_setup_component + +from .conftest import MOCK_MAC, MOCK_PASSWORD, MOCK_SERIAL, MOCK_USERNAME + +from tests.common import MockConfigEntry +from tests.components.repairs import process_repair_fix_flow, start_repair_fix_flow +from tests.typing import ClientSessionGenerator + +pytestmark = pytest.mark.usefixtures("mock_setup_integration") + +# The per-device IP fields are keyed by formatted MAC (dynamic), so they have +# no static label in strings.json; ignore that in the translation check. +IGNORE_FORM_TRANSLATIONS = [ + "component.mitsubishi_comfort.issues.missing_address.fix_flow.step.addresses.data.", + "component.mitsubishi_comfort.issues.missing_address.fix_flow.step.addresses.data_description.", +] + + +def _second_device_info() -> DeviceInfo: + """Build a second fully-credentialed device without a LAN address.""" + return DeviceInfo( + serial="SERIAL002", + label="Bedroom", + address="", + mac="11:22:33:44:55:66", + unit_type="ductless", + password="dGVzdHBhc3M=", + crypto_serial="0102030405060708090a", + ) + + +async def _setup_addressless_entry(hass: HomeAssistant) -> MockConfigEntry: + """Set up an entry whose device has no LAN address, raising the issue.""" + assert await async_setup_component(hass, "repairs", {}) + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + unique_id="user-12345", + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry + + +@pytest.mark.parametrize("ignore_missing_translations", [IGNORE_FORM_TRANSLATIONS]) +@pytest.mark.parametrize( + "invalid_value", + [ + pytest.param("not-an-ip", id="not_an_ip"), + pytest.param("2001:db8::1", id="ipv6"), + ], +) +async def test_fix_flow_sets_missing_address( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + invalid_value: str, +) -> None: + """Test the fix flow records a manually entered IP and resolves the issue. + + Non-IPv4 input is rejected: the local API URL is built without IPv6 + brackets, so an IPv6 literal can never work. + """ + entry = await _setup_addressless_entry(hass) + issue_id = f"missing_address_{entry.entry_id}" + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + client = await hass_client() + data = await start_repair_fix_flow(client, DOMAIN, issue_id) + flow_id = data["flow_id"] + assert data["step_id"] == "addresses" + # The fields are labeled by raw MAC, so the description must pair each MAC + # with its device name for the user to tell the fields apart. + assert dr.format_mac(MOCK_MAC) in data["description_placeholders"]["devices"] + + data = await process_repair_fix_flow( + client, flow_id, json={dr.format_mac(MOCK_MAC): invalid_value} + ) + assert data["errors"] == {dr.format_mac(MOCK_MAC): "invalid_ip"} + + with patch( + "homeassistant.components.mitsubishi_comfort.repairs.probe_candidate_ips", + return_value={MOCK_SERIAL: "192.168.1.50"}, + ) as mock_probe: + data = await process_repair_fix_flow( + client, flow_id, json={dr.format_mac(MOCK_MAC): "192.168.1.50"} + ) + assert data["type"] == "create_entry" + assert mock_probe.call_args.kwargs["session"] is async_get_clientsession(hass) + # The probe must authenticate with the cached local secrets; without them + # every correct IP would be rejected as cannot_connect. + probed = mock_probe.call_args.args[0] + assert probed[MOCK_SERIAL].password == "dGVzdHBhc3M=" + assert probed[MOCK_SERIAL].crypto_serial == "0102030405060708090a" + await hass.async_block_till_done() + + assert entry.data[CONF_ADDRESSES][dr.format_mac(MOCK_MAC)] == "192.168.1.50" + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + + +@pytest.mark.parametrize("ignore_missing_translations", [IGNORE_FORM_TRANSLATIONS]) +async def test_fix_flow_blank_field_keeps_issue( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, +) -> None: + """Test leaving a field blank keeps the device addressless. + + The repairs framework deletes the issue when the flow completes; the + reload the flow schedules re-creates it while any device still lacks an + address. + """ + entry = await _setup_addressless_entry(hass) + issue_id = f"missing_address_{entry.entry_id}" + + client = await hass_client() + data = await start_repair_fix_flow(client, DOMAIN, issue_id) + data = await process_repair_fix_flow(client, data["flow_id"], json={}) + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + assert not entry.data.get(CONF_ADDRESSES) + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + +@pytest.mark.parametrize("ignore_missing_translations", [IGNORE_FORM_TRANSLATIONS]) +@pytest.mark.parametrize( + ("submission", "issue_expected"), + [ + pytest.param({}, True, id="still_addressless"), + pytest.param( + {dr.format_mac(MOCK_MAC): "192.168.1.50"}, False, id="fully_addressed" + ), + ], +) +async def test_fix_flow_failed_reload_restores_issue( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + submission: dict[str, str], + issue_expected: bool, +) -> None: + """Test a reload whose unload fails restores the missing-address issue. + + The repairs framework deletes the issue when the flow finishes, and a + failed unload stops the reload before setup can re-create it — so the + reload task restores the issue itself, but only while devices actually + remain addressless. + """ + entry = await _setup_addressless_entry(hass) + issue_id = f"missing_address_{entry.entry_id}" + + client = await hass_client() + data = await start_repair_fix_flow(client, DOMAIN, issue_id) + with ( + patch( + "homeassistant.components.mitsubishi_comfort.repairs.probe_candidate_ips", + return_value={MOCK_SERIAL: "192.168.1.50"}, + ), + patch( + "homeassistant.components.mitsubishi_comfort.async_unload_entry", + return_value=False, + ), + ): + data = await process_repair_fix_flow(client, data["flow_id"], json=submission) + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + assert bool(issue_registry.async_get_issue(DOMAIN, issue_id)) is issue_expected + + +@pytest.mark.parametrize("ignore_missing_translations", [IGNORE_FORM_TRANSLATIONS]) +async def test_fix_flow_retry_on_wedged_entry_keeps_issue( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a repeat repair attempt on a FAILED_UNLOAD entry keeps the issue. + + The first failed reload leaves the entry non-recoverable, so the second + attempt's reload raises OperationNotAllowed instead of returning False; + the issue must survive that path too or the still-addressless entry loses + its only fix-flow path until restart. + """ + entry = await _setup_addressless_entry(hass) + issue_id = f"missing_address_{entry.entry_id}" + + client = await hass_client() + data = await start_repair_fix_flow(client, DOMAIN, issue_id) + with patch( + "homeassistant.components.mitsubishi_comfort.async_unload_entry", + return_value=False, + ): + data = await process_repair_fix_flow(client, data["flow_id"], json={}) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.FAILED_UNLOAD + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + data = await start_repair_fix_flow(client, DOMAIN, issue_id) + data = await process_repair_fix_flow(client, data["flow_id"], json={}) + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + +@pytest.mark.parametrize("ignore_missing_translations", [IGNORE_FORM_TRANSLATIONS]) +async def test_fix_flow_failed_reload_ignores_partial_records( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + mock_setup_integration: tuple[AsyncMock, MagicMock], + mock_device_info: DeviceInfo, +) -> None: + """Test a partial credential record cannot trigger issue restoration. + + A MAC-less record never gets an address, but it also cannot be offered in + the fix flow, so restoring the issue for it would create an unfixable + repair. + """ + mock_account, _ = mock_setup_integration + mock_account.discover_devices.return_value = { + MOCK_SERIAL: mock_device_info, + "SERIAL002": DeviceInfo( + serial="SERIAL002", + label="Bedroom", + address="", + mac="", + unit_type="ductless", + password="dGVzdHBhc3M=", + crypto_serial="0102030405060708090a", + ), + } + entry = await _setup_addressless_entry(hass) + issue_id = f"missing_address_{entry.entry_id}" + mac = dr.format_mac(MOCK_MAC) + + client = await hass_client() + data = await start_repair_fix_flow(client, DOMAIN, issue_id) + with ( + patch( + "homeassistant.components.mitsubishi_comfort.repairs.probe_candidate_ips", + return_value={MOCK_SERIAL: "192.168.1.50"}, + ), + patch( + "homeassistant.components.mitsubishi_comfort.async_unload_entry", + return_value=False, + ), + ): + data = await process_repair_fix_flow( + client, data["flow_id"], json={mac: "192.168.1.50"} + ) + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + + +@pytest.mark.parametrize("ignore_missing_translations", [IGNORE_FORM_TRANSLATIONS]) +async def test_fix_flow_lists_only_addressless_devices( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_setup_integration: tuple[AsyncMock, MagicMock], + mock_device_info: DeviceInfo, +) -> None: + """Test the form omits devices that already have a stored address.""" + mock_account, _ = mock_setup_integration + mock_account.discover_devices.return_value = { + MOCK_SERIAL: mock_device_info, + "SERIAL002": _second_device_info(), + } + assert await async_setup_component(hass, "repairs", {}) + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_ADDRESSES: {dr.format_mac(MOCK_MAC): "192.168.1.100"}, + }, + unique_id="user-12345", + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + client = await hass_client() + data = await start_repair_fix_flow( + client, DOMAIN, f"missing_address_{entry.entry_id}" + ) + + second_mac = dr.format_mac("11:22:33:44:55:66") + assert [field["name"] for field in data["data_schema"]] == [second_mac] + assert data["description_placeholders"]["devices"] == f"Bedroom ({second_mac})" + + +@pytest.mark.parametrize("ignore_missing_translations", [IGNORE_FORM_TRANSLATIONS]) +async def test_fix_flow_offers_cached_devices_before_first_discovery( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + mock_setup_integration: tuple[AsyncMock, MagicMock], +) -> None: + """Test the form offers cached devices when no discovery ever succeeded. + + The registry is empty then, so the fields fall back to the credential + cache with the serial as the label; a registry-only form would render + zero fields and make the repair a dead-end loop while the cloud is down. + """ + assert await async_setup_component(hass, "repairs", {}) + mock_account, _ = mock_setup_integration + mock_account.login.side_effect = DeviceConnectionError("cloud down") + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_CREDENTIALS: { + MOCK_SERIAL: { + "password": "dGVzdHBhc3M=", + "crypto_serial": "0102030405060708090a", + "mac": MOCK_MAC, + } + }, + }, + unique_id="user-12345", + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + issue_id = f"missing_address_{entry.entry_id}" + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + mac = dr.format_mac(MOCK_MAC) + client = await hass_client() + data = await start_repair_fix_flow(client, DOMAIN, issue_id) + assert [field["name"] for field in data["data_schema"]] == [mac] + assert data["description_placeholders"]["devices"] == f"{MOCK_SERIAL} ({mac})" + + with patch( + "homeassistant.components.mitsubishi_comfort.repairs.probe_candidate_ips", + return_value={MOCK_SERIAL: "192.168.1.50"}, + ): + data = await process_repair_fix_flow( + client, data["flow_id"], json={mac: "192.168.1.50"} + ) + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + assert entry.data[CONF_ADDRESSES][mac] == "192.168.1.50" + # Every cached device is addressed now, so the failed reload (the cloud + # is still down) must not resurrect the issue. + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + + +@pytest.mark.parametrize("ignore_missing_translations", [IGNORE_FORM_TRANSLATIONS]) +async def test_fix_flow_suggests_cached_ip( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test the form pre-fills an IP the DHCP cache saw after setup.""" + entry = await _setup_addressless_entry(hass) + issue_id = f"missing_address_{entry.entry_id}" + + client = await hass_client() + with patch( + "homeassistant.components.mitsubishi_comfort.repairs.async_discovered_service_info", + return_value=[ + DhcpServiceInfo( + ip="10.0.0.5", + hostname="kumo", + macaddress=MOCK_MAC.replace(":", "").lower(), + ) + ], + ): + data = await start_repair_fix_flow(client, DOMAIN, issue_id) + + assert data["step_id"] == "addresses" + assert data["data_schema"][0]["description"] == {"suggested_value": "10.0.0.5"} + + +@pytest.mark.parametrize("ignore_missing_translations", [IGNORE_FORM_TRANSLATIONS]) +async def test_fix_flow_keeps_address_discovered_during_probe( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_setup_integration: tuple[AsyncMock, MagicMock], + mock_device_info: DeviceInfo, +) -> None: + """Test an address stored by DHCP during the probe await is not erased.""" + second = _second_device_info() + mock_account, _ = mock_setup_integration + mock_account.discover_devices.return_value = { + "SERIAL001": mock_device_info, + "SERIAL002": second, + } + entry = await _setup_addressless_entry(hass) + second_mac = dr.format_mac(second.mac) + + async def _probe_with_concurrent_discovery( + *args: object, **kwargs: object + ) -> dict[str, str]: + hass.config_entries.async_update_entry( + entry, + data={**entry.data, CONF_ADDRESSES: {second_mac: "192.168.1.60"}}, + ) + return {MOCK_SERIAL: "192.168.1.50"} + + client = await hass_client() + data = await start_repair_fix_flow( + client, DOMAIN, f"missing_address_{entry.entry_id}" + ) + with patch( + "homeassistant.components.mitsubishi_comfort.repairs.probe_candidate_ips", + side_effect=_probe_with_concurrent_discovery, + ): + data = await process_repair_fix_flow( + client, data["flow_id"], json={dr.format_mac(MOCK_MAC): "192.168.1.50"} + ) + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + assert entry.data[CONF_ADDRESSES] == { + second_mac: "192.168.1.60", + dr.format_mac(MOCK_MAC): "192.168.1.50", + } + + +@pytest.mark.parametrize("ignore_missing_translations", [IGNORE_FORM_TRANSLATIONS]) +async def test_fix_flow_rejects_unreachable_address( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test an address whose device fails the authenticated probe is rejected. + + Storing an unverified address would suppress this repair while the entry + is stuck retrying its first refresh, with no UI path left to correct it. + """ + entry = await _setup_addressless_entry(hass) + + client = await hass_client() + data = await start_repair_fix_flow( + client, DOMAIN, f"missing_address_{entry.entry_id}" + ) + with patch( + "homeassistant.components.mitsubishi_comfort.repairs.probe_candidate_ips", + return_value={}, + ) as mock_probe: + data = await process_repair_fix_flow( + client, data["flow_id"], json={dr.format_mac(MOCK_MAC): "192.168.1.77"} + ) + + assert data["errors"] == {dr.format_mac(MOCK_MAC): "cannot_connect"} + assert mock_probe.call_args.args[1] == ["192.168.1.77"] + # The re-rendered form keeps what the user typed. + assert data["data_schema"][0]["description"] == {"suggested_value": "192.168.1.77"} + assert not entry.data.get(CONF_ADDRESSES) + + +@pytest.mark.parametrize("ignore_missing_translations", [IGNORE_FORM_TRANSLATIONS]) +async def test_fix_flow_flags_each_unreachable_field( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_setup_integration: tuple[AsyncMock, MagicMock], + mock_device_info: DeviceInfo, +) -> None: + """Test only the fields whose probes fail carry the connection error. + + With several devices submitted, a base error would not tell the user + which address is wrong. + """ + second = _second_device_info() + mock_account, _ = mock_setup_integration + mock_account.discover_devices.return_value = { + MOCK_SERIAL: mock_device_info, + "SERIAL002": second, + } + entry = await _setup_addressless_entry(hass) + second_mac = dr.format_mac(second.mac) + + async def _probe_first_only( + devices: dict[str, DeviceInfo], ips: list[str], **kwargs: object + ) -> dict[str, str]: + serial = next(iter(devices)) + return {serial: ips[0]} if serial == MOCK_SERIAL else {} + + client = await hass_client() + data = await start_repair_fix_flow( + client, DOMAIN, f"missing_address_{entry.entry_id}" + ) + with patch( + "homeassistant.components.mitsubishi_comfort.repairs.probe_candidate_ips", + side_effect=_probe_first_only, + ): + data = await process_repair_fix_flow( + client, + data["flow_id"], + json={ + dr.format_mac(MOCK_MAC): "192.168.1.50", + second_mac: "192.168.1.60", + }, + ) + + assert data["errors"] == {second_mac: "cannot_connect"} + assert not entry.data.get(CONF_ADDRESSES) + + +@pytest.mark.parametrize("ignore_missing_translations", [IGNORE_FORM_TRANSLATIONS]) +async def test_fix_flow_concurrent_lease_wins_over_entry( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test a lease DHCP stored during the probe beats the form entry. + + Live discovery saw the device after the user typed the address, so the + stored lease is the fresher fact for the same MAC. + """ + entry = await _setup_addressless_entry(hass) + mac = dr.format_mac(MOCK_MAC) + + async def _probe_with_concurrent_lease( + *args: object, **kwargs: object + ) -> dict[str, str]: + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_ADDRESSES: {mac: "192.168.1.99"}} + ) + return {MOCK_SERIAL: "192.168.1.50"} + + client = await hass_client() + data = await start_repair_fix_flow( + client, DOMAIN, f"missing_address_{entry.entry_id}" + ) + with patch( + "homeassistant.components.mitsubishi_comfort.repairs.probe_candidate_ips", + side_effect=_probe_with_concurrent_lease, + ): + data = await process_repair_fix_flow( + client, data["flow_id"], json={mac: "192.168.1.50"} + ) + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + assert entry.data[CONF_ADDRESSES][mac] == "192.168.1.99" + + +@pytest.mark.parametrize("ignore_missing_translations", [IGNORE_FORM_TRANSLATIONS]) +async def test_fix_flow_omits_devices_without_secrets( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_setup_integration: tuple[AsyncMock, MagicMock], + mock_device_info: DeviceInfo, +) -> None: + """Test the form skips devices whose local secrets are missing. + + A partial record can hold a MAC with no password or cryptoSerial; any + address entered for it is guaranteed to fail the authenticated probe, + and setup counts that device as incomplete rather than addressless. + """ + secretless = DeviceInfo( + serial="SERIAL002", + label="Bedroom", + address="", + mac="11:22:33:44:55:66", + unit_type="ductless", + password="", + crypto_serial="", + ) + mock_account, _ = mock_setup_integration + mock_account.discover_devices.return_value = { + "SERIAL001": mock_device_info, + "SERIAL002": secretless, + } + entry = await _setup_addressless_entry(hass) + + client = await hass_client() + data = await start_repair_fix_flow( + client, DOMAIN, f"missing_address_{entry.entry_id}" + ) + + assert data["step_id"] == "addresses" + assert [field["name"] for field in data["data_schema"]] == [dr.format_mac(MOCK_MAC)] + + +@pytest.mark.parametrize("ignore_missing_translations", [IGNORE_FORM_TRANSLATIONS]) +async def test_fix_flow_omits_devices_no_longer_on_account( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the form skips registry devices the account no longer has. + + Setup prunes the credential cache but leaves old device registry entries, + so the form intersects with the cache to ask only for current devices. + """ + entry = await _setup_addressless_entry(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "REMOVED01")}, + connections={(dr.CONNECTION_NETWORK_MAC, "99:99:99:99:99:99")}, + ) + + client = await hass_client() + data = await start_repair_fix_flow( + client, DOMAIN, f"missing_address_{entry.entry_id}" + ) + + assert data["step_id"] == "addresses" + assert [field["name"] for field in data["data_schema"]] == [dr.format_mac(MOCK_MAC)] + assert "99:99:99:99:99:99" not in data["description_placeholders"]["devices"] + + +async def test_fix_flow_without_entry_falls_back_to_confirm( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, +) -> None: + """Test an issue whose entry no longer exists gets a confirm flow.""" + assert await async_setup_component(hass, "repairs", {}) + ir.async_create_issue( + hass, + DOMAIN, + "missing_address_gone", + is_fixable=True, + severity=ir.IssueSeverity.WARNING, + translation_key="missing_address", + data={"entry_id": "nonexistent"}, + ) + + client = await hass_client() + data = await start_repair_fix_flow(client, DOMAIN, "missing_address_gone") + assert data["step_id"] == "confirm" + + data = await process_repair_fix_flow(client, data["flow_id"], json={}) + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + assert not issue_registry.async_get_issue(DOMAIN, "missing_address_gone") diff --git a/tests/components/modern_forms/__init__.py b/tests/components/modern_forms/__init__.py index 0ff574b6ac605d..5bcf9e627faaf6 100644 --- a/tests/components/modern_forms/__init__.py +++ b/tests/components/modern_forms/__init__.py @@ -2,16 +2,16 @@ from collections.abc import Callable, Coroutine from functools import partial -import json from typing import Any from aiomodernforms.const import COMMAND_QUERY_STATIC_DATA +from yarl import URL from homeassistant.components.modern_forms.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_MAC, CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, async_load_fixture +from tests.common import MockConfigEntry, async_load_json_object_fixture from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse @@ -26,7 +26,7 @@ async def modern_forms_call_mock( return AiohttpClientMockResponse( method=method, url=url, - json=json.loads(await async_load_fixture(hass, fixture, DOMAIN)), + json=await async_load_json_object_fixture(hass, fixture, DOMAIN), ) @@ -41,7 +41,7 @@ async def modern_forms_no_light_call_mock( return AiohttpClientMockResponse( method=method, url=url, - json=json.loads(await async_load_fixture(hass, fixture, DOMAIN)), + json=await async_load_json_object_fixture(hass, fixture, DOMAIN), ) @@ -56,7 +56,7 @@ async def modern_forms_timers_set_mock( return AiohttpClientMockResponse( method=method, url=url, - json=json.loads(await async_load_fixture(hass, fixture, DOMAIN)), + json=await async_load_json_object_fixture(hass, fixture, DOMAIN), ) @@ -71,7 +71,7 @@ async def modern_forms_breeze_call_mock( return AiohttpClientMockResponse( method=method, url=url, - json=json.loads(await async_load_fixture(hass, fixture, DOMAIN)), + json=await async_load_json_object_fixture(hass, fixture, DOMAIN), ) @@ -86,7 +86,7 @@ async def modern_forms_breeze_active_call_mock( return AiohttpClientMockResponse( method=method, url=url, - json=json.loads(await async_load_fixture(hass, fixture, DOMAIN)), + json=await async_load_json_object_fixture(hass, fixture, DOMAIN), ) @@ -120,3 +120,52 @@ async def init_integration( await hass.async_block_till_done() return entry + + +async def modern_forms_gen4_call_mock( + hass: HomeAssistant, method: str, url: URL, data: dict[str, Any] +) -> AiohttpClientMockResponse: + """Route Gen4 /device and /fixture requests to their fixtures.""" + fixture = ( + "device_gen4.json" if url.path.endswith("/device") else "fixture_gen4.json" + ) + return AiohttpClientMockResponse( + method=method, + url=url, + json=await async_load_json_object_fixture(hass, fixture, DOMAIN), + ) + + +async def init_integration_gen4( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + skip_setup: bool = False, + mock_type: Callable[ + [HomeAssistant, str, URL, dict[str, Any]], + Coroutine[Any, Any, AiohttpClientMockResponse], + ] = modern_forms_gen4_call_mock, +) -> MockConfigEntry: + """Set up the Modern Forms integration against a mock Gen4 device.""" + aioclient_mock.post("http://192.168.1.123:80/mf", text="", status=404) + aioclient_mock.post( + "http://192.168.1.123:80/device", + side_effect=partial(mock_type, hass), + ) + aioclient_mock.post( + "http://192.168.1.123:80/fixture", + side_effect=partial(mock_type, hass), + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.123", CONF_MAC: "AA:BB:CC:00:11:22"}, + unique_id="AA:BB:CC:00:11:22", + ) + + entry.add_to_hass(hass) + + if not skip_setup: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/modern_forms/fixtures/device_gen4.json b/tests/components/modern_forms/fixtures/device_gen4.json new file mode 100644 index 00000000000000..d6b45e83164352 --- /dev/null +++ b/tests/components/modern_forms/fixtures/device_gen4.json @@ -0,0 +1,8 @@ +{ + "systemType": "FAN_G4", + "deviceName": "ModernFormsFan", + "iotmVer": "01.03.0025", + "scmVer": "01.03.3008", + "owner": "someone@somewhere.com", + "staMac": "AA:BB:CC:00:11:22" +} diff --git a/tests/components/modern_forms/fixtures/fixture_gen4.json b/tests/components/modern_forms/fixtures/fixture_gen4.json new file mode 100644 index 00000000000000..365981796dae78 --- /dev/null +++ b/tests/components/modern_forms/fixtures/fixture_gen4.json @@ -0,0 +1,31 @@ +{ + "fixture": [ + { + "addr": 1, + "type": 13, + "name": "Fan", + "detail": { "model": "2603-56" }, + "state": { + "status": true, + "fanSpeed": 3, + "fanDirection": false, + "wind": false, + "windSpeed": 3 + } + }, + { + "addr": 2, + "type": 0, + "name": "ModernFormsFan Uplight", + "detail": { "minColorTemp": 2700, "maxColorTemp": 5000 }, + "state": { "status": true, "level": 8000, "mixColorTemp": 3000 } + }, + { + "addr": 3, + "type": 0, + "name": "ModernFormsFan Downlight", + "detail": { "minColorTemp": 2700, "maxColorTemp": 5000 }, + "state": { "status": false, "level": 5000, "mixColorTemp": 4000 } + } + ] +} diff --git a/tests/components/modern_forms/snapshots/test_diagnostics.ambr b/tests/components/modern_forms/snapshots/test_diagnostics.ambr index 051a3527527449..25c431c2867f71 100644 --- a/tests/components/modern_forms/snapshots/test_diagnostics.ambr +++ b/tests/components/modern_forms/snapshots/test_diagnostics.ambr @@ -77,3 +77,91 @@ }), }) # --- +# name: test_entry_diagnostics_gen4 + dict({ + 'config_entry': dict({ + 'data': dict({ + 'host': '192.168.1.123', + 'mac': '**REDACTED**', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'modern_forms', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Mock Title', + 'unique_id': 'AA:BB:CC:00:11:22', + 'version': 1, + }), + 'device': dict({ + 'info': dict({ + 'brand': None, + 'client_id': '', + 'date_code': '', + 'device_name': 'ModernFormsFan', + 'fan_motor_type': '', + 'fan_type': '2603-56', + 'federated_identity': '', + 'firmware_url': '', + 'firmware_version': '01.03.0025', + 'light_type': 'gen4', + 'mac_address': '**REDACTED**', + 'main_mcu_firmware_version': '01.03.3008', + 'owner': '**REDACTED**', + 'product_sku': '', + 'production_lot_number': '', + }), + 'status': dict({ + 'adaptive_learning_enabled': False, + 'away_mode_enabled': False, + 'decommission': False, + 'factory_reset': False, + 'fan_direction': 'forward', + 'fan_on': True, + 'fan_sleep_timer': 0, + 'fan_speed': 3, + 'fan_timer': None, + 'light_brightness': 80, + 'light_color_temp_kelvin': 3000, + 'light_fixtures': list([ + dict({ + 'address': 2, + 'brightness': 80, + 'color_temp_kelvin': 3000, + 'fixture_type': 0, + 'max_color_temp_kelvin': 5000, + 'min_color_temp_kelvin': 2700, + 'name': '**REDACTED**', + 'on': True, + }), + dict({ + 'address': 3, + 'brightness': 50, + 'color_temp_kelvin': 4000, + 'fixture_type': 0, + 'max_color_temp_kelvin': 5000, + 'min_color_temp_kelvin': 2700, + 'name': '**REDACTED**', + 'on': False, + }), + ]), + 'light_on': True, + 'light_sleep_timer': 0, + 'light_timer': None, + 'reset_rf_pair_list': False, + 'rf_pair_mode_active': False, + 'schedule': '', + 'user_data': '', + 'wind': False, + 'wind_speed': 3, + }), + }), + }) +# --- diff --git a/tests/components/modern_forms/test_binary_sensor.py b/tests/components/modern_forms/test_binary_sensor.py index a605b86d484420..9717c507b842a2 100644 --- a/tests/components/modern_forms/test_binary_sensor.py +++ b/tests/components/modern_forms/test_binary_sensor.py @@ -5,7 +5,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import init_integration +from . import init_integration, init_integration_gen4 from tests.test_util.aiohttp import AiohttpClientMocker @@ -43,3 +43,26 @@ async def test_binary_sensors( state = hass.states.get("binary_sensor.modernformsfan_fan_sleep_timer_active") assert state assert state.state == "off" + + +async def test_no_sleep_timer_binary_sensors_on_gen4( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test the sleep-timer binary sensors aren't created for Gen4 fans.""" + await init_integration_gen4(hass, aioclient_mock) + + # Both entities are disabled by default, so checking hass.states here + # wouldn't distinguish "not created" from "created but disabled" -- + # check the entity registry instead. + assert ( + entity_registry.async_get("binary_sensor.modernformsfan_fan_sleep_timer_active") + is None + ) + assert ( + entity_registry.async_get( + "binary_sensor.modernformsfan_light_sleep_timer_active" + ) + is None + ) diff --git a/tests/components/modern_forms/test_button.py b/tests/components/modern_forms/test_button.py new file mode 100644 index 00000000000000..c28bedb1aa0a68 --- /dev/null +++ b/tests/components/modern_forms/test_button.py @@ -0,0 +1,59 @@ +"""Tests for the Modern Forms button platform.""" + +from unittest.mock import patch + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration, init_integration_gen4 + +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_restart_button( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test the creation of the restart button on a legacy fan.""" + await init_integration(hass, aioclient_mock) + + state = hass.states.get("button.modernformsfan_restart") + assert state + entry = entity_registry.async_get("button.modernformsfan_restart") + assert entry + assert entry.unique_id == "AA:BB:CC:DD:EE:FF_restart" + + +async def test_restart_button_gen4( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test the creation of the restart button on a Gen4 fan.""" + await init_integration_gen4(hass, aioclient_mock) + + state = hass.states.get("button.modernformsfan_restart") + assert state + entry = entity_registry.async_get("button.modernformsfan_restart") + assert entry + assert entry.unique_id == "AA:BB:CC:00:11:22_restart" + + +async def test_restart_button_press( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test pressing the restart button.""" + await init_integration(hass, aioclient_mock) + + with patch("aiomodernforms.ModernFormsDevice.reboot") as reboot_mock: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.modernformsfan_restart"}, + blocking=True, + ) + await hass.async_block_till_done() + reboot_mock.assert_called_once_with() diff --git a/tests/components/modern_forms/test_config_flow.py b/tests/components/modern_forms/test_config_flow.py index 7e63574d99aae2..f3353fea9190f8 100644 --- a/tests/components/modern_forms/test_config_flow.py +++ b/tests/components/modern_forms/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for the Modern Forms config flow.""" +from functools import partial from ipaddress import ip_address from unittest.mock import MagicMock, patch @@ -13,7 +14,7 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from . import init_integration +from . import init_integration, modern_forms_gen4_call_mock from tests.common import async_load_fixture from tests.test_util.aiohttp import AiohttpClientMocker @@ -233,3 +234,36 @@ async def test_zeroconf_with_mac_device_exists_abort( assert result.get("type") is FlowResultType.ABORT assert result.get("reason") == "already_configured" + + +async def test_full_user_flow_gen4( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the user flow successfully adds a Gen4 fan.""" + aioclient_mock.post("http://192.168.1.123:80/mf", text="", status=404) + aioclient_mock.post( + "http://192.168.1.123:80/device", + side_effect=partial(modern_forms_gen4_call_mock, hass), + ) + aioclient_mock.post( + "http://192.168.1.123:80/fixture", + side_effect=partial(modern_forms_gen4_call_mock, hass), + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + with patch( + "homeassistant.components.modern_forms.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "192.168.1.123"} + ) + + assert result2.get("type") is FlowResultType.CREATE_ENTRY + assert result2.get("title") == "ModernFormsFan" + assert result2["data"][CONF_HOST] == "192.168.1.123" + assert result2["data"][CONF_MAC] == "AA:BB:CC:00:11:22" diff --git a/tests/components/modern_forms/test_diagnostics.py b/tests/components/modern_forms/test_diagnostics.py index 10a4c8385fabc3..c0d79d8bcab738 100644 --- a/tests/components/modern_forms/test_diagnostics.py +++ b/tests/components/modern_forms/test_diagnostics.py @@ -5,7 +5,7 @@ from homeassistant.core import HomeAssistant -from . import init_integration +from . import init_integration, init_integration_gen4 from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.test_util.aiohttp import AiohttpClientMocker @@ -24,3 +24,21 @@ async def test_entry_diagnostics( result = await get_diagnostics_for_config_entry(hass, hass_client, entry) assert result == snapshot(exclude=props("created_at", "modified_at", "entry_id")) + + +async def test_entry_diagnostics_gen4( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test Gen4 fixture names are redacted from diagnostics.""" + entry = await init_integration_gen4(hass, aioclient_mock) + + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + fixture_names = [ + fixture["name"] for fixture in result["device"]["status"]["light_fixtures"] + ] + assert fixture_names == ["**REDACTED**", "**REDACTED**"] + assert result == snapshot(exclude=props("created_at", "modified_at", "entry_id")) diff --git a/tests/components/modern_forms/test_fan.py b/tests/components/modern_forms/test_fan.py index f81ce39dba8138..8400cc6ad824b6 100644 --- a/tests/components/modern_forms/test_fan.py +++ b/tests/components/modern_forms/test_fan.py @@ -43,6 +43,7 @@ from . import ( init_integration, + init_integration_gen4, modern_forms_breeze_active_call_mock, modern_forms_breeze_call_mock, ) @@ -395,3 +396,41 @@ async def test_turn_off_does_not_touch_wind( ) await hass.async_block_till_done() fan_mock.assert_called_once_with(on=False) + + +async def test_fan_sleep_timer_not_supported_gen4( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test setting a sleep timer on a Gen4 fan raises an error.""" + await init_integration_gen4(hass, aioclient_mock) + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_FAN_SLEEP_TIMER, + {ATTR_ENTITY_ID: "fan.modernformsfan_fan", ATTR_SLEEP_TIME: 1}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "sleep_timer_not_supported" + + +async def test_clear_fan_sleep_timer_not_supported_gen4( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test clearing a sleep timer on a Gen4 fan raises an error.""" + await init_integration_gen4(hass, aioclient_mock) + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_CLEAR_FAN_SLEEP_TIMER, + {ATTR_ENTITY_ID: "fan.modernformsfan_fan"}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "sleep_timer_not_supported" diff --git a/tests/components/modern_forms/test_light.py b/tests/components/modern_forms/test_light.py index 03ab3af607c991..3614d1d2f1c06f 100644 --- a/tests/components/modern_forms/test_light.py +++ b/tests/components/modern_forms/test_light.py @@ -1,11 +1,21 @@ """Tests for the Modern Forms light platform.""" +from typing import Any from unittest.mock import patch from aiomodernforms import ModernFormsConnectionError import pytest +from yarl import URL -from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, + ATTR_SUPPORTED_COLOR_MODES, + DOMAIN as LIGHT_DOMAIN, + ColorMode, +) from homeassistant.components.modern_forms.const import ( ATTR_SLEEP_TIME, DOMAIN, @@ -17,6 +27,7 @@ ATTR_FRIENDLY_NAME, SERVICE_TURN_OFF, SERVICE_TURN_ON, + STATE_OFF, STATE_ON, STATE_UNAVAILABLE, ) @@ -24,9 +35,10 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import init_integration +from . import init_integration, init_integration_gen4, modern_forms_gen4_call_mock -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import async_load_json_object_fixture +from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse async def test_light_state( @@ -164,3 +176,264 @@ async def test_light_connection_error( state = hass.states.get("light.modernformsfan_light") assert state.state == STATE_UNAVAILABLE + + +async def test_light_state_gen4( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test a multi-fixture Gen4 fan creates one light entity per fixture.""" + await init_integration_gen4(hass, aioclient_mock) + + state = hass.states.get("light.modernformsfan_uplight") + assert state + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "ModernFormsFan Uplight" + assert state.state == STATE_ON + assert state.attributes.get(ATTR_BRIGHTNESS) == 204 + + entry = entity_registry.async_get("light.modernformsfan_uplight") + assert entry + assert entry.unique_id == "AA:BB:CC:00:11:22_2" + + state = hass.states.get("light.modernformsfan_downlight") + assert state + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "ModernFormsFan Downlight" + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_BRIGHTNESS) is None + + entry = entity_registry.async_get("light.modernformsfan_downlight") + assert entry + assert entry.unique_id == "AA:BB:CC:00:11:22_3" + + +async def test_light_name_requires_word_boundary_gen4( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test a fixture name isn't stripped without a real device-name boundary.""" + + async def partial_word_name_mock( + hass: HomeAssistant, method: str, url: URL, data: dict[str, Any] + ) -> AiohttpClientMockResponse: + """Serve the normal Gen4 fixtures, with the uplight renamed.""" + if not url.path.endswith("/fixture"): + return await modern_forms_gen4_call_mock(hass, method, url, data) + payload = await async_load_json_object_fixture( + hass, "fixture_gen4.json", DOMAIN + ) + for fixture in payload["fixture"]: + if fixture["addr"] == 2: + fixture["name"] = "ModernFormsFancy Light" + return AiohttpClientMockResponse(method=method, url=url, json=payload) + + await init_integration_gen4(hass, aioclient_mock, mock_type=partial_word_name_mock) + + entity_id = entity_registry.async_get_entity_id( + LIGHT_DOMAIN, DOMAIN, "AA:BB:CC:00:11:22_2" + ) + assert entity_id + state = hass.states.get(entity_id) + assert state + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "ModernFormsFan ModernFormsFancy Light" + ) + + +async def test_light_name_falls_back_to_device_name_gen4( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test a fixture named exactly like the device falls back to that name alone.""" + + async def exact_match_name_mock( + hass: HomeAssistant, method: str, url: URL, data: dict[str, Any] + ) -> AiohttpClientMockResponse: + """Serve the normal Gen4 fixtures, with the uplight renamed.""" + if not url.path.endswith("/fixture"): + return await modern_forms_gen4_call_mock(hass, method, url, data) + payload = await async_load_json_object_fixture( + hass, "fixture_gen4.json", DOMAIN + ) + for fixture in payload["fixture"]: + if fixture["addr"] == 2: + fixture["name"] = "ModernFormsFan" + return AiohttpClientMockResponse(method=method, url=url, json=payload) + + await init_integration_gen4(hass, aioclient_mock, mock_type=exact_match_name_mock) + + entity_id = entity_registry.async_get_entity_id( + LIGHT_DOMAIN, DOMAIN, "AA:BB:CC:00:11:22_2" + ) + assert entity_id + state = hass.states.get(entity_id) + assert state + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "ModernFormsFan" + + +async def test_light_unavailable_when_fixture_disappears_gen4( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test a Gen4 light entity goes unavailable if its fixture disappears.""" + removed_addresses: set[int] = set() + + async def fixture_removal_mock( + hass: HomeAssistant, method: str, url: URL, data: dict[str, Any] + ) -> AiohttpClientMockResponse: + """Serve the normal Gen4 fixtures, minus any addresses removed.""" + if not url.path.endswith("/fixture") or not removed_addresses: + return await modern_forms_gen4_call_mock(hass, method, url, data) + payload = await async_load_json_object_fixture( + hass, "fixture_gen4.json", DOMAIN + ) + payload["fixture"] = [ + fixture + for fixture in payload["fixture"] + if fixture["addr"] not in removed_addresses + ] + return AiohttpClientMockResponse(method=method, url=url, json=payload) + + entry = await init_integration_gen4( + hass, aioclient_mock, mock_type=fixture_removal_mock + ) + + state = hass.states.get("light.modernformsfan_uplight") + assert state + assert state.state == STATE_ON + + removed_addresses.add(2) + await entry.runtime_data.async_refresh() + await hass.async_block_till_done() + + state = hass.states.get("light.modernformsfan_uplight") + assert state + assert state.state == STATE_UNAVAILABLE + + +async def test_light_change_state_gen4( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test Gen4 fixture entities control via light_fixture(), not light().""" + await init_integration_gen4(hass, aioclient_mock) + + with ( + patch("aiomodernforms.ModernFormsDevice.light_fixture") as light_fixture_mock, + patch("aiomodernforms.ModernFormsDevice.light") as light_mock, + ): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.modernformsfan_uplight"}, + blocking=True, + ) + await hass.async_block_till_done() + light_fixture_mock.assert_called_once_with(2, on=False) + light_mock.assert_not_called() + + +async def test_light_sleep_timer_not_supported_gen4( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test setting a sleep timer on a Gen4 light fixture raises an error.""" + await init_integration_gen4(hass, aioclient_mock) + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_LIGHT_SLEEP_TIMER, + {ATTR_ENTITY_ID: "light.modernformsfan_uplight", ATTR_SLEEP_TIME: 1}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "sleep_timer_not_supported" + + +async def test_clear_light_sleep_timer_not_supported_gen4( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test clearing a sleep timer on a Gen4 light fixture raises an error.""" + await init_integration_gen4(hass, aioclient_mock) + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_CLEAR_LIGHT_SLEEP_TIMER, + {ATTR_ENTITY_ID: "light.modernformsfan_uplight"}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "sleep_timer_not_supported" + + +async def test_light_color_temp_gen4( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test Gen4 light fixtures advertise and control color temperature.""" + await init_integration_gen4(hass, aioclient_mock) + + state = hass.states.get("light.modernformsfan_uplight") + assert state + assert state.attributes.get(ATTR_COLOR_TEMP_KELVIN) == 3000 + assert state.attributes.get(ATTR_MIN_COLOR_TEMP_KELVIN) == 2700 + assert state.attributes.get(ATTR_MAX_COLOR_TEMP_KELVIN) == 5000 + assert state.attributes.get(ATTR_SUPPORTED_COLOR_MODES) == [ColorMode.COLOR_TEMP] + + with patch("aiomodernforms.ModernFormsDevice.light_fixture") as light_fixture_mock: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.modernformsfan_uplight", + ATTR_COLOR_TEMP_KELVIN: 4000, + }, + blocking=True, + ) + await hass.async_block_till_done() + light_fixture_mock.assert_called_once_with(2, on=True, color_temp_kelvin=4000) + + +async def test_light_color_temp_missing_bounds_gen4( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test a Gen4 fixture missing color-temp bounds falls back to brightness.""" + + async def missing_bounds_mock( + hass: HomeAssistant, method: str, url: URL, data: dict[str, Any] + ) -> AiohttpClientMockResponse: + """Serve the normal Gen4 fixtures, minus the downlight's color-temp bounds.""" + if not url.path.endswith("/fixture"): + return await modern_forms_gen4_call_mock(hass, method, url, data) + payload = await async_load_json_object_fixture( + hass, "fixture_gen4.json", DOMAIN + ) + for fixture in payload["fixture"]: + if fixture["addr"] == 3: + fixture["detail"] = {} + return AiohttpClientMockResponse(method=method, url=url, json=payload) + + await init_integration_gen4(hass, aioclient_mock, mock_type=missing_bounds_mock) + + state = hass.states.get("light.modernformsfan_downlight") + assert state + assert state.attributes.get(ATTR_SUPPORTED_COLOR_MODES) == [ColorMode.BRIGHTNESS] + assert state.attributes.get(ATTR_COLOR_TEMP_KELVIN) is None + + +async def test_light_no_color_temp_on_legacy( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test Gen 1/2/3 lights never advertise color temperature.""" + await init_integration(hass, aioclient_mock) + + state = hass.states.get("light.modernformsfan_light") + assert state + assert state.attributes.get(ATTR_SUPPORTED_COLOR_MODES) == [ColorMode.BRIGHTNESS] diff --git a/tests/components/modern_forms/test_sensor.py b/tests/components/modern_forms/test_sensor.py index 9058808443efc2..65f5370dc94921 100644 --- a/tests/components/modern_forms/test_sensor.py +++ b/tests/components/modern_forms/test_sensor.py @@ -6,7 +6,7 @@ from homeassistant.const import ATTR_DEVICE_CLASS from homeassistant.core import HomeAssistant -from . import init_integration, modern_forms_timers_set_mock +from . import init_integration, init_integration_gen4, modern_forms_timers_set_mock from tests.test_util.aiohttp import AiohttpClientMocker @@ -51,3 +51,14 @@ async def test_active_sensors( assert state assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP datetime.fromisoformat(state.state) + + +async def test_no_sleep_timer_sensors_on_gen4( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test the sleep-timer sensors aren't created for Gen4 fans.""" + await init_integration_gen4(hass, aioclient_mock) + + assert hass.states.get("sensor.modernformsfan_fan_sleep_time") is None + assert hass.states.get("sensor.modernformsfan_light_sleep_time") is None diff --git a/tests/components/modern_forms/test_switch.py b/tests/components/modern_forms/test_switch.py index 3ba6305dfcff5b..cd80778cd6cf9a 100644 --- a/tests/components/modern_forms/test_switch.py +++ b/tests/components/modern_forms/test_switch.py @@ -18,7 +18,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import init_integration +from . import init_integration, init_integration_gen4 from tests.test_util.aiohttp import AiohttpClientMocker @@ -156,3 +156,14 @@ async def test_switch_connection_error( state = hass.states.get("switch.modernformsfan_away_mode") assert state.state == STATE_UNAVAILABLE + + +async def test_no_adaptive_learning_switch_on_gen4( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test the adaptive learning switch isn't created for Gen4 fans.""" + await init_integration_gen4(hass, aioclient_mock) + + assert hass.states.get("switch.modernformsfan_adaptive_learning") is None + assert hass.states.get("switch.modernformsfan_away_mode") is not None diff --git a/tests/components/monarch_money/conftest.py b/tests/components/monarch_money/conftest.py index 7d6a965a0090d2..dfaddcb1df0fb1 100644 --- a/tests/components/monarch_money/conftest.py +++ b/tests/components/monarch_money/conftest.py @@ -1,7 +1,6 @@ """Common fixtures for the Monarch Money tests.""" from collections.abc import Generator -import json from typing import Any from unittest.mock import AsyncMock, PropertyMock, patch @@ -15,7 +14,7 @@ from homeassistant.components.monarch_money.const import DOMAIN from homeassistant.const import CONF_TOKEN -from tests.common import MockConfigEntry, load_fixture, load_json_object_fixture +from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture @@ -48,12 +47,12 @@ def mock_config_api() -> Generator[AsyncMock]: acc["id"]: MonarchAccount(acc) for acc in account_json["accounts"] } - cashflow_json: dict[str, Any] = json.loads( - load_fixture("get_cashflow_summary.json", DOMAIN) + cashflow_json: dict[str, Any] = load_json_object_fixture( + "get_cashflow_summary.json", DOMAIN ) cashflow_summary = MonarchCashflowSummary(cashflow_json) subscription_details = MonarchSubscription( - json.loads(load_fixture("get_subscription_details.json", DOMAIN)) + load_json_object_fixture("get_subscription_details.json", DOMAIN) ) with ( diff --git a/tests/components/mysensors/conftest.py b/tests/components/mysensors/conftest.py index a7a2e5dda869ac..189d525c849072 100644 --- a/tests/components/mysensors/conftest.py +++ b/tests/components/mysensors/conftest.py @@ -198,9 +198,8 @@ def gateway_fixture( def load_nodes_state(fixture_path: str) -> dict: """Load mysensors nodes fixture.""" - return json.loads( - load_fixture(fixture_path, integration=DOMAIN), cls=MySensorsJSONDecoder - ) + fixture = load_fixture(fixture_path, integration=DOMAIN) + return json.loads(fixture, cls=MySensorsJSONDecoder) def update_gateway_nodes( diff --git a/tests/components/netatmo/common.py b/tests/components/netatmo/common.py index b0f8550cd35179..31f046c4624106 100644 --- a/tests/components/netatmo/common.py +++ b/tests/components/netatmo/common.py @@ -15,7 +15,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.util.aiohttp import MockRequest -from tests.common import MockConfigEntry, async_load_fixture +from tests.common import MockConfigEntry, async_load_json_object_fixture from tests.test_util.aiohttp import AiohttpClientMockResponse HOME_ID = "91763b24c43d3e344f424e8b" @@ -78,12 +78,12 @@ async def fake_post_request(hass: HomeAssistant, *args: Any, **kwargs: Any): elif endpoint == "homestatus": home_id = kwargs.get("params", {}).get("home_id") - payload = json.loads( - await async_load_fixture(hass, f"{endpoint}_{home_id}.json", DOMAIN) + payload = await async_load_json_object_fixture( + hass, f"{endpoint}_{home_id}.json", DOMAIN ) else: - payload = json.loads(await async_load_fixture(hass, f"{endpoint}.json", DOMAIN)) + payload = await async_load_json_object_fixture(hass, f"{endpoint}.json", DOMAIN) # Apply test-specific modifications to the payload if "msg_callback" in kwargs: diff --git a/tests/components/nextbus/const.py b/tests/components/nextbus/const.py index 66eb3635ca92de..b76af217adf0b3 100644 --- a/tests/components/nextbus/const.py +++ b/tests/components/nextbus/const.py @@ -7,7 +7,6 @@ VALID_AGENCY = "sfmta-cis" VALID_ROUTE = "F" VALID_STOP = "5184" -VALID_COORDINATOR_KEY = f"{VALID_AGENCY}-{VALID_STOP}" VALID_AGENCY_TITLE = "San Francisco Muni" VALID_ROUTE_TITLE = "F-Market & Wharves" VALID_STOP_TITLE = "Market St & 7th St" diff --git a/tests/components/nextbus/test_sensor.py b/tests/components/nextbus/test_sensor.py index faba9c7c8f9dc1..0f84b7c9df8daf 100644 --- a/tests/components/nextbus/test_sensor.py +++ b/tests/components/nextbus/test_sensor.py @@ -9,12 +9,14 @@ from py_nextbus.client import NextBusFormatError, NextBusHTTPError import pytest -from homeassistant.components.nextbus.const import DOMAIN +from homeassistant.components.nextbus import NEXTBUS_KEY +from homeassistant.components.nextbus.const import CONF_AGENCY, CONF_ROUTE, DOMAIN from homeassistant.components.nextbus.coordinator import NextBusDataUpdateCoordinator from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, CONF_STOP from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import UpdateFailed +from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from . import assert_setup_sensor @@ -27,12 +29,12 @@ SENSOR_ID, SENSOR_ID_2, VALID_AGENCY, - VALID_COORDINATOR_KEY, + VALID_AGENCY_TITLE, VALID_ROUTE_TITLE, VALID_STOP_TITLE, ) -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed async def test_predictions( @@ -69,8 +71,8 @@ async def test_prediction_exceptions( client_exception: Exception, ) -> None: """Test that some coodinator exceptions raise UpdateFailed exceptions.""" - await assert_setup_sensor(hass, CONFIG_BASIC) - coordinator: NextBusDataUpdateCoordinator = hass.data[DOMAIN][VALID_COORDINATOR_KEY] + entry = await assert_setup_sensor(hass, CONFIG_BASIC) + coordinator: NextBusDataUpdateCoordinator = entry.runtime_data mock_nextbus_predictions.side_effect = client_exception with pytest.raises(UpdateFailed): await coordinator._async_update_data() @@ -175,6 +177,39 @@ async def test_verify_throttle( assert state.state == "unknown" +async def test_concurrent_setup_shares_coordinator( + hass: HomeAssistant, + mock_nextbus: MagicMock, + mock_nextbus_lists: MagicMock, + mock_nextbus_predictions: MagicMock, +) -> None: + """Test that two entries set up concurrently share one coordinator.""" + entries = [] + for config, route_title in ( + (CONFIG_BASIC, VALID_ROUTE_TITLE), + (CONFIG_BASIC_2, ROUTE_TITLE_2), + ): + entry = MockConfigEntry( + domain=DOMAIN, + data=config[DOMAIN], + title=f"{VALID_AGENCY_TITLE} {route_title} {VALID_STOP_TITLE}", + unique_id=( + f"{config[DOMAIN][CONF_AGENCY]}" + f"_{config[DOMAIN][CONF_ROUTE]}" + f"_{config[DOMAIN][CONF_STOP]}" + ), + ) + entry.add_to_hass(hass) + entries.append(entry) + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + assert entries[0].state is ConfigEntryState.LOADED + assert entries[1].state is ConfigEntryState.LOADED + assert entries[0].runtime_data is entries[1].runtime_data + + async def test_unload_entry( hass: HomeAssistant, mock_nextbus: MagicMock, @@ -184,7 +219,11 @@ async def test_unload_entry( ) -> None: """Test that the sensor can be unloaded.""" config_entry1 = await assert_setup_sensor(hass, CONFIG_BASIC) - await assert_setup_sensor(hass, CONFIG_BASIC_2, route_title=ROUTE_TITLE_2) + config_entry2 = await assert_setup_sensor( + hass, CONFIG_BASIC_2, route_title=ROUTE_TITLE_2 + ) + + assert config_entry1.runtime_data is config_entry2.runtime_data # Verify the first sensor state = hass.states.get(SENSOR_ID) @@ -224,3 +263,27 @@ async def test_unload_entry( assert state is not None assert state.attributes["upcoming"] == "5" assert state.state == "2019-03-28T21:09:35+00:00" + + +async def test_unload_final_entry_cleans_up_shared_coordinator( + hass: HomeAssistant, + mock_nextbus: MagicMock, + mock_nextbus_lists: MagicMock, + mock_nextbus_predictions: MagicMock, +) -> None: + """Test that unloading the final entry shuts down the shared coordinator.""" + config_entry1 = await assert_setup_sensor(hass, CONFIG_BASIC) + config_entry2 = await assert_setup_sensor( + hass, CONFIG_BASIC_2, route_title=ROUTE_TITLE_2 + ) + coordinator: NextBusDataUpdateCoordinator = config_entry1.runtime_data + + await hass.config_entries.async_unload(config_entry1.entry_id) + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry2.entry_id) + await hass.async_block_till_done() + + assert config_entry1.state is ConfigEntryState.NOT_LOADED + assert config_entry2.state is ConfigEntryState.NOT_LOADED + assert coordinator._shutdown_requested + assert hass.data[NEXTBUS_KEY] == {} diff --git a/tests/components/nordpool/test_services.py b/tests/components/nordpool/test_services.py index 0b152a8fb4083d..1bd747cd52dade 100644 --- a/tests/components/nordpool/test_services.py +++ b/tests/components/nordpool/test_services.py @@ -1,6 +1,5 @@ """Test services in Nord Pool.""" -import json from typing import Any from unittest.mock import patch @@ -26,7 +25,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from tests.common import MockConfigEntry, async_load_fixture +from tests.common import MockConfigEntry, async_load_json_object_fixture from tests.test_util.aiohttp import AiohttpClientMocker TEST_SERVICE_DATA = { @@ -228,8 +227,8 @@ async def test_service_call_for_price_indices( ) -> None: """Test get_price_indices_for_date service call.""" - fixture_60 = json.loads(await async_load_fixture(hass, "indices_60.json", DOMAIN)) - fixture_15 = json.loads(await async_load_fixture(hass, "indices_15.json", DOMAIN)) + fixture_60 = await async_load_json_object_fixture(hass, "indices_60.json", DOMAIN) + fixture_15 = await async_load_json_object_fixture(hass, "indices_15.json", DOMAIN) aioclient_mock.request( "GET", diff --git a/tests/components/notion/conftest.py b/tests/components/notion/conftest.py index 24b8a46bf7c271..ec51d3260c7398 100644 --- a/tests/components/notion/conftest.py +++ b/tests/components/notion/conftest.py @@ -1,7 +1,6 @@ """Define fixtures for Notion tests.""" from collections.abc import Generator -import json from unittest.mock import AsyncMock, Mock, patch from aionotion.bridge.models import Bridge @@ -18,7 +17,7 @@ from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_object_fixture TEST_USERNAME = "user@host.com" TEST_PASSWORD = "password123" @@ -94,25 +93,25 @@ def config_fixture(): @pytest.fixture(name="data_bridge", scope="package") def data_bridge_fixture(): """Define bridge data.""" - return json.loads(load_fixture("bridge_data.json", "notion")) + return load_json_object_fixture("bridge_data.json", "notion") @pytest.fixture(name="data_listener", scope="package") def data_listener_fixture(): """Define listener data.""" - return json.loads(load_fixture("listener_data.json", "notion")) + return load_json_object_fixture("listener_data.json", "notion") @pytest.fixture(name="data_sensor", scope="package") def data_sensor_fixture(): """Define sensor data.""" - return json.loads(load_fixture("sensor_data.json", "notion")) + return load_json_object_fixture("sensor_data.json", "notion") @pytest.fixture(name="data_user_preferences", scope="package") def data_user_preferences_fixture(): """Define user preferences data.""" - return json.loads(load_fixture("user_preferences_data.json", "notion")) + return load_json_object_fixture("user_preferences_data.json", "notion") @pytest.fixture(name="get_client") diff --git a/tests/components/nut/test_switch.py b/tests/components/nut/test_switch.py index 966996e641a72f..3adf8f34721911 100644 --- a/tests/components/nut/test_switch.py +++ b/tests/components/nut/test_switch.py @@ -1,6 +1,5 @@ """Test the NUT switch platform.""" -import json from unittest.mock import AsyncMock import pytest @@ -19,7 +18,7 @@ from .util import async_init_integration -from tests.common import async_load_fixture +from tests.common import async_load_json_object_fixture @pytest.mark.parametrize( @@ -83,7 +82,7 @@ async def test_switch_pdu_dynamic_outlets( list_commands_return_value[command] = command ups_fixture = f"{model}.json" - list_vars = json.loads(await async_load_fixture(hass, ups_fixture, DOMAIN)) + list_vars = await async_load_json_object_fixture(hass, ups_fixture, DOMAIN) run_command = AsyncMock() diff --git a/tests/components/nut/util.py b/tests/components/nut/util.py index bd51ab7acc90d8..f55f7f29d3f4e6 100644 --- a/tests/components/nut/util.py +++ b/tests/components/nut/util.py @@ -1,6 +1,5 @@ """Tests for the nut integration.""" -import json from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -15,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, async_load_fixture +from tests.common import MockConfigEntry, async_load_json_object_fixture def _get_mock_nutclient( @@ -61,7 +60,7 @@ async def async_init_integration( if ups_fixture is not None: ups_fixture = f"{ups_fixture}.json" if list_vars is None: - list_vars = json.loads(await async_load_fixture(hass, ups_fixture, DOMAIN)) + list_vars = await async_load_json_object_fixture(hass, ups_fixture, DOMAIN) mock_pynut = _get_mock_nutclient( list_ups=list_ups, diff --git a/tests/components/openuv/conftest.py b/tests/components/openuv/conftest.py index c4ba6080693669..cf80be86d2a25c 100644 --- a/tests/components/openuv/conftest.py +++ b/tests/components/openuv/conftest.py @@ -1,7 +1,6 @@ """Define test fixtures for OpenUV.""" from collections.abc import Generator -import json from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -20,7 +19,7 @@ ) from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_object_fixture TEST_API_KEY = "abcde12345" TEST_ELEVATION = 0 @@ -85,13 +84,13 @@ def config_fixture() -> dict[str, Any]: @pytest.fixture(name="data_protection_window", scope="package") def data_protection_window_fixture(): """Define a fixture to return UV protection window data.""" - return json.loads(load_fixture("protection_window_data.json", "openuv")) + return load_json_object_fixture("protection_window_data.json", "openuv") @pytest.fixture(name="data_uv_index", scope="package") def data_uv_index_fixture(): """Define a fixture to return UV index data.""" - return json.loads(load_fixture("uv_index_data.json", "openuv")) + return load_json_object_fixture("uv_index_data.json", "openuv") @pytest.fixture(name="mock_pyopenuv") diff --git a/tests/components/p1_monitor/conftest.py b/tests/components/p1_monitor/conftest.py index fbd39914536f0d..17eaaedd1093b5 100644 --- a/tests/components/p1_monitor/conftest.py +++ b/tests/components/p1_monitor/conftest.py @@ -1,6 +1,5 @@ """Fixtures for P1 Monitor integration tests.""" -import json from unittest.mock import AsyncMock, MagicMock, patch from p1monitor import Phases, Settings, SmartMeter, WaterMeter @@ -10,7 +9,7 @@ from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_array_fixture @pytest.fixture @@ -34,22 +33,22 @@ def mock_p1monitor(): client = p1monitor_mock.return_value client.smartmeter = AsyncMock( return_value=SmartMeter.from_dict( - json.loads(load_fixture("p1_monitor/smartmeter.json")) + load_json_array_fixture("p1_monitor/smartmeter.json") ) ) client.phases = AsyncMock( return_value=Phases.from_dict( - json.loads(load_fixture("p1_monitor/phases.json")) + load_json_array_fixture("p1_monitor/phases.json") ) ) client.settings = AsyncMock( return_value=Settings.from_dict( - json.loads(load_fixture("p1_monitor/settings.json")) + load_json_array_fixture("p1_monitor/settings.json") ) ) client.watermeter = AsyncMock( return_value=WaterMeter.from_dict( - json.loads(load_fixture("p1_monitor/watermeter.json")) + load_json_array_fixture("p1_monitor/watermeter.json") ) ) yield client diff --git a/tests/components/peblar/conftest.py b/tests/components/peblar/conftest.py index 0fc3dbe48b5b5e..a26cce1ae08884 100644 --- a/tests/components/peblar/conftest.py +++ b/tests/components/peblar/conftest.py @@ -3,7 +3,6 @@ import asyncio from collections.abc import Generator from contextlib import nullcontext -import json from unittest.mock import AsyncMock, MagicMock, patch from peblar import ( @@ -20,7 +19,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_fixture, load_json_object_fixture @pytest.fixture @@ -54,8 +53,8 @@ def mock_peblar(request: pytest.FixtureRequest) -> Generator[MagicMock]: and the user configuration. """ overrides = getattr(request, "param", {}) - system_information = json.loads(load_fixture("system_information.json", DOMAIN)) - user_configuration = json.loads(load_fixture("user_configuration.json", DOMAIN)) + system_information = load_json_object_fixture("system_information.json", DOMAIN) + user_configuration = load_json_object_fixture("user_configuration.json", DOMAIN) for key, value in overrides.items(): if key in system_information: system_information[key] = value diff --git a/tests/components/picnic/conftest.py b/tests/components/picnic/conftest.py index daba33bd3dfbbc..61aff42ece613a 100644 --- a/tests/components/picnic/conftest.py +++ b/tests/components/picnic/conftest.py @@ -2,7 +2,6 @@ from collections.abc import Awaitable, Callable from datetime import timedelta -import json from unittest.mock import MagicMock, patch import pytest @@ -13,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_object_fixture from tests.typing import WebSocketGenerator ENTITY_ID = "todo.mock_title_shopping_cart" @@ -44,13 +43,13 @@ def mock_picnic_api(): client = mock.return_value client.session.auth_token = "3q29fpwhulzes" client.get_cart.return_value = Cart.from_api( - json.loads(load_fixture("picnic/cart.json")) + load_json_object_fixture("picnic/cart.json") ) client.get_user.return_value = User.from_api( - json.loads(load_fixture("picnic/user.json")) + load_json_object_fixture("picnic/user.json") ) client.get_deliveries.return_value = [ - DeliverySummary.from_api(json.loads(load_fixture("picnic/delivery.json"))) + DeliverySummary.from_api(load_json_object_fixture("picnic/delivery.json")) ] client.get_delivery_position.return_value = {} yield client diff --git a/tests/components/pure_energie/conftest.py b/tests/components/pure_energie/conftest.py index 9aa3a4cc1b4a4e..c7b670f8ecb2e0 100644 --- a/tests/components/pure_energie/conftest.py +++ b/tests/components/pure_energie/conftest.py @@ -1,7 +1,6 @@ """Fixtures for Pure Energie integration tests.""" from collections.abc import Generator -import json from unittest.mock import AsyncMock, MagicMock, patch from gridnet import Device as GridNetDevice, SmartBridge @@ -11,7 +10,7 @@ from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture @@ -42,7 +41,7 @@ def mock_pure_energie_config_flow() -> Generator[MagicMock]: ) as pure_energie_mock: pure_energie = pure_energie_mock.return_value pure_energie.device.return_value = GridNetDevice.from_dict( - json.loads(load_fixture("device.json", DOMAIN)) + load_json_object_fixture("device.json", DOMAIN) ) yield pure_energie @@ -56,12 +55,12 @@ def mock_pure_energie(): pure_energie = pure_energie_mock.return_value pure_energie.smartbridge = AsyncMock( return_value=SmartBridge.from_dict( - json.loads(load_fixture("pure_energie/smartbridge.json")) + load_json_object_fixture("pure_energie/smartbridge.json") ) ) pure_energie.device = AsyncMock( return_value=GridNetDevice.from_dict( - json.loads(load_fixture("pure_energie/device.json")) + load_json_object_fixture("pure_energie/device.json") ) ) yield pure_energie_mock diff --git a/tests/components/pushover/test_notify.py b/tests/components/pushover/test_notify.py index 49ee1a93d803ec..39a2ed482c596b 100644 --- a/tests/components/pushover/test_notify.py +++ b/tests/components/pushover/test_notify.py @@ -1,5 +1,6 @@ """Test the pushover notify platform.""" +from pathlib import Path from unittest.mock import MagicMock, patch from pushover_complete import BadAPIRequestError @@ -94,6 +95,78 @@ async def test_send_message( ) +@pytest.mark.usefixtures("mock_pushover") +@pytest.mark.parametrize( + ("is_allowed", "translation_key"), + [ + pytest.param(False, "attachment_not_allowed", id="not_allowed"), + pytest.param(True, "attachment_open_failed", id="open_failed"), + ], +) +async def test_send_message_attachment_error( + hass: HomeAssistant, + mock_send_message: MagicMock, + is_allowed: bool, + translation_key: str, +) -> None: + """Test that an unusable attachment raises and sends nothing.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with ( + patch.object(hass.config, "is_allowed_path", return_value=is_allowed), + pytest.raises(ServiceValidationError) as exc_info, + ): + await hass.services.async_call( + "notify", + "pushover", + { + "message": "Hello", + "data": {"attachment": "/nonexistent/attachment.jpg"}, + }, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == translation_key + mock_send_message.assert_not_called() + + +@pytest.mark.usefixtures("mock_pushover") +async def test_send_message_with_attachment( + hass: HomeAssistant, + mock_send_message: MagicMock, + tmp_path: Path, +) -> None: + """Test that a readable attachment is sent as an open file.""" + attachment = tmp_path / "attachment.jpg" + attachment.write_bytes(b"image data") + + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with patch.object(hass.config, "is_allowed_path", return_value=True): + await hass.services.async_call( + "notify", + "pushover", + { + "message": "Hello", + "data": {"attachment": str(attachment)}, + }, + blocking=True, + ) + + image = mock_send_message.call_args.kwargs["image"] + assert image.name == str(attachment) + image.close() + + async def test_cancel_by_tag( hass: HomeAssistant, mock_pushover: MagicMock, diff --git a/tests/components/rainmachine/conftest.py b/tests/components/rainmachine/conftest.py index 080948a4e675e3..3b63d4f5a1fadb 100644 --- a/tests/components/rainmachine/conftest.py +++ b/tests/components/rainmachine/conftest.py @@ -1,7 +1,6 @@ """Define test fixtures for RainMachine.""" from collections.abc import AsyncGenerator -import json from typing import Any from unittest.mock import AsyncMock, patch @@ -12,7 +11,11 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, load_fixture +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) @pytest.fixture(name="client") @@ -90,27 +93,27 @@ def controller_mac_fixture() -> str: @pytest.fixture(name="data_api_versions", scope="package") def data_api_versions_fixture(): """Define API version data.""" - return json.loads(load_fixture("api_versions_data.json", "rainmachine")) + return load_json_object_fixture("api_versions_data.json", "rainmachine") @pytest.fixture(name="data_diagnostics_current", scope="package") def data_diagnostics_current_fixture(): """Define current diagnostics data.""" - return json.loads(load_fixture("diagnostics_current_data.json", "rainmachine")) + return load_json_object_fixture("diagnostics_current_data.json", "rainmachine") @pytest.fixture(name="data_machine_firmare_update_status", scope="package") def data_machine_firmare_update_status_fixture(): """Define machine firmware update status data.""" - return json.loads( - load_fixture("machine_firmware_update_status_data.json", "rainmachine") + return load_json_object_fixture( + "machine_firmware_update_status_data.json", "rainmachine" ) @pytest.fixture(name="data_programs", scope="package") def data_programs_fixture(): """Define program data.""" - raw_data = json.loads(load_fixture("programs_data.json", "rainmachine")) + raw_data = load_json_array_fixture("programs_data.json", "rainmachine") # This replicate the process from `regenmaschine` to convert list to dict return {program["uid"]: program for program in raw_data} @@ -118,27 +121,27 @@ def data_programs_fixture(): @pytest.fixture(name="data_provision_settings", scope="package") def data_provision_settings_fixture(): """Define provisioning settings data.""" - return json.loads(load_fixture("provision_settings_data.json", "rainmachine")) + return load_json_object_fixture("provision_settings_data.json", "rainmachine") @pytest.fixture(name="data_restrictions_current", scope="package") def data_restrictions_current_fixture(): """Define current restrictions settings data.""" - return json.loads(load_fixture("restrictions_current_data.json", "rainmachine")) + return load_json_object_fixture("restrictions_current_data.json", "rainmachine") @pytest.fixture(name="data_restrictions_universal", scope="package") def data_restrictions_universal_fixture(): """Define universal restrictions settings data.""" - return json.loads(load_fixture("restrictions_universal_data.json", "rainmachine")) + return load_json_object_fixture("restrictions_universal_data.json", "rainmachine") @pytest.fixture(name="data_zones", scope="package") def data_zones_fixture(): """Define zone data.""" - raw_data = json.loads(load_fixture("zones_data.json", "rainmachine")) + raw_data = load_json_array_fixture("zones_data.json", "rainmachine") # This replicate the process from `regenmaschine` to convert list to dict - zone_details = json.loads(load_fixture("zones_details.json", "rainmachine")) + zone_details = load_json_array_fixture("zones_details.json", "rainmachine") zones: dict[int, dict[str, Any]] = {} for zone in raw_data: diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index 24c79b73a4e029..d9d103a951fd19 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -1,7 +1,6 @@ """The tests for the Recorder component.""" from datetime import datetime, timedelta -from unittest.mock import PropertyMock import pytest @@ -301,42 +300,31 @@ async def test_lazy_state_handles_include_json( caplog: pytest.LogCaptureFixture, ) -> None: """Test that the LazyState class handles invalid json.""" - row = PropertyMock( - entity_id="sensor.invalid", - shared_attrs="{INVALID_JSON}", - ) - assert LazyState(row, {}, None, row.entity_id, "", 1, False).attributes == {} + lstate = LazyState({}, None, "sensor.invalid", "", 1, "{INVALID_JSON}") + assert lstate.attributes == {} assert "Error converting row to state attributes" in caplog.text -async def test_lazy_state_can_decode_attributes( - caplog: pytest.LogCaptureFixture, -) -> None: +async def test_lazy_state_can_decode_attributes() -> None: """Test that the LazyState prefers can decode attributes.""" - row = PropertyMock( - entity_id="sensor.invalid", - attributes='{"shared":true}', - ) - assert LazyState(row, {}, None, row.entity_id, "", 1, False).attributes == { - "shared": True - } + lstate = LazyState({}, None, "sensor.invalid", "", 1, '{"shared":true}') + assert lstate.attributes == {"shared": True} -async def test_lazy_state_handles_different_last_updated_and_last_changed( - caplog: pytest.LogCaptureFixture, -) -> None: +async def test_lazy_state_handles_different_last_updated_and_last_changed() -> None: """Test that the LazyState handles different last_updated and last_changed.""" now = datetime(2021, 6, 12, 3, 4, 1, 323, tzinfo=dt_util.UTC) - row = PropertyMock( - entity_id="sensor.valid", - state="off", - attributes='{"shared":true}', - last_updated_ts=now.timestamp(), - last_reported_ts=now.timestamp(), - last_changed_ts=(now - timedelta(seconds=60)).timestamp(), - ) + last_updated_ts = now.timestamp() + last_changed_ts = (now - timedelta(seconds=60)).timestamp() lstate = LazyState( - row, {}, None, row.entity_id, row.state, row.last_updated_ts, False + {}, + None, + "sensor.valid", + "off", + last_updated_ts, + '{"shared":true}', + last_changed_ts, + last_updated_ts, ) assert lstate.as_dict() == { "attributes": {"shared": True}, @@ -345,9 +333,9 @@ async def test_lazy_state_handles_different_last_updated_and_last_changed( "last_updated": "2021-06-12T03:04:01.000323+00:00", "state": "off", } - assert lstate.last_updated.timestamp() == row.last_updated_ts - assert lstate.last_changed.timestamp() == row.last_changed_ts - assert lstate.last_reported.timestamp() == row.last_updated_ts + assert lstate.last_updated.timestamp() == last_updated_ts + assert lstate.last_changed.timestamp() == last_changed_ts + assert lstate.last_reported.timestamp() == last_updated_ts assert lstate.as_dict() == { "attributes": {"shared": True}, "entity_id": "sensor.valid", @@ -355,26 +343,24 @@ async def test_lazy_state_handles_different_last_updated_and_last_changed( "last_updated": "2021-06-12T03:04:01.000323+00:00", "state": "off", } - assert lstate.last_changed_timestamp == row.last_changed_ts - assert lstate.last_updated_timestamp == row.last_updated_ts - assert lstate.last_reported_timestamp == row.last_updated_ts + assert lstate.last_changed_timestamp == last_changed_ts + assert lstate.last_updated_timestamp == last_updated_ts + assert lstate.last_reported_timestamp == last_updated_ts -async def test_lazy_state_handles_same_last_updated_and_last_changed( - caplog: pytest.LogCaptureFixture, -) -> None: +async def test_lazy_state_handles_same_last_updated_and_last_changed() -> None: """Test that the LazyState handles same last_updated and last_changed.""" now = datetime(2021, 6, 12, 3, 4, 1, 323, tzinfo=dt_util.UTC) - row = PropertyMock( - entity_id="sensor.valid", - state="off", - attributes='{"shared":true}', - last_updated_ts=now.timestamp(), - last_changed_ts=now.timestamp(), - last_reported_ts=None, - ) + last_updated_ts = now.timestamp() lstate = LazyState( - row, {}, None, row.entity_id, row.state, row.last_updated_ts, False + {}, + None, + "sensor.valid", + "off", + last_updated_ts, + '{"shared":true}', + last_updated_ts, + None, ) assert lstate.as_dict() == { "attributes": {"shared": True}, @@ -383,9 +369,9 @@ async def test_lazy_state_handles_same_last_updated_and_last_changed( "last_updated": "2021-06-12T03:04:01.000323+00:00", "state": "off", } - assert lstate.last_updated.timestamp() == row.last_updated_ts - assert lstate.last_changed.timestamp() == row.last_changed_ts - assert lstate.last_reported.timestamp() == row.last_updated_ts + assert lstate.last_updated.timestamp() == last_updated_ts + assert lstate.last_changed.timestamp() == last_updated_ts + assert lstate.last_reported.timestamp() == last_updated_ts assert lstate.as_dict() == { "attributes": {"shared": True}, "entity_id": "sensor.valid", @@ -393,26 +379,25 @@ async def test_lazy_state_handles_same_last_updated_and_last_changed( "last_updated": "2021-06-12T03:04:01.000323+00:00", "state": "off", } - assert lstate.last_changed_timestamp == row.last_changed_ts - assert lstate.last_updated_timestamp == row.last_updated_ts - assert lstate.last_reported_timestamp == row.last_updated_ts + assert lstate.last_changed_timestamp == last_updated_ts + assert lstate.last_updated_timestamp == last_updated_ts + assert lstate.last_reported_timestamp == last_updated_ts -async def test_lazy_state_handles_different_last_reported( - caplog: pytest.LogCaptureFixture, -) -> None: +async def test_lazy_state_handles_different_last_reported() -> None: """Test that the LazyState handles last_reported different from last_updated.""" now = datetime(2021, 6, 12, 3, 4, 1, 323, tzinfo=dt_util.UTC) - row = PropertyMock( - entity_id="sensor.valid", - state="off", - attributes='{"shared":true}', - last_updated_ts=(now - timedelta(seconds=60)).timestamp(), - last_reported_ts=now.timestamp(), - last_changed_ts=(now - timedelta(seconds=60)).timestamp(), - ) + last_reported_ts = now.timestamp() + last_updated_ts = (now - timedelta(seconds=60)).timestamp() lstate = LazyState( - row, {}, None, row.entity_id, row.state, row.last_updated_ts, False + {}, + None, + "sensor.valid", + "off", + last_updated_ts, + '{"shared":true}', + last_updated_ts, + last_reported_ts, ) assert lstate.as_dict() == { "attributes": {"shared": True}, @@ -421,9 +406,9 @@ async def test_lazy_state_handles_different_last_reported( "last_updated": "2021-06-12T03:03:01.000323+00:00", "state": "off", } - assert lstate.last_updated.timestamp() == row.last_updated_ts - assert lstate.last_changed.timestamp() == row.last_changed_ts - assert lstate.last_reported.timestamp() == row.last_reported_ts - assert lstate.last_changed_timestamp == row.last_changed_ts - assert lstate.last_updated_timestamp == row.last_updated_ts - assert lstate.last_reported_timestamp == row.last_reported_ts + assert lstate.last_updated.timestamp() == last_updated_ts + assert lstate.last_changed.timestamp() == last_updated_ts + assert lstate.last_reported.timestamp() == last_reported_ts + assert lstate.last_changed_timestamp == last_updated_ts + assert lstate.last_updated_timestamp == last_updated_ts + assert lstate.last_reported_timestamp == last_reported_ts diff --git a/tests/components/roku/conftest.py b/tests/components/roku/conftest.py index f3ff48ef2f1765..7e3401e93439a1 100644 --- a/tests/components/roku/conftest.py +++ b/tests/components/roku/conftest.py @@ -1,7 +1,6 @@ """Fixtures for Roku integration tests.""" from collections.abc import Generator -import json from unittest.mock import MagicMock, patch import pytest @@ -11,7 +10,7 @@ from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, async_load_fixture +from tests.common import MockConfigEntry, async_load_json_object_fixture def app_icon_url(*args, **kwargs): @@ -48,7 +47,7 @@ async def mock_device( if hasattr(request, "param") and request.param: fixture = request.param - return RokuDevice(json.loads(await async_load_fixture(hass, fixture))) + return RokuDevice(await async_load_json_object_fixture(hass, fixture)) @pytest.fixture diff --git a/tests/components/samsung_infrared/test_climate.py b/tests/components/samsung_infrared/test_climate.py new file mode 100644 index 00000000000000..e5a2a997a27cc6 --- /dev/null +++ b/tests/components/samsung_infrared/test_climate.py @@ -0,0 +1,380 @@ +"""Tests for the Samsung Infrared climate platform.""" + +from unittest.mock import AsyncMock, patch + +from infrared_protocols.commands.samsung_ac import ( + SamsungAC0292Command, + SamsungAC0292HvacMode, + SamsungACFanMode, +) + +from homeassistant.components.climate import ( + ATTR_FAN_MODE, + ATTR_HVAC_MODE, + FAN_AUTO, + FAN_HIGH, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +from homeassistant.components.samsung_infrared.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_ON +from homeassistant.core import HomeAssistant, State + +from tests.common import ( + MockConfigEntry, + mock_restore_cache, + mock_restore_cache_with_extra_data, +) + + +async def test_samsung_infrared_climate_services(hass: HomeAssistant) -> None: + """Test climate services send the correct IR commands.""" + remote_entity_id = "remote.living_room_ir" + hass.states.async_set(remote_entity_id, STATE_ON) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "infrared_emitter_entity_id": remote_entity_id, + "device_type": "ac", + }, + unique_id="samsung_ir_ac_test", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.samsung_infrared.climate.SamsungIrClimate._send_command", + new_callable=AsyncMock, + ) as mock_send_command: + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "climate.samsung_ac" + + state = hass.states.get(entity_id) + assert state is not None + assert state.state != "unavailable" + + await hass.services.async_call( + "climate", + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.COOL}, + blocking=True, + ) + mock_send_command.assert_called_once() + + sent_command = mock_send_command.call_args[0][0] + assert isinstance(sent_command, SamsungAC0292Command) + assert sent_command.hvac_mode == SamsungAC0292HvacMode.COOL + + mock_send_command.reset_mock() + + await hass.services.async_call( + "climate", + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 26}, + blocking=True, + ) + mock_send_command.assert_called_once() + sent_command = mock_send_command.call_args[0][0] + assert sent_command.target_temperature == 26 + + mock_send_command.reset_mock() + + await hass.services.async_call( + "climate", + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_HIGH}, + blocking=True, + ) + mock_send_command.assert_called_once() + sent_command = mock_send_command.call_args[0][0] + assert sent_command.fan_mode == SamsungACFanMode.HIGH + + +async def test_samsung_infrared_climate_turn_off_sends_bare_off_command( + hass: HomeAssistant, +) -> None: + """Test that turning off sends OFF with no temperature, fan, or swing fields. + + SamsungAC0292Command raises if hvac_mode is OFF and any of those fields are not + None, so this also guards against a regression that would break every turn_off. + """ + remote_entity_id = "remote.living_room_ir" + hass.states.async_set(remote_entity_id, STATE_ON) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "infrared_emitter_entity_id": remote_entity_id, + "device_type": "ac", + }, + unique_id="samsung_ir_ac_test", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.samsung_infrared.climate.SamsungIrClimate._send_command", + new_callable=AsyncMock, + ) as mock_send_command: + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "climate.samsung_ac" + + await hass.services.async_call( + "climate", + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.COOL}, + blocking=True, + ) + mock_send_command.reset_mock() + + await hass.services.async_call( + "climate", + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + + mock_send_command.assert_called_once() + sent_command = mock_send_command.call_args[0][0] + assert isinstance(sent_command, SamsungAC0292Command) + assert sent_command.hvac_mode == SamsungAC0292HvacMode.OFF + assert sent_command.target_temperature is None + assert sent_command.fan_mode is None + assert sent_command.swing_mode is None + + +async def test_samsung_infrared_climate_set_temperature_with_hvac_mode( + hass: HomeAssistant, +) -> None: + """Test that set_temperature applies an included HVAC mode atomically.""" + remote_entity_id = "remote.living_room_ir" + hass.states.async_set(remote_entity_id, STATE_ON) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "infrared_emitter_entity_id": remote_entity_id, + "device_type": "ac", + }, + unique_id="samsung_ir_ac_test", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.samsung_infrared.climate.SamsungIrClimate._send_command", + new_callable=AsyncMock, + ) as mock_send_command: + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "climate.samsung_ac" + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == HVACMode.OFF + + await hass.services.async_call( + "climate", + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_HVAC_MODE: HVACMode.COOL, + ATTR_TEMPERATURE: 26, + }, + blocking=True, + ) + + mock_send_command.assert_called_once() + sent_command = mock_send_command.call_args[0][0] + assert sent_command.hvac_mode == SamsungAC0292HvacMode.COOL + assert sent_command.target_temperature == 26 + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == HVACMode.COOL + assert state.attributes[ATTR_TEMPERATURE] == 26 + + mock_send_command.reset_mock() + + await hass.services.async_call( + "climate", + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 22.5}, + blocking=True, + ) + + mock_send_command.assert_called_once() + sent_command = mock_send_command.call_args[0][0] + assert sent_command.target_temperature == 22 + + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes[ATTR_TEMPERATURE] == 22 + + +async def test_samsung_infrared_climate_set_hvac_mode_auto_normalizes_fan_mode( + hass: HomeAssistant, +) -> None: + """Test that switching to AUTO resets the reported fan mode to FAN_AUTO. + + SamsungAC0292Command always transmits a fixed fan value in auto mode and + reports fan_mode=None, so the assumed state must not keep showing a previously + selected fan speed (e.g. "high") that isn't actually being sent anymore. + """ + remote_entity_id = "remote.living_room_ir" + hass.states.async_set(remote_entity_id, STATE_ON) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "infrared_emitter_entity_id": remote_entity_id, + "device_type": "ac", + }, + unique_id="samsung_ir_ac_test", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.samsung_infrared.climate.SamsungIrClimate._send_command", + new_callable=AsyncMock, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "climate.samsung_ac" + + await hass.services.async_call( + "climate", + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.COOL}, + blocking=True, + ) + await hass.services.async_call( + "climate", + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_HIGH}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes[ATTR_FAN_MODE] == FAN_HIGH + + await hass.services.async_call( + "climate", + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.AUTO}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes[ATTR_FAN_MODE] == FAN_AUTO + + +async def test_samsung_infrared_climate_restores_state_after_restart( + hass: HomeAssistant, +) -> None: + """Test that hvac_mode, temperature, and fan_mode survive a restart.""" + remote_entity_id = "remote.living_room_ir" + hass.states.async_set(remote_entity_id, STATE_ON) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "infrared_emitter_entity_id": remote_entity_id, + "device_type": "ac", + }, + unique_id="samsung_ir_ac_test", + ) + entry.add_to_hass(hass) + + mock_restore_cache( + hass, + [ + State( + "climate.samsung_ac", + HVACMode.HEAT, + {ATTR_TEMPERATURE: 27, ATTR_FAN_MODE: FAN_HIGH}, + ) + ], + ) + + with patch( + "homeassistant.components.samsung_infrared.climate.SamsungIrClimate._send_command", + new_callable=AsyncMock, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("climate.samsung_ac") + assert state is not None + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 27 + assert state.attributes[ATTR_FAN_MODE] == FAN_HIGH + + +async def test_samsung_infrared_climate_turn_on_after_restart_resumes_last_mode( + hass: HomeAssistant, +) -> None: + """Test that turn_on after a restart resumes the last non-OFF mode, not COOL. + + Regression test: without restoring _last_on_hvac_mode, an AC that was last + HEAT and got turned OFF, then restarted, would resume in COOL on turn_on + instead of HEAT. + """ + remote_entity_id = "remote.living_room_ir" + hass.states.async_set(remote_entity_id, STATE_ON) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "infrared_emitter_entity_id": remote_entity_id, + "device_type": "ac", + }, + unique_id="samsung_ir_ac_test", + ) + entry.add_to_hass(hass) + + # The entity's last visible state was OFF, but it had been heating before that. + mock_restore_cache_with_extra_data( + hass, + [ + ( + State("climate.samsung_ac", HVACMode.OFF, {}), + {"last_on_hvac_mode": HVACMode.HEAT.value}, + ) + ], + ) + + with patch( + "homeassistant.components.samsung_infrared.climate.SamsungIrClimate._send_command", + new_callable=AsyncMock, + ) as mock_send_command: + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "climate.samsung_ac" + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == HVACMode.OFF + + await hass.services.async_call( + "climate", + "turn_on", + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == HVACMode.HEAT + + sent_command = mock_send_command.call_args[0][0] + assert sent_command.hvac_mode == SamsungAC0292HvacMode.HEAT diff --git a/tests/components/shelly/test_cover.py b/tests/components/shelly/test_cover.py index d028bb04dd925c..100a49506aabe6 100644 --- a/tests/components/shelly/test_cover.py +++ b/tests/components/shelly/test_cover.py @@ -1,7 +1,7 @@ """Tests for Shelly cover platform.""" from copy import deepcopy -from unittest.mock import Mock +from unittest.mock import AsyncMock, Mock from freezegun.api import FrozenDateTimeFactory import pytest @@ -23,7 +23,13 @@ CoverState, ) from homeassistant.components.shelly.const import RPC_COVER_UPDATE_TIME_SEC -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry @@ -111,6 +117,83 @@ async def test_block_device_update( state = hass.states.get("cover.test_name") assert state assert state.state == CoverState.OPEN + assert ATTR_ASSUMED_STATE not in state.attributes + + +@pytest.mark.parametrize( + ("last_direction", "expected_state"), + [ + ("close", CoverState.CLOSED), + ("open", CoverState.OPEN), + # Nothing has moved since the device booted + (None, STATE_UNKNOWN), + ], +) +async def test_block_device_roller_without_positioning( + hass: HomeAssistant, + mock_block_device: Mock, + monkeypatch: pytest.MonkeyPatch, + last_direction: str | None, + expected_state: str, +) -> None: + """Test an uncalibrated roller reports the direction it last travelled in.""" + settings = deepcopy(mock_block_device.settings) + settings["rollers"][0]["positioning"] = False + monkeypatch.setattr(mock_block_device, "settings", settings) + + status = deepcopy(mock_block_device.status) + # An uncalibrated roller parks its position on 101 + status["rollers"] = [{"current_pos": 101, "last_direction": last_direction}] + monkeypatch.setattr(mock_block_device, "status", status) + + await init_integration(hass, 1) + + assert (state := hass.states.get("cover.test_name")) + assert state.state == expected_state + assert state.attributes.get(ATTR_CURRENT_POSITION) is None + # Stopping mid travel leaves the direction saying more than it knows, so + # both buttons stay available + assert state.attributes[ATTR_ASSUMED_STATE] is True + + +async def test_block_device_roller_without_positioning_stopped( + hass: HomeAssistant, + mock_block_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test stopping an uncalibrated roller keeps it on its last direction.""" + settings = deepcopy(mock_block_device.settings) + settings["rollers"][0]["positioning"] = False + monkeypatch.setattr(mock_block_device, "settings", settings) + + status = deepcopy(mock_block_device.status) + status["rollers"] = [{"current_pos": 101, "last_direction": "close"}] + monkeypatch.setattr(mock_block_device, "status", status) + + # An uncalibrated roller answers a command with position 101 as well + monkeypatch.setattr( + mock_block_device.blocks[ROLLER_BLOCK_ID], + "set_state", + AsyncMock( + side_effect=lambda go, roller_pos=0: {"current_pos": 101, "state": go} + ), + ) + + await init_integration(hass, 1) + + entity_id = "cover.test_name" + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.CLOSED + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.CLOSED async def test_block_device_no_roller_blocks( diff --git a/tests/components/sofar/snapshots/test_diagnostics.ambr b/tests/components/sofar/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..e8c1ff9856b1a5 --- /dev/null +++ b/tests/components/sofar/snapshots/test_diagnostics.ambr @@ -0,0 +1,151 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'inverter_type': 1537, + 'model': '4.4 KTLX-G3', + 'raw': dict({ + 'holding': dict({ + '1028': 2, + '1029': 0, + '1030': 0, + '1031': 0, + '1032': 0, + '1033': 0, + '1034': 0, + '1035': 0, + '1036': 0, + '1037': 0, + '1038': 0, + '1039': 0, + '1040': 0, + '1041': 0, + '1042': 0, + '1043': 0, + '1044': 0, + '1045': 0, + '1046': 0, + '1047': 0, + '1048': 0, + '1049': 0, + '1050': 0, + '1051': 0, + '1052': 0, + '1053': 0, + '1054': 0, + '1055': 0, + '1056': 0, + '1057': 0, + '1068': 0, + '1069': 0, + '1070': 0, + '1071': 0, + '1072': 0, + '1073': 0, + '1100': 0, + '1101': 22065, + '1102': 12336, + '1103': 22066, + '1104': 12848, + '1105': 0, + '1106': 0, + '1156': 5000, + '1157': 0, + '1158': 0, + '1159': 0, + '1160': 0, + '1161': 0, + '1162': 0, + '1163': 0, + '1164': 0, + '1165': 0, + '1166': 0, + '1167': 0, + '1168': 0, + '1169': 0, + '1170': 0, + '1171': 0, + '1172': 0, + '1173': 0, + '1174': 0, + '1175': 0, + '1176': 0, + '1177': 0, + '1178': 0, + '1179': 0, + '1180': 0, + '1181': 0, + '1182': 0, + '1183': 0, + '1184': 0, + '1185': 0, + '1186': 0, + '1187': 0, + '1188': 0, + '1189': 0, + '1190': 0, + '1191': 0, + '1192': 0, + '1193': 0, + '1194': 0, + '1195': 0, + '1196': 0, + '1197': 0, + '1198': 0, + '1199': 0, + '1200': 0, + '1201': 0, + '1202': 0, + '1203': 0, + '1204': 0, + '1205': 0, + '1206': 0, + '1207': 0, + '1208': 0, + '1209': 0, + '1210': 0, + '1211': 0, + '1212': 0, + '1412': 0, + '1413': 0, + '1414': 250, + '1415': 0, + '1416': 0, + '1417': 180, + '1476': 43, + '1668': 0, + '1669': 1000, + '1670': 0, + '1671': 150, + '1672': 0, + '1673': 0, + '1674': 0, + '1675': 0, + '1676': 0, + '1677': 0, + '1678': 0, + '1679': 0, + '1680': 0, + '1681': 0, + '1682': 0, + '1683': 0, + '4131': 0, + '4132': 0, + '4356': 0, + '4357': 0, + '4358': 0, + }), + }), + 'readings_components': list([ + 'state', + 'grid', + 'pv_1_2', + 'energy', + ]), + 'serial_number': '**REDACTED**', + 'settings_components': list([ + 'feed_in', + 'remote', + 'active_power_control', + ]), + }) +# --- diff --git a/tests/components/sofar/snapshots/test_switch.ambr b/tests/components/sofar/snapshots/test_switch.ambr new file mode 100644 index 00000000000000..267b695da287bd --- /dev/null +++ b/tests/components/sofar/snapshots/test_switch.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_pv_entities[switch.4_4_ktlx_g3-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': 'switch', + 'entity_category': None, + 'entity_id': 'switch.4_4_ktlx_g3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'SS2ES104N5S445_remote_switch_on_off', + 'unit_of_measurement': None, + }) +# --- +# name: test_pv_entities[switch.4_4_ktlx_g3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '4.4 KTLX-G3', + }), + 'context': , + 'entity_id': 'switch.4_4_ktlx_g3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/sofar/test_config_flow.py b/tests/components/sofar/test_config_flow.py index e1be81ee439bcf..324c8a226e2040 100644 --- a/tests/components/sofar/test_config_flow.py +++ b/tests/components/sofar/test_config_flow.py @@ -10,13 +10,14 @@ from homeassistant import config_entries from homeassistant.components.sofar.const import DEFAULT_NAME, DOMAIN +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.exceptions import HomeAssistantError from . import MOCK_MODEL, MOCK_SERIAL, MOCK_USER_INPUT, seed_pv_inverter -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value # A recognized prefix with no model in sofar-modbus's own table. _UNMODELED_SERIAL = "SA1XXES100XX" @@ -202,3 +203,110 @@ async def test_user_step_already_configured(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +_NEW_USER_INPUT = {**MOCK_USER_INPUT, CONF_HOST: "192.168.1.200"} + + +async def test_reconfigure_updates_the_entry( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test reconfigure updates the entry and reloads it.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id=MOCK_SERIAL, data=MOCK_USER_INPUT) + entry.add_to_hass(hass) + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_conn = MockModbusConnection() + seed_pv_inverter(mock_conn.for_unit(1)) + + with _patch_temporary_unit(mock_conn): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], _NEW_USER_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == _NEW_USER_INPUT + + +async def test_reconfigure_rejects_a_different_serial(hass: HomeAssistant) -> None: + """Test reconfigure aborts if the inverter's serial doesn't match.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id=MOCK_SERIAL, data=MOCK_USER_INPUT) + entry.add_to_hass(hass) + result = await entry.start_reconfigure_flow(hass) + + mock_conn = MockModbusConnection() + seed_pv_inverter(mock_conn.for_unit(1), serial=_UNMODELED_SERIAL) + + with _patch_temporary_unit(mock_conn): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], _NEW_USER_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + assert entry.data == MOCK_USER_INPUT + + +@pytest.mark.parametrize( + ("seed", "expected_error", "expected_placeholders"), + [ + pytest.param( + _seed_unreachable, + "cannot_connect", + {"error": "stuck"}, + id="cannot_connect", + ), + pytest.param( + _seed_unrecognized, + "unrecognized_inverter", + {}, + id="unrecognized_inverter", + ), + ], +) +async def test_reconfigure_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + seed: Callable[[MockModbusUnit], None], + expected_error: str, + expected_placeholders: dict[str, str], +) -> None: + """Test the reconfigure step reports the right error and recovers.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id=MOCK_SERIAL, data=MOCK_USER_INPUT) + entry.add_to_hass(hass) + result = await entry.start_reconfigure_flow(hass) + + mock_conn = MockModbusConnection() + seed(mock_conn.for_unit(1)) + + with _patch_temporary_unit(mock_conn): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], _NEW_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": expected_error} + assert result["description_placeholders"] == expected_placeholders + assert entry.data == MOCK_USER_INPUT + # The retry starts from what was typed, not from the stored entry. + assert ( + get_schema_suggested_value(result["data_schema"].schema, CONF_HOST) + == _NEW_USER_INPUT[CONF_HOST] + ) + + working_conn = MockModbusConnection() + seed_pv_inverter(working_conn.for_unit(1)) + + with _patch_temporary_unit(working_conn): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], _NEW_USER_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == _NEW_USER_INPUT diff --git a/tests/components/sofar/test_diagnostics.py b/tests/components/sofar/test_diagnostics.py new file mode 100644 index 00000000000000..cd2a4f2e71eac7 --- /dev/null +++ b/tests/components/sofar/test_diagnostics.py @@ -0,0 +1,35 @@ +"""Test the Sofar Inverter Modbus diagnostics.""" + +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, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a config entry.""" + diag = await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + + assert diag == snapshot + + +async def test_diagnostics_redacts_serial_number( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, +) -> None: + """Test the serial number is redacted, both as a field and as raw ASCII.""" + diag = await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + + assert diag["serial_number"] == "**REDACTED**" + holding = diag["raw"]["holding"] + for address in range(0x0445, 0x044C): + assert str(address) not in holding diff --git a/tests/components/sofar/test_switch.py b/tests/components/sofar/test_switch.py new file mode 100644 index 00000000000000..71df5c3dd3d979 --- /dev/null +++ b/tests/components/sofar/test_switch.py @@ -0,0 +1,116 @@ +"""Test the Sofar Inverter Modbus switch platform.""" + +from unittest.mock import patch + +from modbus_connection import ModbusError +from modbus_connection.mock import MockModbusConnection +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.sofar.const import DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import MOCK_MODEL, MOCK_SERIAL, MOCK_USER_INPUT, seed_pv_inverter + +from tests.common import MockConfigEntry, snapshot_platform + + +async def _setup_pv( + hass: HomeAssistant, *, remote_on: bool = False +) -> tuple[MockConfigEntry, MockModbusConnection]: + """Set up a PV-only inverter with only the switch platform loaded.""" + connection = MockModbusConnection() + seed_pv_inverter(connection.for_unit(1)) + if remote_on: + connection.for_unit(1).holding[0x1104] = 1 + entry = MockConfigEntry( + domain=DOMAIN, unique_id=MOCK_SERIAL, data=MOCK_USER_INPUT, title=MOCK_MODEL + ) + entry.add_to_hass(hass) + with ( + patch("homeassistant.components.sofar.PLATFORMS", [Platform.SWITCH]), + patch( + "homeassistant.components.sofar.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: connection.for_unit( + unit_id + ), + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + return entry, connection + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_pv_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the switch entities a PV-only inverter serves.""" + entry, _ = await _setup_pv(hass) + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +@pytest.mark.parametrize( + ("remote_on", "service", "initial", "final"), + [ + pytest.param(False, SERVICE_TURN_ON, STATE_OFF, STATE_ON, id="turn_on"), + pytest.param(True, SERVICE_TURN_OFF, STATE_ON, STATE_OFF, id="turn_off"), + ], +) +async def test_remote_switch_toggle( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + remote_on: bool, + service: str, + initial: str, + final: str, +) -> None: + """Test toggling the remote switch writes the register.""" + await _setup_pv(hass, remote_on=remote_on) + entity_id = entity_registry.async_get_entity_id( + SWITCH_DOMAIN, DOMAIN, f"{MOCK_SERIAL}_remote_switch_on_off" + ) + assert entity_id is not None + assert (state := hass.states.get(entity_id)) is not None + assert state.state == initial + + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert (state := hass.states.get(entity_id)) is not None + assert state.state == final + + +async def test_turn_on_modbus_error( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test a write failure propagates as-is.""" + _, connection = await _setup_pv(hass) + entity_id = entity_registry.async_get_entity_id( + SWITCH_DOMAIN, DOMAIN, f"{MOCK_SERIAL}_remote_switch_on_off" + ) + assert entity_id is not None + connection.for_unit(1).fail_write(0x1104, ModbusError("busy")) + + with pytest.raises(ModbusError, match="busy"): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) diff --git a/tests/components/solaredge_modbus/snapshots/test_switch.ambr b/tests/components/solaredge_modbus/snapshots/test_switch.ambr new file mode 100644 index 00000000000000..8c660321d6b713 --- /dev/null +++ b/tests/components/solaredge_modbus/snapshots/test_switch.ambr @@ -0,0 +1,101 @@ +# serializer version: 1 +# name: test_switches[switch.solaredge_se10000h_external_production-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': 'switch', + 'entity_category': , + 'entity_id': 'switch.solaredge_se10000h_external_production', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'External production', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'External production', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'external_production', + 'unique_id': '7E123ABC_external_production', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.solaredge_se10000h_external_production-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'SolarEdge SE10000H External production', + }), + 'context': , + 'entity_id': 'switch.solaredge_se10000h_external_production', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.solaredge_se10000h_negative_site_limit-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': 'switch', + 'entity_category': , + 'entity_id': 'switch.solaredge_se10000h_negative_site_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Negative site limit', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Negative site limit', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'negative_site_limit', + 'unique_id': '7E123ABC_negative_site_limit', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.solaredge_se10000h_negative_site_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'SolarEdge SE10000H Negative site limit', + }), + 'context': , + 'entity_id': 'switch.solaredge_se10000h_negative_site_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/solaredge_modbus/test_init.py b/tests/components/solaredge_modbus/test_init.py index 64c452aaf289ea..84cada46e63ec3 100644 --- a/tests/components/solaredge_modbus/test_init.py +++ b/tests/components/solaredge_modbus/test_init.py @@ -1,24 +1,38 @@ """Tests for the SolarEdge Modbus config-entry setup.""" +import asyncio from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory from modbus_connection import ( IllegalDataAddressError, ModbusTimeoutError, + ModbusUnit, ServerDeviceFailureError, ) from modbus_connection.mock import MockModbusConnection, MockModbusUnit import pytest -from solaredged import SolarEdgeConnectionError +from solaredged import SolarEdge, SolarEdgeConnectionError +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) from homeassistant.components.solaredge_modbus.const import ( + ATTACHMENT_SCAN_INTERVAL, DOMAIN, SCAN_INTERVAL, SETTINGS_SCAN_INTERVAL, ) +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_ON, + STATE_ON, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr @@ -46,6 +60,12 @@ # An address inside the pooled storage and export control read. SITE_CONTROL_REGISTER = 57348 +# Where the first meter's serial number lives. +METER_SERIAL_REGISTER = 40171 + +EXPORT_LIMITATION_ENTITY = "select.solaredge_se10000h_export_limitation" +EXTERNAL_PRODUCTION_ENTITY = "switch.solaredge_se10000h_external_production" + async def _setup(hass: HomeAssistant, entry: MockConfigEntry) -> None: entry.add_to_hass(hass) @@ -554,6 +574,256 @@ async def test_settings_failure_does_not_block_setup( assert state.state == STATE_UNAVAILABLE +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_concurrent_control_writes_keep_both_changes( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """Two writes to the same control register do not clobber each other. + + The export mode and its flags live in one register, which the library + changes by taking its cached value, flipping bits and writing it back. + Select and switch have separate parallel-update semaphores, so without + serialization the second write undoes the first. + """ + await _setup(hass, mock_config_entry) + + write_register = mock_modbus_unit.write_register + + async def write_register_slowly(address: int, value: int) -> None: + """Write with a suspension point, which a real link has and a mock lacks.""" + await asyncio.sleep(0) + await write_register(address, value) + + mock_modbus_unit.write_register = write_register_slowly + + await asyncio.gather( + hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: EXPORT_LIMITATION_ENTITY, + ATTR_OPTION: "production_control", + }, + blocking=True, + ), + hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: EXTERNAL_PRODUCTION_ENTITY}, + blocking=True, + ), + ) + + state = hass.states.get(EXPORT_LIMITATION_ENTITY) + assert state is not None + assert state.state == "production_control" + + state = hass.states.get(EXTERNAL_PRODUCTION_ENTITY) + assert state is not None + assert state.state == STATE_ON + + +async def _tick_attachment_check( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> int: + """Let the check for changed hardware run, and report what it cost. + + Setting up probes the device, so a check that reloads the entry probes + twice: once to look, once to build the entry again. + """ + probes = 0 + probe = SolarEdge.async_probe + + async def counting_probe(unit: ModbusUnit) -> SolarEdge: + nonlocal probes + probes += 1 + return await probe(unit) + + with patch.object(SolarEdge, "async_probe", counting_probe): + freezer.tick(ATTACHMENT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + return probes + + +async def test_meter_added_later_is_picked_up( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """A meter wired to a running installation appears without being asked. + + What is attached is read while the entry is set up, so the entry loads + again once a probe finds something that was not there before. + """ + mock_modbus_unit.fail_read(METER_MODEL_REGISTER, IllegalDataAddressError()) + await _setup(hass, mock_config_entry) + + meter = (DOMAIN, f"{SERIAL_NUMBER}_meter_{METER_SERIAL_NUMBER}") + assert ( + device_registry.async_get_device_by_identifier( + meter, mock_config_entry.entry_id + ) + is None + ) + + # The meter is wired in and answers from now on. + mock_modbus_unit.fail_read(METER_MODEL_REGISTER, None) + + await _tick_attachment_check(hass, freezer) + + assert ( + device_registry.async_get_device_by_identifier( + meter, mock_config_entry.entry_id + ) + is not None + ) + + +async def test_battery_removed_later_is_dropped( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """A battery taken out of a running installation stops being a device.""" + await _setup(hass, mock_config_entry) + + battery = (DOMAIN, f"{SERIAL_NUMBER}_battery_{BATTERY_SERIAL_NUMBERS[0]}") + assert ( + device_registry.async_get_device_by_identifier( + battery, mock_config_entry.entry_id + ) + is not None + ) + + mock_modbus_unit.fail_read(BATTERY_RATED_ENERGY, IllegalDataAddressError()) + + await _tick_attachment_check(hass, freezer) + + assert ( + device_registry.async_get_device_by_identifier( + battery, mock_config_entry.entry_id + ) + is None + ) + + +async def test_replaced_meter_is_picked_up( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """Another meter in the same place is another device, while running too. + + Swapping one meter for another leaves the count alone, so what gives it + away is the serial number the polls have been reading all along. + """ + await _setup(hass, mock_config_entry) + + replacement = "7E9C55A6" + padded = replacement.ljust(32, "\0").encode() + mock_modbus_unit.holding.update( + { + METER_SERIAL_REGISTER + index: (padded[index * 2] << 8) + | padded[index * 2 + 1] + for index in range(16) + } + ) + + # The swap is seen without probing, so the only probe here is the reload's. + assert await _tick_attachment_check(hass, freezer) == 1 + + assert ( + device_registry.async_get_device_by_identifier( + (DOMAIN, f"{SERIAL_NUMBER}_meter_{METER_SERIAL_NUMBER}"), + mock_config_entry.entry_id, + ) + is None + ) + assert ( + device_registry.async_get_device_by_identifier( + (DOMAIN, f"{SERIAL_NUMBER}_meter_{replacement}"), + mock_config_entry.entry_id, + ) + is not None + ) + + +async def test_silent_attachment_does_not_trigger_a_reload( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """A meter that did not answer the probe is not a meter that was removed. + + Silence is taken for absence while probing, so reloading on it would drop a + device, and its history, over a single timeout. + """ + await _setup(hass, mock_config_entry) + + meter = (DOMAIN, f"{SERIAL_NUMBER}_meter_{METER_SERIAL_NUMBER}") + mock_modbus_unit.fail_read(METER_MODEL_REGISTER, ModbusTimeoutError("timed out")) + + assert await _tick_attachment_check(hass, freezer) == 1 + + assert ( + device_registry.async_get_device_by_identifier( + meter, mock_config_entry.entry_id + ) + is not None + ) + + +async def test_unchanged_attachments_leave_the_entry_alone( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, +) -> None: + """Nothing changed means nothing happens, however often it is checked.""" + await _setup(hass, mock_config_entry) + + coordinator = mock_config_entry.runtime_data.readings + + assert await _tick_attachment_check(hass, freezer) == 1 + + assert mock_config_entry.state is ConfigEntryState.LOADED + # A reload would have built new coordinators. + assert mock_config_entry.runtime_data.readings is coordinator + + +async def test_a_dead_probe_leaves_the_entry_where_it_is( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """An inverter that stops answering says nothing about what is wired to it. + + The coordinators already report an inverter gone quiet; reloading on top of + that would only take the entry down with it. + """ + await _setup(hass, mock_config_entry) + + coordinator = mock_config_entry.runtime_data.readings + mock_modbus_unit.fail_requests(ModbusTimeoutError("link died")) + + assert await _tick_attachment_check(hass, freezer) == 1 + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_config_entry.runtime_data.readings is coordinator + + async def test_setup_retry_when_device_unresponsive( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/solaredge_modbus/test_switch.py b/tests/components/solaredge_modbus/test_switch.py new file mode 100644 index 00000000000000..68e6d920b139c6 --- /dev/null +++ b/tests/components/solaredge_modbus/test_switch.py @@ -0,0 +1,137 @@ +"""Tests for the SolarEdge Modbus switch entities.""" + +from unittest.mock import patch + +from modbus_connection import IllegalDataAddressError +from modbus_connection.mock import MockModbusUnit +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +EXTERNAL_PRODUCTION_ENTITY = "switch.solaredge_se10000h_external_production" +NEGATIVE_SITE_LIMIT_ENTITY = "switch.solaredge_se10000h_negative_site_limit" + +# The export mode register, which carries both flags and reads as absent when +# the whole block is. +EXPORT_MODE_REGISTER = 57344 + + +async def _setup_switch_platform(hass: HomeAssistant, entry: MockConfigEntry) -> None: + with patch( + "homeassistant.components.solaredge_modbus.PLATFORMS", [Platform.SWITCH] + ): + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_switches( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """All switch entities and their states match the snapshot.""" + await _setup_switch_platform(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_switches_disabled_by_default( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Export-control flags are installer settings, not day-to-day switches.""" + await _setup_switch_platform(hass, mock_config_entry) + + for entity_id in ( + EXTERNAL_PRODUCTION_ENTITY, + "switch.solaredge_se10000h_negative_site_limit", + ): + assert hass.states.get(entity_id) is None + entry = entity_registry.async_get(entity_id) + assert entry is not None + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +async def test_no_switches_without_export_control( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """An inverter without the export control block gets no switches. + + Both switches are flags in that block, so there is nothing to show for an + installation that does not have it. + """ + # A real device answers reads of a block it does not have with a Modbus + # exception (illegal data address). + mock_modbus_unit.fail_read(EXPORT_MODE_REGISTER, IllegalDataAddressError()) + + await _setup_switch_platform(hass, mock_config_entry) + + assert hass.states.async_entity_ids(SWITCH_DOMAIN) == [] + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("entity_id", "bit"), + [ + pytest.param(EXTERNAL_PRODUCTION_ENTITY, 10, id="external production"), + pytest.param(NEGATIVE_SITE_LIMIT_ENTITY, 11, id="negative site limit"), + ], +) +async def test_turn_on_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, + entity_id: str, + bit: int, +) -> None: + """Turning a switch on and off writes its own flag bit to the device. + + Both flags live in the export mode register, each with a bit and a setter + of its own, so each has to reach the one it names. + """ + await _setup_switch_platform(hass, mock_config_entry) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + assert mock_modbus_unit.holding[EXPORT_MODE_REGISTER] & (1 << bit) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_OFF + assert not mock_modbus_unit.holding[EXPORT_MODE_REGISTER] & (1 << bit) diff --git a/tests/components/sonarr/conftest.py b/tests/components/sonarr/conftest.py index 28500c3d171276..dbc2ec90fe086f 100644 --- a/tests/components/sonarr/conftest.py +++ b/tests/components/sonarr/conftest.py @@ -1,7 +1,6 @@ """Fixtures for Sonarr integration tests.""" from collections.abc import Generator -import json from unittest.mock import MagicMock, patch from aiopyarr import ( @@ -33,36 +32,40 @@ ) from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_fixture +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) def sonarr_calendar() -> list[SonarrCalendar]: """Generate a response for the calendar method.""" - results = json.loads(load_fixture("sonarr/calendar.json")) + results = load_json_array_fixture("sonarr/calendar.json") return [SonarrCalendar(result) for result in results] def sonarr_commands() -> list[Command]: """Generate a response for the commands method.""" - results = json.loads(load_fixture("sonarr/command.json")) + results = load_json_array_fixture("sonarr/command.json") return [Command(result) for result in results] def sonarr_diskspace() -> list[Diskspace]: """Generate a response for the diskspace method.""" - results = json.loads(load_fixture("sonarr/diskspace.json")) + results = load_json_array_fixture("sonarr/diskspace.json") return [Diskspace(result) for result in results] def sonarr_queue() -> SonarrQueue: """Generate a response for the queue method.""" - results = json.loads(load_fixture("sonarr/queue.json")) + results = load_json_object_fixture("sonarr/queue.json") return SonarrQueue(results) def sonarr_queue_season_pack() -> SonarrQueue: """Generate a response for the queue method with a season pack.""" - results = json.loads(load_fixture("sonarr/queue_season_pack.json")) + results = load_json_object_fixture("sonarr/queue_season_pack.json") return SonarrQueue(results) @@ -75,25 +78,25 @@ def mock_sonarr_season_pack(mock_sonarr: MagicMock) -> MagicMock: def sonarr_series() -> list[SonarrSeries]: """Generate a response for the series method.""" - results = json.loads(load_fixture("sonarr/series.json")) + results = load_json_array_fixture("sonarr/series.json") return [SonarrSeries(result) for result in results] def sonarr_system_status() -> SystemStatus: """Generate a response for the system status method.""" - result = json.loads(load_fixture("sonarr/system-status.json")) + result = load_json_object_fixture("sonarr/system-status.json") return SystemStatus(result) def sonarr_wanted() -> SonarrWantedMissing: """Generate a response for the wanted method.""" - results = json.loads(load_fixture("sonarr/wanted-missing.json")) + results = load_json_object_fixture("sonarr/wanted-missing.json") return SonarrWantedMissing(results) def sonarr_episodes() -> list[SonarrEpisode]: """Generate a response for the episodes method.""" - results = json.loads(load_fixture("sonarr/episodes.json")) + results = load_json_array_fixture("sonarr/episodes.json") return [SonarrEpisode(result) for result in results] diff --git a/tests/components/sonarr/test_calendar.py b/tests/components/sonarr/test_calendar.py index c2e4dba44e42bf..54c3d2ab8f4fc7 100644 --- a/tests/components/sonarr/test_calendar.py +++ b/tests/components/sonarr/test_calendar.py @@ -1,7 +1,6 @@ """The tests for Sonarr calendar platform.""" from datetime import datetime -import json from unittest.mock import MagicMock, patch from aiopyarr import SonarrCalendar @@ -17,7 +16,11 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, async_load_fixture, snapshot_platform +from tests.common import ( + MockConfigEntry, + async_load_json_array_fixture, + snapshot_platform, +) ENTITY_ID = "calendar.sonarr" @@ -112,7 +115,7 @@ async def test_calendar_get_events_without_overview( ) -> None: """Test that episodes without an overview are handled (real Sonarr omits it).""" await hass.config.async_set_time_zone("UTC") - raw = json.loads(await async_load_fixture(hass, "calendar.json", "sonarr"))[0] + raw = (await async_load_json_array_fixture(hass, "calendar.json", "sonarr"))[0] raw.pop("overview") mock_sonarr.async_get_calendar.return_value = [SonarrCalendar(raw)] diff --git a/tests/components/starlink/patchers.py b/tests/components/starlink/patchers.py index 06c23b70bc6c54..d8acc756714a33 100644 --- a/tests/components/starlink/patchers.py +++ b/tests/components/starlink/patchers.py @@ -1,9 +1,8 @@ """General Starlink patchers.""" -import json from unittest.mock import patch -from tests.common import load_fixture +from tests.common import load_json_array_fixture, load_json_object_fixture SETUP_ENTRY_PATCHER = patch( "homeassistant.components.starlink.async_setup_entry", return_value=True @@ -11,23 +10,23 @@ LOCATION_DATA_SUCCESS_PATCHER = patch( "homeassistant.components.starlink.coordinator.location_data", - return_value=json.loads(load_fixture("location_data_success.json", "starlink")), + return_value=load_json_object_fixture("location_data_success.json", "starlink"), ) SLEEP_DATA_SUCCESS_PATCHER = patch( "homeassistant.components.starlink.coordinator.get_sleep_config", - return_value=json.loads(load_fixture("sleep_data_success.json", "starlink")), + return_value=load_json_array_fixture("sleep_data_success.json", "starlink"), ) STATUS_DATA_TARGET = "homeassistant.components.starlink.coordinator.status_data" -STATUS_DATA_FIXTURE = json.loads(load_fixture("status_data_success.json", "starlink")) +STATUS_DATA_FIXTURE = load_json_array_fixture("status_data_success.json", "starlink") STATUS_DATA_SUCCESS_PATCHER = patch( STATUS_DATA_TARGET, return_value=STATUS_DATA_FIXTURE ) HISTORY_STATS_SUCCESS_PATCHER = patch( "homeassistant.components.starlink.coordinator.history_stats", - return_value=json.loads(load_fixture("history_stats_success.json", "starlink")), + return_value=load_json_array_fixture("history_stats_success.json", "starlink"), ) DEVICE_FOUND_PATCHER = patch( diff --git a/tests/components/subaru/test_diagnostics.py b/tests/components/subaru/test_diagnostics.py index 35144cae49f641..0e0ddf68d869c5 100644 --- a/tests/components/subaru/test_diagnostics.py +++ b/tests/components/subaru/test_diagnostics.py @@ -1,6 +1,5 @@ """Test Subaru diagnostics.""" -import json from unittest.mock import patch import pytest @@ -18,7 +17,7 @@ advance_time_to_next_fetch, ) -from tests.common import async_load_fixture +from tests.common import async_load_json_object_fixture from tests.components.diagnostics import ( get_diagnostics_for_config_entry, get_diagnostics_for_device, @@ -58,7 +57,7 @@ async def test_device_diagnostics( ) assert reg_device is not None - raw_data = json.loads(await async_load_fixture(hass, "raw_api_data.json", DOMAIN)) + raw_data = await async_load_json_object_fixture(hass, "raw_api_data.json", DOMAIN) with patch(MOCK_API_GET_RAW_DATA, return_value=raw_data) as mock_get_raw_data: assert ( await get_diagnostics_for_device( diff --git a/tests/components/swiss_public_transport/conftest.py b/tests/components/swiss_public_transport/conftest.py index 03924ba4e2f3fd..7584c76cd671df 100644 --- a/tests/components/swiss_public_transport/conftest.py +++ b/tests/components/swiss_public_transport/conftest.py @@ -1,7 +1,6 @@ """Common fixtures for the swiss_public_transport tests.""" from collections.abc import Generator -import json from unittest.mock import AsyncMock, patch import pytest @@ -12,7 +11,7 @@ DOMAIN, ) -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_array_fixture START = "Zürich" DESTINATION = "Bern" @@ -35,7 +34,7 @@ def mock_opendata_client() -> Generator[AsyncMock]: client.async_get_data.return_value = None client.from_name = START client.to_name = DESTINATION - client.connections = json.loads(load_fixture("connections.json", DOMAIN))[0:3] + client.connections = load_json_array_fixture("connections.json", DOMAIN)[0:3] yield client diff --git a/tests/components/swiss_public_transport/test_sensor.py b/tests/components/swiss_public_transport/test_sensor.py index 56cda2e348523c..d94e623da5d91b 100644 --- a/tests/components/swiss_public_transport/test_sensor.py +++ b/tests/components/swiss_public_transport/test_sensor.py @@ -1,6 +1,5 @@ """Tests for the swiss_public_transport sensor platform.""" -import json from unittest.mock import AsyncMock, patch from opendata_transport.exceptions import ( @@ -25,7 +24,7 @@ from tests.common import ( MockConfigEntry, async_fire_time_changed, - async_load_fixture, + async_load_json_array_fixture, snapshot_platform, ) from tests.test_config_entries import FrozenDateTimeFactory @@ -93,8 +92,8 @@ async def test_fetching_data( assert hass.states.get("sensor.zurich_bern_line").state == "T10" # Set new data and verify it - mock_opendata_client.connections = json.loads( - await async_load_fixture(hass, "connections.json", DOMAIN) + mock_opendata_client.connections = ( + await async_load_json_array_fixture(hass, "connections.json", DOMAIN) )[3:6] freezer.tick(DEFAULT_UPDATE_TIME) async_fire_time_changed(hass) @@ -113,8 +112,8 @@ async def test_fetching_data( # Recover and fetch new data again mock_opendata_client.async_get_data.side_effect = None - mock_opendata_client.connections = json.loads( - await async_load_fixture(hass, "connections.json", DOMAIN) + mock_opendata_client.connections = ( + await async_load_json_array_fixture(hass, "connections.json", DOMAIN) )[6:9] freezer.tick(DEFAULT_UPDATE_TIME) async_fire_time_changed(hass) diff --git a/tests/components/swiss_public_transport/test_services.py b/tests/components/swiss_public_transport/test_services.py index d4a7104041bbc3..7bdd4ba4b2bd59 100644 --- a/tests/components/swiss_public_transport/test_services.py +++ b/tests/components/swiss_public_transport/test_services.py @@ -1,6 +1,5 @@ """Test the swiss_public_transport service.""" -import json import logging from unittest.mock import AsyncMock, patch @@ -27,7 +26,7 @@ from . import setup_integration -from tests.common import MockConfigEntry, async_load_fixture +from tests.common import MockConfigEntry, async_load_json_array_fixture _LOGGER = logging.getLogger(__name__) @@ -68,8 +67,8 @@ async def test_service_call_fetch_connections_success( "homeassistant.components.swiss_public_transport.OpendataTransport", return_value=AsyncMock(), ) as mock: - mock().connections = json.loads( - await async_load_fixture(hass, "connections.json", DOMAIN) + mock().connections = ( + await async_load_json_array_fixture(hass, "connections.json", DOMAIN) )[0 : data.get(ATTR_LIMIT, CONNECTIONS_COUNT) + 2] await setup_integration(hass, config_entry) @@ -136,8 +135,8 @@ async def test_service_call_fetch_connections_error( "homeassistant.components.swiss_public_transport.OpendataTransport", return_value=AsyncMock(), ) as mock: - mock().connections = json.loads( - await async_load_fixture(hass, "connections.json", DOMAIN) + mock().connections = await async_load_json_array_fixture( + hass, "connections.json", DOMAIN ) await setup_integration(hass, config_entry) @@ -178,8 +177,8 @@ async def test_service_call_load_unload( "homeassistant.components.swiss_public_transport.OpendataTransport", return_value=AsyncMock(), ) as mock: - mock().connections = json.loads( - await async_load_fixture(hass, "connections.json", DOMAIN) + mock().connections = await async_load_json_array_fixture( + hass, "connections.json", DOMAIN ) await setup_integration(hass, config_entry) diff --git a/tests/components/switchbee/test_config_flow.py b/tests/components/switchbee/test_config_flow.py index e2bd8fedee3c0b..92aa94c0b2c2cd 100644 --- a/tests/components/switchbee/test_config_flow.py +++ b/tests/components/switchbee/test_config_flow.py @@ -1,6 +1,5 @@ """Test the SwitchBee Smart Home config flow.""" -import json from unittest.mock import patch import pytest @@ -14,15 +13,15 @@ from . import MOCK_FAILED_TO_LOGIN_MSG, MOCK_INVALID_TOKEN_MGS -from tests.common import MockConfigEntry, async_load_fixture +from tests.common import MockConfigEntry, async_load_json_object_fixture @pytest.mark.parametrize("test_cucode_in_coordinator_data", [False, True]) async def test_form(hass: HomeAssistant, test_cucode_in_coordinator_data) -> None: """Test we get the form.""" - coordinator_data = json.loads( - await async_load_fixture(hass, "switchbee.json", DOMAIN) + coordinator_data = await async_load_json_object_fixture( + hass, "switchbee.json", DOMAIN ) if test_cucode_in_coordinator_data: @@ -140,8 +139,8 @@ async def test_form_unknown_error(hass: HomeAssistant) -> None: async def test_form_entry_exists(hass: HomeAssistant) -> None: """Test we handle an already existing entry.""" - coordinator_data = json.loads( - await async_load_fixture(hass, "switchbee.json", DOMAIN) + coordinator_data = await async_load_json_object_fixture( + hass, "switchbee.json", DOMAIN ) MockConfigEntry( unique_id="a8:21:08:e7:67:b6", diff --git a/tests/components/switchbot_cloud/fixtures/sensor_status.json b/tests/components/switchbot_cloud/fixtures/sensor_status.json index d54cf269b5722d..ee53b292cef303 100644 --- a/tests/components/switchbot_cloud/fixtures/sensor_status.json +++ b/tests/components/switchbot_cloud/fixtures/sensor_status.json @@ -209,6 +209,16 @@ "doorState": "open", "calibrate": true }, + { + "deviceId": "7E50C3AEE222", + "deviceType": "Smart Lock Ultra Max", + "hubDeviceId": "FFFFFFFEEFF", + "battery": 100, + "version": "V6.3", + "lockState": "unlock", + "doorState": "open", + "calibrate": true + }, { "deviceId": "6D4F9C2A0E8B", "deviceType": "Lock Vision", diff --git a/tests/components/switchbot_cloud/snapshots/test_binary_sensor.ambr b/tests/components/switchbot_cloud/snapshots/test_binary_sensor.ambr index 3f1f8c46d2f20e..0f8b14732f6329 100644 --- a/tests/components/switchbot_cloud/snapshots/test_binary_sensor.ambr +++ b/tests/components/switchbot_cloud/snapshots/test_binary_sensor.ambr @@ -1223,6 +1223,108 @@ 'state': 'off', }) # --- +# name: test_coordinator_data[Smart Lock Ultra Max][binary_sensor.test_device_name_1_calibration-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_device_name_1_calibration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Calibration', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Calibration', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'calibration', + 'unique_id': 'test-device-id-1_calibrate', + 'unit_of_measurement': None, + }) +# --- +# name: test_coordinator_data[Smart Lock Ultra Max][binary_sensor.test_device_name_1_calibration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : 'test-device-name-1 Calibration', + }), + 'context': , + 'entity_id': 'binary_sensor.test_device_name_1_calibration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_coordinator_data[Smart Lock Ultra Max][binary_sensor.test_device_name_1_door-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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_device_name_1_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Door', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-device-id-1_doorState', + 'unit_of_measurement': None, + }) +# --- +# name: test_coordinator_data[Smart Lock Ultra Max][binary_sensor.test_device_name_1_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'door', + : 'test-device-name-1 Door', + }), + 'context': , + 'entity_id': 'binary_sensor.test_device_name_1_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_coordinator_data[Smart Lock Ultra][binary_sensor.test_device_name_1_calibration-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -2906,6 +3008,108 @@ 'state': 'unknown', }) # --- +# name: test_no_coordinator_data[Smart Lock Ultra Max][binary_sensor.test_device_name_1_calibration-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_device_name_1_calibration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Calibration', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Calibration', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'calibration', + 'unique_id': 'test-device-id-1_calibrate', + 'unit_of_measurement': None, + }) +# --- +# name: test_no_coordinator_data[Smart Lock Ultra Max][binary_sensor.test_device_name_1_calibration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : 'test-device-name-1 Calibration', + }), + 'context': , + 'entity_id': 'binary_sensor.test_device_name_1_calibration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_no_coordinator_data[Smart Lock Ultra Max][binary_sensor.test_device_name_1_door-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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_device_name_1_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Door', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-device-id-1_doorState', + 'unit_of_measurement': None, + }) +# --- +# name: test_no_coordinator_data[Smart Lock Ultra Max][binary_sensor.test_device_name_1_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'door', + : 'test-device-name-1 Door', + }), + 'context': , + 'entity_id': 'binary_sensor.test_device_name_1_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_no_coordinator_data[Smart Lock Ultra][binary_sensor.test_device_name_1_calibration-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/switchbot_cloud/snapshots/test_sensor.ambr b/tests/components/switchbot_cloud/snapshots/test_sensor.ambr index 85ddbf7df35245..c0e1952edd9576 100644 --- a/tests/components/switchbot_cloud/snapshots/test_sensor.ambr +++ b/tests/components/switchbot_cloud/snapshots/test_sensor.ambr @@ -2976,6 +2976,131 @@ 'state': '100', }) # --- +# name: test_coordinator_data[Smart Lock Ultra Max][sensor.test_device_name_1_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_device_name_1_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-device-id-1_battery', + 'unit_of_measurement': , + }) +# --- +# name: test_coordinator_data[Smart Lock Ultra Max][sensor.test_device_name_1_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'battery', + : 'test-device-name-1 Battery', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.test_device_name_1_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_coordinator_data[Smart Lock Ultra Max][sensor.test_device_name_1_lock_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'locked', + 'unlocked', + 'locking', + 'unlocking', + 'jammed', + 'latch_bolt_locked', + 'half_locked', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_device_name_1_lock_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lock state', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lock state', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lock_state', + 'unique_id': 'test-device-id-1_lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_coordinator_data[Smart Lock Ultra Max][sensor.test_device_name_1_lock_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'test-device-name-1 Lock state', + : list([ + 'locked', + 'unlocked', + 'locking', + 'unlocking', + 'jammed', + 'latch_bolt_locked', + 'half_locked', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_device_name_1_lock_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_coordinator_data[Smart Lock Ultra][sensor.test_device_name_1_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -7214,6 +7339,131 @@ 'state': 'unknown', }) # --- +# name: test_no_coordinator_data[Smart Lock Ultra Max][sensor.test_device_name_1_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_device_name_1_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-device-id-1_battery', + 'unit_of_measurement': , + }) +# --- +# name: test_no_coordinator_data[Smart Lock Ultra Max][sensor.test_device_name_1_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'battery', + : 'test-device-name-1 Battery', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.test_device_name_1_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_no_coordinator_data[Smart Lock Ultra Max][sensor.test_device_name_1_lock_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'locked', + 'unlocked', + 'locking', + 'unlocking', + 'jammed', + 'latch_bolt_locked', + 'half_locked', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_device_name_1_lock_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lock state', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lock state', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lock_state', + 'unique_id': 'test-device-id-1_lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_no_coordinator_data[Smart Lock Ultra Max][sensor.test_device_name_1_lock_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'test-device-name-1 Lock state', + : list([ + 'locked', + 'unlocked', + 'locking', + 'unlocking', + 'jammed', + 'latch_bolt_locked', + 'half_locked', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_device_name_1_lock_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_no_coordinator_data[Smart Lock Ultra][sensor.test_device_name_1_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/switchbot_cloud/test_lock.py b/tests/components/switchbot_cloud/test_lock.py index c43013555a234c..b7c0f500b12b01 100644 --- a/tests/components/switchbot_cloud/test_lock.py +++ b/tests/components/switchbot_cloud/test_lock.py @@ -30,6 +30,7 @@ ("Lock Vision", 4), ("Lock Vision Pro", 5), ("Smart Lock Pro Wifi", 6), + ("Smart Lock Ultra Max", 7), ], ) async def test_lock( @@ -74,6 +75,7 @@ async def test_lock( ("Smart Lock", 0), ("Smart Lock Pro", 1), ("Smart Lock Ultra", 2), + ("Smart Lock Ultra Max", 3), ("Smart Lock Pro Wifi", 5), ], ) diff --git a/tests/components/tedee/conftest.py b/tests/components/tedee/conftest.py index 40619599e9934e..7f1992b6771b4f 100644 --- a/tests/components/tedee/conftest.py +++ b/tests/components/tedee/conftest.py @@ -1,7 +1,6 @@ """Fixtures for Tedee integration tests.""" from collections.abc import Generator -import json from unittest.mock import AsyncMock, MagicMock, patch from aiotedee.models import TedeeBridge, TedeeLock @@ -13,7 +12,7 @@ from . import setup_integration -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_array_fixture WEBHOOK_ID = "bq33efxmdi3vxy55q2wbnudbra7iv8mjrq9x0gea33g4zqtd87093pwveg8xcb33" @@ -66,7 +65,7 @@ def mock_tedee() -> Generator[MagicMock]: tedee.register_webhook.return_value = 1 tedee.delete_webhooks.return_value = None - locks_json = json.loads(load_fixture("locks.json", DOMAIN)) + locks_json = load_json_array_fixture("locks.json", DOMAIN) lock_list = [TedeeLock.from_dict(lock) for lock in locks_json] tedee.locks_dict = {lock.id: lock for lock in lock_list} diff --git a/tests/components/tomorrowio/conftest.py b/tests/components/tomorrowio/conftest.py index 3e7095210fec6a..05f788d614f17f 100644 --- a/tests/components/tomorrowio/conftest.py +++ b/tests/components/tomorrowio/conftest.py @@ -1,11 +1,10 @@ """Configure py.test.""" -import json from unittest.mock import PropertyMock, patch import pytest -from tests.common import load_fixture +from tests.common import load_json_object_fixture @pytest.fixture(name="tomorrowio_config_flow_connect", autouse=True) @@ -24,7 +23,7 @@ def tomorrowio_config_entry_update_fixture(): with ( patch( "homeassistant.components.tomorrowio.TomorrowioV4.realtime_and_all_forecasts", - return_value=json.loads(load_fixture("v4.json", "tomorrowio")), + return_value=load_json_object_fixture("v4.json", "tomorrowio"), ) as mock_update, patch( "homeassistant.components.tomorrowio.TomorrowioV4.max_requests_per_day", diff --git a/tests/components/tplink/test_diagnostics.py b/tests/components/tplink/test_diagnostics.py index 5587e2af655576..50c2bf552fc8b3 100644 --- a/tests/components/tplink/test_diagnostics.py +++ b/tests/components/tplink/test_diagnostics.py @@ -1,7 +1,5 @@ """Tests for the diagnostics data provided by the TP-Link integration.""" -import json - from kasa import Device import pytest @@ -10,7 +8,7 @@ from . import _mocked_device, initialize_config_entry_for_device -from tests.common import async_load_fixture +from tests.common import async_load_json_object_fixture from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -41,7 +39,7 @@ async def test_diagnostics( expected_oui: str | None, ) -> None: """Test diagnostics for config entry.""" - diagnostics_data = json.loads(await async_load_fixture(hass, fixture_file, DOMAIN)) + diagnostics_data = await async_load_json_object_fixture(hass, fixture_file, DOMAIN) mocked_dev.internal_state = diagnostics_data["device_last_response"] diff --git a/tests/components/tplink_omada/test_diagnostics.py b/tests/components/tplink_omada/test_diagnostics.py index 3bb8beacebcead..c34ed78bd375a0 100644 --- a/tests/components/tplink_omada/test_diagnostics.py +++ b/tests/components/tplink_omada/test_diagnostics.py @@ -9,7 +9,7 @@ from homeassistant.components.tplink_omada.const import DOMAIN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, async_load_fixture +from tests.common import MockConfigEntry, async_load_json_array_fixture from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -21,8 +21,8 @@ async def test_entry_diagnostics( snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics payload and redaction.""" - connected_clients_data = json.loads( - await async_load_fixture(hass, "connected-clients.json", DOMAIN) + connected_clients_data = await async_load_json_array_fixture( + hass, "connected-clients.json", DOMAIN ) controller = init_integration.runtime_data diff --git a/tests/components/trace/test_websocket_api.py b/tests/components/trace/test_websocket_api.py index b30ed27abaa3da..8c911e4d686b18 100644 --- a/tests/components/trace/test_websocket_api.py +++ b/tests/components/trace/test_websocket_api.py @@ -19,7 +19,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util.uuid import random_uuid_hex -from tests.common import async_load_fixture +from tests.common import async_load_json_object_fixture from tests.typing import WebSocketGenerator @@ -452,8 +452,8 @@ def next_id(): msg_id += 1 return msg_id - saved_traces = json.loads( - await async_load_fixture(hass, f"{domain}_saved_traces.json", "trace") + saved_traces = await async_load_json_object_fixture( + hass, f"{domain}_saved_traces.json", "trace" ) hass_storage["trace.saved_traces"] = saved_traces await _setup_automation_or_script(hass, domain, []) @@ -633,8 +633,8 @@ def next_id(): msg_id += 1 return msg_id - saved_traces = json.loads( - await async_load_fixture(hass, f"{domain}_saved_traces.json", "trace") + saved_traces = await async_load_json_object_fixture( + hass, f"{domain}_saved_traces.json", "trace" ) hass_storage["trace.saved_traces"] = saved_traces sun_config = { @@ -716,8 +716,8 @@ def next_id(): msg_id += 1 return msg_id - saved_traces = json.loads( - await async_load_fixture(hass, f"{domain}_saved_traces.json", "trace") + saved_traces = await async_load_json_object_fixture( + hass, f"{domain}_saved_traces.json", "trace" ) hass_storage["trace.saved_traces"] = saved_traces sun_config = { diff --git a/tests/components/velbus/conftest.py b/tests/components/velbus/conftest.py index 643f9f73e7a459..947a527cc8e60a 100644 --- a/tests/components/velbus/conftest.py +++ b/tests/components/velbus/conftest.py @@ -177,6 +177,7 @@ def mock_select() -> AsyncMock: channel = AsyncMock(spec=SelectedProgram) channel.get_categories.return_value = ["select"] channel.get_name.return_value = "select" + channel.get_property_key.return_value = "SelectedProgram" channel.get_module_address.return_value = 88 channel.get_channel_number.return_value = 33 channel.get_module_type_name.return_value = "VMB4RYNO" @@ -241,8 +242,10 @@ def mock_lightsensor() -> AsyncMock: channel = AsyncMock(spec=LightValue) channel.get_categories.return_value = ["sensor"] channel.get_name.return_value = "LightSensor" + channel.get_property_key.return_value = "LightValue" channel.get_module_address.return_value = 2 - channel.get_channel_number.return_value = 4 + # Properties always report channel number 0 (Property.get_channel_number) + channel.get_channel_number.return_value = 0 channel.get_module_type_name.return_value = "VMB7IN" channel.get_module_type.return_value = 8 channel.get_full_name.return_value = "Input" diff --git a/tests/components/velbus/snapshots/test_select.ambr b/tests/components/velbus/snapshots/test_select.ambr index d7bb0ed05b45e5..ee2e95e9a6eb65 100644 --- a/tests/components/velbus/snapshots/test_select.ambr +++ b/tests/components/velbus/snapshots/test_select.ambr @@ -39,7 +39,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'select_program', - 'unique_id': 'qwerty1234567-33-program_select', + 'unique_id': 'qwerty1234567-SelectedProgram', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/velbus/snapshots/test_sensor.ambr b/tests/components/velbus/snapshots/test_sensor.ambr index 6b034623df681b..af0377b6ccd45e 100644 --- a/tests/components/velbus/snapshots/test_sensor.ambr +++ b/tests/components/velbus/snapshots/test_sensor.ambr @@ -151,7 +151,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'a1b2c3d4e5f6-4', + 'unique_id': 'a1b2c3d4e5f6-LightValue', 'unit_of_measurement': 'illuminance', }) # --- diff --git a/tests/components/velbus/test_entity.py b/tests/components/velbus/test_entity.py new file mode 100644 index 00000000000000..cb709c2b510a6e --- /dev/null +++ b/tests/components/velbus/test_entity.py @@ -0,0 +1,34 @@ +"""Tests for the Velbus entity base class.""" + +from unittest.mock import AsyncMock + +import pytest +from velbusaio.channels import Channel as VelbusChannel + +from homeassistant.components.velbus.entity import VelbusEntity + + +@pytest.mark.parametrize( + ("module_serial", "expected_unique_id"), + [ + ("a1b2c3d4e5f6", "a1b2c3d4e5f6-2"), + (None, "5-2"), + ("", "5-2"), + # Modules like the VMB4RY report a serial of "0"; without a fallback two + # such modules would share the same unique_id. + ("0", "5-2"), + ], +) +def test_unique_id_falls_back_to_module_address( + module_serial: str | None, expected_unique_id: str +) -> None: + """Test that a missing or "0" module serial falls back to the module address.""" + channel = AsyncMock(spec=VelbusChannel) + channel.get_module_address.return_value = 5 + channel.get_channel_number.return_value = 2 + channel.get_module_serial.return_value = module_serial + channel.get_name.return_value = "channel" + + entity = VelbusEntity(channel) + + assert entity.unique_id == expected_unique_id diff --git a/tests/components/velbus/test_init.py b/tests/components/velbus/test_init.py index 5d2b275bea10b1..a8a10316e3fc75 100644 --- a/tests/components/velbus/test_init.py +++ b/tests/components/velbus/test_init.py @@ -308,3 +308,211 @@ async def test_remove_config_entry_device_detaches_subdevices( config_entry.entry_id not in sub_device_after.config_entries and sub_device_after.via_device_id is None ) + + +# velbus-aio maps both the spec key and the display name to the class name, because +# Property.get_name() returned the spec key before velbus-aio 2026.4.1 and the display +# name from that release onwards; both forms exist as original_name in the wild. +_PROPERTY_KEY_MAP = { + "selected_program": "SelectedProgram", + "Selected program": "SelectedProgram", + "light_value": "LightValue", + "Light value": "LightValue", +} + + +@pytest.mark.parametrize( + ("domain", "device_serial", "old_unique_id", "original_name", "expected_unique_id"), + [ + pytest.param( + "select", + "test_serial", + "test_serial-0-program_select", + "selected_program", + "test_serial-SelectedProgram", + id="rename_select_spec_key", + ), + pytest.param( + "select", + "test_serial", + "test_serial-0-program_select", + "Selected program", + "test_serial-SelectedProgram", + id="rename_select_display_name", + ), + pytest.param( + "select", + "test_serial", + "test_serial-5-program_select", + "selected_program", + "test_serial-SelectedProgram", + id="rename_select_legacy_channel", + ), + pytest.param( + "sensor", + "test_serial", + "test_serial-0", + "light_value", + "test_serial-LightValue", + id="rename_sensor", + ), + pytest.param( + "sensor", + "overwritten_serial", + "test_serial-0", + "light_value", + "test_serial-LightValue", + id="serial_taken_from_unique_id_not_device", + ), + pytest.param( + "select", + "test_serial", + "test_serial-SelectedProgram", + "selected_program", + "test_serial-SelectedProgram", + id="already_correct", + ), + pytest.param( + "select", + "test_serial", + "test_serial-old_format", + None, + "test_serial-old_format", + id="skipped_without_name", + ), + pytest.param( + "select", + "test_serial", + "test_serial-old_format", + "not_a_property", + "test_serial-old_format", + id="skipped_unknown_name", + ), + pytest.param( + "sensor", + "test_serial", + "test_serial-3", + "light_value", + "test_serial-3", + id="skipped_colliding_channel", + ), + ], +) +async def test_migrate_property_unique_ids( + hass: HomeAssistant, + config_entry: VelbusConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + controller: MagicMock, + domain: str, + device_serial: str, + old_unique_id: str, + original_name: str | None, + expected_unique_id: str, +) -> None: + """Test the property unique_id migration for every legacy and skip case.""" + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "1")}, + serial_number=device_serial, + ) + entity = entity_registry.async_get_or_create( + domain, + DOMAIN, + old_unique_id, + config_entry=config_entry, + device_id=device.id, + original_name=original_name, + ) + + with patch( + "homeassistant.components.velbus.get_property_key_map", + return_value=_PROPERTY_KEY_MAP, + ): + await init_integration(hass, config_entry) + + migrated = entity_registry.async_get(entity.entity_id) + assert migrated + assert migrated.unique_id == expected_unique_id + + +async def test_migrate_property_unique_ids_remove_stale( + hass: HomeAssistant, + config_entry: VelbusConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + controller: MagicMock, +) -> None: + """Test that a stale property entity is removed when the correct one already exists.""" + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "1")}, + serial_number="test_serial", + ) + entity_registry.async_get_or_create( + "select", + DOMAIN, + "test_serial-SelectedProgram", + config_entry=config_entry, + device_id=device.id, + original_name="selected_program", + ) + entity_registry.async_get_or_create( + "select", + DOMAIN, + "test_serial-0-program_select", + config_entry=config_entry, + device_id=device.id, + original_name="selected_program", + ) + + with patch( + "homeassistant.components.velbus.get_property_key_map", + return_value=_PROPERTY_KEY_MAP, + ): + await init_integration(hass, config_entry) + + assert not entity_registry.async_get_entity_id( + "select", DOMAIN, "test_serial-0-program_select" + ) + assert entity_registry.async_get_entity_id( + "select", DOMAIN, "test_serial-SelectedProgram" + ) + + +async def test_migrate_property_unique_ids_preserves_entity_id( + hass: HomeAssistant, + config_entry: VelbusConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + controller: MagicMock, +) -> None: + """Test that a migrated property keeps its entity_id once the bus scan registers it.""" + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "2")}, + serial_number="a1b2c3d4e5f6", + ) + # Same serial as the scanned LightValue property, so migrating before the scan makes + # the scan reuse this entry instead of registering a second one. + legacy_entity = entity_registry.async_get_or_create( + "sensor", + DOMAIN, + "a1b2c3d4e5f6-0", + config_entry=config_entry, + device_id=device.id, + original_name="light_value", + suggested_object_id="legacy_light_value", + ) + assert legacy_entity.entity_id == "sensor.legacy_light_value" + + with patch( + "homeassistant.components.velbus.get_property_key_map", + return_value=_PROPERTY_KEY_MAP, + ): + await init_integration(hass, config_entry) + + assert ( + entity_registry.async_get_entity_id("sensor", DOMAIN, "a1b2c3d4e5f6-LightValue") + == "sensor.legacy_light_value" + ) diff --git a/tests/components/vivotek/test_camera.py b/tests/components/vivotek/test_camera.py index 0c56129b68ad94..b62aeee4047bbe 100644 --- a/tests/components/vivotek/test_camera.py +++ b/tests/components/vivotek/test_camera.py @@ -1,11 +1,23 @@ """Tests for the Vivotek camera integration.""" +from datetime import timedelta from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory +from libpyvivotek.vivotek import VivotekCameraError from syrupy.assertion import SnapshotAssertion +from homeassistant.components.camera import ( + SERVICE_DISABLE_MOTION, + SERVICE_ENABLE_MOTION, + async_get_image, + async_get_stream_source, +) +from homeassistant.components.vivotek.const import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity_component import async_update_entity from . import setup_integration @@ -24,3 +36,148 @@ async def test_all_entities( await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_camera_device_info( + hass: HomeAssistant, + mock_vivotek_camera: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the camera is linked to a device with expected metadata.""" + mock_vivotek_camera.get_serial.return_value = "ABCD1234" + mock_vivotek_camera.get_param.side_effect = lambda key: { + "system_info_firmwareversion": "1.2.3", + "system_info_modelname": "FD9165-HT", + }[key] + + await setup_integration(hass, mock_config_entry) + + entity_entry = entity_registry.async_get("camera.vivotek_camera") + assert entity_entry is not None + assert entity_entry.device_id is not None + + device = device_registry.async_get(entity_entry.device_id) + assert device is not None + assert (DOMAIN, "11:22:33:44:55:66") in device.identifiers + assert device.manufacturer == "VIVOTEK" + assert device.model == "FD9165-HT" + assert device.serial_number == "ABCD1234" + assert device.sw_version == "1.2.3" + + +async def test_camera_device_info_with_metadata_errors( + hass: HomeAssistant, + mock_vivotek_camera: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test metadata fields are omitted when camera metadata calls fail.""" + mock_vivotek_camera.get_serial.side_effect = VivotekCameraError + mock_vivotek_camera.get_param.side_effect = VivotekCameraError + + await setup_integration(hass, mock_config_entry) + + entity_entry = entity_registry.async_get("camera.vivotek_camera") + assert entity_entry is not None + assert entity_entry.device_id is not None + + device = device_registry.async_get(entity_entry.device_id) + assert device is not None + assert device.model is None + assert device.serial_number is None + assert device.sw_version is None + + +async def test_camera_available_when_update_succeeds( + hass: HomeAssistant, + mock_vivotek_camera: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test camera is available when update probe succeeds.""" + await setup_integration(hass, mock_config_entry) + + freezer.tick(timedelta(seconds=1)) + await async_update_entity(hass, "camera.vivotek_camera") + + state = hass.states.get("camera.vivotek_camera") + assert state is not None + assert state.state != STATE_UNAVAILABLE + + +async def test_camera_unavailable_when_update_fails( + hass: HomeAssistant, + mock_vivotek_camera: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test camera is unavailable when update probe raises error.""" + await setup_integration(hass, mock_config_entry) + + mock_vivotek_camera.get_serial.side_effect = VivotekCameraError + freezer.tick(timedelta(seconds=1)) + await async_update_entity(hass, "camera.vivotek_camera") + + state = hass.states.get("camera.vivotek_camera") + assert state is not None + + assert state.state == STATE_UNAVAILABLE + + +async def test_camera_stream_source( + hass: HomeAssistant, + mock_vivotek_camera: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test stream source is returned from camera entity.""" + await setup_integration(hass, mock_config_entry) + + stream_source = await async_get_stream_source(hass, "camera.vivotek_camera") + + assert stream_source == "rtsp://admin:pass1234@1.2.3.4:554//live.sdp" + + +async def test_camera_motion_detection_methods( + hass: HomeAssistant, + mock_vivotek_camera: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test motion detection commands update entity state.""" + await setup_integration(hass, mock_config_entry) + + mock_vivotek_camera.set_param.side_effect = ["1", "0"] + + await hass.services.async_call( + "camera", + SERVICE_ENABLE_MOTION, + {"entity_id": "camera.vivotek_camera"}, + blocking=True, + ) + + await hass.services.async_call( + "camera", + SERVICE_DISABLE_MOTION, + {"entity_id": "camera.vivotek_camera"}, + blocking=True, + ) + + mock_vivotek_camera.set_param.assert_any_call("event_i0_enable", 1) + mock_vivotek_camera.set_param.assert_any_call("event_i0_enable", 0) + + +async def test_camera_image_returns_snapshot( + hass: HomeAssistant, + mock_vivotek_camera: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test camera image comes directly from camera snapshot call.""" + await setup_integration(hass, mock_config_entry) + + mock_vivotek_camera.snapshot.return_value = b"snapshot-bytes" + + image = await async_get_image(hass, "camera.vivotek_camera") + + assert image.content == b"snapshot-bytes" diff --git a/tests/components/yandex_transport/test_sensor.py b/tests/components/yandex_transport/test_sensor.py index dd8e82278f3c1d..0a033b2ed38e4c 100644 --- a/tests/components/yandex_transport/test_sensor.py +++ b/tests/components/yandex_transport/test_sensor.py @@ -1,6 +1,5 @@ """Tests for the yandex transport platform.""" -import json from typing import Any from unittest.mock import AsyncMock, patch @@ -12,11 +11,11 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import assert_setup_component, load_fixture +from tests.common import assert_setup_component, load_json_object_fixture -BUS_REPLY = json.loads(load_fixture("bus_reply.json", "yandex_transport")) -SUBURBAN_TRAIN_REPLY = json.loads( - load_fixture("suburban_reply.json", "yandex_transport") +BUS_REPLY = load_json_object_fixture("bus_reply.json", "yandex_transport") +SUBURBAN_TRAIN_REPLY = load_json_object_fixture( + "suburban_reply.json", "yandex_transport" ) diff --git a/tests/components/youtube/__init__.py b/tests/components/youtube/__init__.py index fd1a6c365791e2..56c5dcd7226bfc 100644 --- a/tests/components/youtube/__init__.py +++ b/tests/components/youtube/__init__.py @@ -1,7 +1,6 @@ """Tests for the YouTube integration.""" from collections.abc import AsyncGenerator -import json from youtubeaio.models import YouTubeChannel, YouTubePlaylistItem, YouTubeSubscription from youtubeaio.types import AuthScope @@ -9,7 +8,7 @@ from homeassistant.components.youtube import DOMAIN from homeassistant.core import HomeAssistant -from tests.common import async_load_fixture +from tests.common import async_load_json_object_fixture class MockYouTube: @@ -39,8 +38,8 @@ async def set_user_authentication( async def get_user_channels(self) -> AsyncGenerator[YouTubeChannel]: """Get channels for authenticated user.""" - channels = json.loads( - await async_load_fixture(self.hass, self._channel_fixture, DOMAIN) + channels = await async_load_json_object_fixture( + self.hass, self._channel_fixture, DOMAIN ) for item in channels["items"]: yield YouTubeChannel(**item) @@ -51,8 +50,8 @@ async def get_channels( """Get channels.""" if self._thrown_error is not None: raise self._thrown_error - channels = json.loads( - await async_load_fixture(self.hass, self._channel_fixture, DOMAIN) + channels = await async_load_json_object_fixture( + self.hass, self._channel_fixture, DOMAIN ) for item in channels["items"]: yield YouTubeChannel(**item) @@ -61,16 +60,16 @@ async def get_playlist_items( self, playlist_id: str, amount: int ) -> AsyncGenerator[YouTubePlaylistItem]: """Get channels.""" - channels = json.loads( - await async_load_fixture(self.hass, self._playlist_items_fixture, DOMAIN) + channels = await async_load_json_object_fixture( + self.hass, self._playlist_items_fixture, DOMAIN ) for item in channels["items"]: yield YouTubePlaylistItem(**item) async def get_user_subscriptions(self) -> AsyncGenerator[YouTubeSubscription]: """Get channels for authenticated user.""" - channels = json.loads( - await async_load_fixture(self.hass, self._subscriptions_fixture, DOMAIN) + channels = await async_load_json_object_fixture( + self.hass, self._subscriptions_fixture, DOMAIN ) for item in channels["items"]: yield YouTubeSubscription(**item) diff --git a/tests/components/zamg/conftest.py b/tests/components/zamg/conftest.py index 9fa4f333ef8684..a9f429d59228e7 100644 --- a/tests/components/zamg/conftest.py +++ b/tests/components/zamg/conftest.py @@ -1,7 +1,6 @@ """Fixtures for Zamg integration tests.""" from collections.abc import Generator -import json from unittest.mock import MagicMock, patch import pytest @@ -10,7 +9,7 @@ from homeassistant.components.zamg.const import CONF_STATION_ID, DOMAIN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_object_fixture TEST_STATION_ID = "11240" TEST_STATION_NAME = "Graz/Flughafen" @@ -44,7 +43,7 @@ def mock_zamg_config_flow() -> Generator[MagicMock]: ) as zamg_mock: zamg = zamg_mock.return_value zamg.update.return_value = ZamgDevice( - json.loads(load_fixture("zamg/data.json")) + load_json_object_fixture("zamg/data.json") ) zamg.get_data.return_value = zamg.get_data(TEST_STATION_ID) yield zamg diff --git a/tests/components/zinvolt/test_number.py b/tests/components/zinvolt/test_number.py index d567f6ae2f0443..3fced3691d2973 100644 --- a/tests/components/zinvolt/test_number.py +++ b/tests/components/zinvolt/test_number.py @@ -13,7 +13,11 @@ from . import setup_integration -from tests.common import MockConfigEntry, async_load_fixture, snapshot_platform +from tests.common import ( + MockConfigEntry, + async_load_json_object_fixture, + snapshot_platform, +) async def test_all_entities( @@ -36,8 +40,8 @@ async def test_max_output_when_unlocked( mock_config_entry: MockConfigEntry, ) -> None: """Test max_output value stays within its own bound once output is unlocked.""" - fixture_data = json.loads( - await async_load_fixture(hass, "current_state.json", DOMAIN) + fixture_data = await async_load_json_object_fixture( + hass, "current_state.json", DOMAIN ) fixture_data["globalSettings"]["maxOutputUnlocked"] = True mock_zinvolt_client.get_battery_status.return_value = BatteryState.from_json( diff --git a/tests/components/zwave_js/scripts/test_convert_device_diagnostics_to_fixture.py b/tests/components/zwave_js/scripts/test_convert_device_diagnostics_to_fixture.py index a5c5e4475ce649..34fff7d973203c 100644 --- a/tests/components/zwave_js/scripts/test_convert_device_diagnostics_to_fixture.py +++ b/tests/components/zwave_js/scripts/test_convert_device_diagnostics_to_fixture.py @@ -15,7 +15,7 @@ main, ) -from tests.common import load_fixture +from tests.common import load_fixture, load_json_object_fixture def _minify(text: str) -> str: @@ -25,7 +25,7 @@ def _minify(text: str) -> str: def test_fixture_functions() -> None: """Test functions related to the fixture.""" - diagnostics_data = json.loads(load_fixture("zwave_js/device_diagnostics.json")) + diagnostics_data = load_json_object_fixture("zwave_js/device_diagnostics.json") state = extract_fixture_data(copy.deepcopy(diagnostics_data)) assert isinstance(state["values"], list) assert ( @@ -54,7 +54,7 @@ def test_load_file() -> None: """Test load file.""" assert load_file( Path(__file__).parents[1] / "fixtures" / "device_diagnostics.json" - ) == json.loads(load_fixture("zwave_js/device_diagnostics.json")) + ) == load_json_object_fixture("zwave_js/device_diagnostics.json") def test_main(capfd: pytest.CaptureFixture[str]) -> None: diff --git a/tests/pylint/test_json_fixture.py b/tests/pylint/test_json_fixture.py new file mode 100644 index 00000000000000..c4383f13e8e937 --- /dev/null +++ b/tests/pylint/test_json_fixture.py @@ -0,0 +1,125 @@ +"""Tests for the JSON fixture checker.""" + +import astroid +from pylint.testutils import MessageTest, UnittestLinter +from pylint_home_assistant.checkers.json_fixture import HassJsonFixtureChecker +import pytest + +from . import assert_adds_messages, assert_no_messages, walk_checker + + +@pytest.fixture(name="json_fixture_checker") +def json_fixture_checker_fixture( + linter: UnittestLinter, +) -> HassJsonFixtureChecker: + """Fixture to provide a JSON fixture checker.""" + return HassJsonFixtureChecker(linter) + + +@pytest.mark.parametrize( + "code", + [ + pytest.param( + "value = json.loads(load_fixture('data.json', 'my_integration'))", + id="json_loads_load_fixture", + ), + pytest.param( + "value = json_loads(load_fixture('data.json'))", + id="json_loads_helper", + ), + pytest.param( + "value = json_loads_object(load_fixture('data.json'))", + id="json_loads_object", + ), + pytest.param( + "value = json_loads_array(load_fixture_bytes('data.json'))", + id="json_loads_array_bytes", + ), + pytest.param( + "value = json.loads(await async_load_fixture(hass, 'data.json'))", + id="json_loads_async_load_fixture", + ), + ], +) +def test_flagged( + linter: UnittestLinter, + json_fixture_checker: HassJsonFixtureChecker, + code: str, +) -> None: + """Test cases that should be flagged.""" + root_node = astroid.parse(code, "tests.components.my_integration.test_sensor") + call_node = next(root_node.nodes_of_class(astroid.nodes.Call)) + + with assert_adds_messages( + linter, + MessageTest( + msg_id="home-assistant-json-fixture", + node=call_node, + line=call_node.lineno, + col_offset=call_node.col_offset, + end_line=call_node.end_lineno, + end_col_offset=call_node.end_col_offset, + ), + ): + walk_checker(linter, json_fixture_checker, root_node) + + +@pytest.mark.parametrize( + "code", + [ + pytest.param( + "value = load_json_object_fixture('data.json', 'my_integration')", + id="json_object_fixture_helper", + ), + pytest.param( + "value = json.loads(some_string)", + id="json_loads_non_fixture", + ), + pytest.param( + "value = json.dumps(load_fixture('data.json'))", + id="json_dumps_load_fixture", + ), + pytest.param( + "value = load_fixture('data.json')", + id="load_fixture_only", + ), + ], +) +def test_not_flagged( + linter: UnittestLinter, + json_fixture_checker: HassJsonFixtureChecker, + code: str, +) -> None: + """Test cases that should not be flagged.""" + root_node = astroid.parse(code, "tests.components.my_integration.test_sensor") + + with assert_no_messages(linter): + walk_checker(linter, json_fixture_checker, root_node) + + +def test_not_flagged_outside_test_module( + linter: UnittestLinter, + json_fixture_checker: HassJsonFixtureChecker, +) -> None: + """Test that non-test modules are ignored.""" + root_node = astroid.parse( + "value = json.loads(load_fixture('data.json'))", + "homeassistant.components.my_integration.sensor", + ) + + with assert_no_messages(linter): + walk_checker(linter, json_fixture_checker, root_node) + + +def test_not_flagged_in_tests_common( + linter: UnittestLinter, + json_fixture_checker: HassJsonFixtureChecker, +) -> None: + """Test that the fixture helper definitions in tests.common are ignored.""" + root_node = astroid.parse( + "value = json_loads_object(load_fixture('data.json'))", + "tests.common", + ) + + with assert_no_messages(linter): + walk_checker(linter, json_fixture_checker, root_node) diff --git a/tests/util/test_package.py b/tests/util/test_package.py index fbeaa031554ae0..0b3e1de70fcfd0 100644 --- a/tests/util/test_package.py +++ b/tests/util/test_package.py @@ -552,13 +552,12 @@ def test_check_package_global(caplog: pytest.LogCaptureFixture) -> None: def test_check_package_fragment(caplog: pytest.LogCaptureFixture) -> None: """Test for an installed package with a fragment.""" + url = "git+https://github.com/home-assistant/core" + assert not package.is_installed(TEST_ZIP_REQ) - assert package.is_installed("git+https://github.com/pypa/pip#pip>=1") - assert not package.is_installed("git+https://github.com/pypa/pip#-1 invalid") - assert ( - "Invalid requirement 'git+https://github.com/pypa/pip#-1 invalid'" - in caplog.text - ) + assert package.is_installed(f"{url}#homeassistant>=1") + assert not package.is_installed(f"{url}#-1 invalid") + assert f"Invalid requirement '{url}#-1 invalid'" in caplog.text def test_get_is_installed() -> None: diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index a729c43dd46da8..f37011646f1171 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -1,7 +1,6 @@ """Test Home Assistant unit conversion utility functions.""" import inspect -from itertools import chain import pytest @@ -1282,38 +1281,22 @@ def test_all_converters(converter: type[BaseUnitConverter]) -> None: ), f"Unit `{valid_unit}` is not tested in _CONVERTED_VALUE" -@pytest.mark.parametrize( - ("converter", "valid_unit"), - [ - # Ensure all units are tested - (converter, valid_unit) - for converter, valid_units in _ALL_CONVERTERS.items() - for valid_unit in valid_units - ], -) -def test_convert_same_unit(converter: type[BaseUnitConverter], valid_unit: str) -> None: +@pytest.mark.parametrize("converter", _ALL_CONVERTERS) +def test_convert_same_unit(converter: type[BaseUnitConverter]) -> None: """Test conversion from any valid unit to same unit.""" - assert converter.convert(2, valid_unit, valid_unit) == 2 + for valid_unit in _ALL_CONVERTERS[converter]: + assert converter.convert(2, valid_unit, valid_unit) == 2 -@pytest.mark.parametrize( - ("converter", "valid_unit"), - [ - # Ensure all units are tested - (converter, valid_unit) - for converter, valid_units in _ALL_CONVERTERS.items() - for valid_unit in valid_units - ], -) -def test_convert_invalid_unit( - converter: type[BaseUnitConverter], valid_unit: str -) -> None: +@pytest.mark.parametrize("converter", _ALL_CONVERTERS) +def test_convert_invalid_unit(converter: type[BaseUnitConverter]) -> None: """Test exception is thrown for invalid units.""" - with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): - converter.convert(5, INVALID_SYMBOL, valid_unit) + for valid_unit in _ALL_CONVERTERS[converter]: + with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): + converter.convert(5, INVALID_SYMBOL, valid_unit) - with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): - converter.convert(5, valid_unit, INVALID_SYMBOL) + with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): + converter.convert(5, valid_unit, INVALID_SYMBOL) @pytest.mark.parametrize( @@ -1370,46 +1353,22 @@ def get_unit_floored_log_ratio( assert converter.get_unit_floored_log_ratio(to_unit, from_unit) == 1 / ratio -@pytest.mark.parametrize( - ("converter", "value", "from_unit", "expected", "to_unit"), - [ - # Process all items in _CONVERTED_VALUE - (converter, value, from_unit, expected, to_unit) - for converter, item in _CONVERTED_VALUE.items() - for value, from_unit, expected, to_unit in item - ], -) -def test_unit_conversion( - converter: type[BaseUnitConverter], - value: float, - from_unit: str, - expected: float, - to_unit: str, -) -> None: +@pytest.mark.parametrize("converter", _CONVERTED_VALUE) +def test_unit_conversion(converter: type[BaseUnitConverter]) -> None: """Test conversion to other units.""" - assert converter.convert(value, from_unit, to_unit) == pytest.approx(expected) + for value, from_unit, expected, to_unit in _CONVERTED_VALUE[converter]: + assert converter.convert(value, from_unit, to_unit) == pytest.approx( + expected + ), f"{value} {from_unit} to {to_unit}" -@pytest.mark.parametrize( - ("converter", "value", "from_unit", "expected", "to_unit"), - [ - # Process all items in _CONVERTED_VALUE - (converter, value, from_unit, expected, to_unit) - for converter, item in _CONVERTED_VALUE.items() - for value, from_unit, expected, to_unit in item - ], -) -def test_unit_conversion_factory( - converter: type[BaseUnitConverter], - value: float, - from_unit: str, - expected: float, - to_unit: str, -) -> None: +@pytest.mark.parametrize("converter", _CONVERTED_VALUE) +def test_unit_conversion_factory(converter: type[BaseUnitConverter]) -> None: """Test conversion to other units.""" - assert converter.converter_factory(from_unit, to_unit)(value) == pytest.approx( - expected - ) + for value, from_unit, expected, to_unit in _CONVERTED_VALUE[converter]: + assert converter.converter_factory(from_unit, to_unit)(value) == pytest.approx( + expected + ), f"{value} {from_unit} to {to_unit}" def test_unit_conversion_factory_allow_none_with_none() -> None: @@ -1504,34 +1463,17 @@ def test_unit_conversion_factory_allow_none_with_zero_for_inverse_units() -> Non )(25) == pytest.approx(4) -@pytest.mark.parametrize( - ("converter", "value", "from_unit", "expected", "to_unit"), - chain( - [ - # Process all items in _CONVERTED_VALUE - (converter, value, from_unit, expected, to_unit) - for converter, item in _CONVERTED_VALUE.items() - for value, from_unit, expected, to_unit in item - ], - [ - # Process all items in _CONVERTED_VALUE and replace the value with None - (converter, None, from_unit, None, to_unit) - for converter, item in _CONVERTED_VALUE.items() - for value, from_unit, expected, to_unit in item - ], - ), -) +@pytest.mark.parametrize("converter", _CONVERTED_VALUE) def test_unit_conversion_factory_allow_none( converter: type[BaseUnitConverter], - value: float, - from_unit: str, - expected: float, - to_unit: str, ) -> None: - """Test conversion to other units.""" - assert converter.converter_factory_allow_none(from_unit, to_unit)( - value - ) == pytest.approx(expected) + """Test conversion to other units, and that None is passed through.""" + for value, from_unit, expected, to_unit in _CONVERTED_VALUE[converter]: + convert = converter.converter_factory_allow_none(from_unit, to_unit) + assert convert(value) == pytest.approx(expected), ( + f"{value} {from_unit} to {to_unit}" + ) + assert convert(None) is None, f"None {from_unit} to {to_unit}" @pytest.mark.parametrize(