diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 9386de6f4d081..0000000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,21 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: daily - time: "06:00" - open-pull-requests-limit: 10 - labels: - - dependency - - github_actions - cooldown: - default-days: 7 - groups: - codeql-action: - patterns: - - "github/codeql-action/*" - ignore: - # Managed by gh aw compile. Version-locked to the gh-aw compiler; do not bump. - - dependency-name: "github/gh-aw-actions/**" - - dependency-name: "github/gh-aw-actions" diff --git a/.github/renovate.json b/.github/renovate.json index ff8501778252c..05657543aa157 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -9,6 +9,7 @@ "dockerfile", "npm", "custom.regex", + "github-actions", "homeassistant-manifest" ], @@ -55,7 +56,7 @@ ], "minimumReleaseAge": "7 days", - "prConcurrentLimit": 10, + "prConcurrentLimit": 0, "prHourlyLimit": 2, "schedule": ["before 6am"], @@ -183,6 +184,22 @@ "enabled": true, "labels": ["dependency"] }, + { + "description": "GitHub Actions used in our workflows (allowlisted). Runner labels and action version inputs are deliberately left out.", + "matchManagers": ["github-actions"], + "matchDepTypes": ["action", "workflow", "docker", "container", "service"], + "enabled": true, + "labels": ["dependency", "github_actions"] + }, + { + "description": "Managed by gh aw compile. Version-locked to the gh-aw compiler; do not bump.", + "matchPackageNames": [ + "github/gh-aw-actions", + "/^github\\/gh-aw-actions\\//", + "/^ghcr\\.io\\/github\\/gh-aw/" + ], + "enabled": false + }, { "description": "pnpm version pinned in the E2E tests packageManager field (allowlisted)", "matchManagers": ["npm"], @@ -192,6 +209,14 @@ "enabled": true, "labels": ["dependency"] }, + { + "description": "Playwright pinned in the E2E tests (allowlisted)", + "matchManagers": ["npm"], + "matchFileNames": ["tests/e2e/package.json"], + "matchPackageNames": ["@playwright/test"], + "enabled": true, + "labels": ["dependency"] + }, { "description": "For types-* stubs, only allow patch updates. Major/minor bumps track the upstream runtime package version and must be manually coordinated with the corresponding pin.", "matchPackageNames": ["/^types-/"], @@ -247,6 +272,18 @@ "groupName": "pylint", "groupSlug": "pylint" }, + { + "description": "Group the github/codeql-action subactions into one PR (they share a release)", + "matchPackageNames": ["/^github\\/codeql-action(?:\\/|$)/"], + "groupName": "github/codeql-action", + "groupSlug": "codeql-action" + }, + { + "description": "Group the actions/cache subactions into one PR (they share a release)", + "matchPackageNames": ["/^actions\\/cache(?:\\/|$)/"], + "groupName": "actions/cache", + "groupSlug": "actions-cache" + }, { "description": "Group go2rtc Dockerfile pin with const.py RECOMMENDED_VERSION into one PR", "matchPackageNames": ["ghcr.io/alexxit/go2rtc"], diff --git a/homeassistant/components/alexa_devices/binary_sensor.py b/homeassistant/components/alexa_devices/binary_sensor.py index c16da0e7af3d2..bd7b41c6d5d29 100644 --- a/homeassistant/components/alexa_devices/binary_sensor.py +++ b/homeassistant/components/alexa_devices/binary_sensor.py @@ -32,7 +32,7 @@ class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription): """Alexa Devices binary sensor entity description.""" is_on_fn: Callable[[AmazonDevice, str], bool] - is_supported: Callable[[AmazonDevice, str], bool] = lambda device, key: True + is_supported_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: True is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: True @@ -49,7 +49,7 @@ class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription): is_on_fn=lambda device, key: bool( device.sensors[key].value != SENSOR_STATE_OFF ), - is_supported=lambda device, key: device.sensors.get(key) is not None, + is_supported_fn=lambda device, key: device.sensors.get(key) is not None, is_available_fn=lambda device, key: ( device.online and (sensor := device.sensors.get(key)) is not None @@ -127,6 +127,7 @@ async def async_setup_entry( def _check_device() -> None: current_devices = set(coordinator.data) + known_devices.intersection_update(current_devices) new_devices = current_devices - known_devices if new_devices: known_devices.update(new_devices) @@ -134,7 +135,7 @@ def _check_device() -> None: AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc) for sensor_desc in BINARY_SENSORS for serial_num in new_devices - if sensor_desc.is_supported( + if sensor_desc.is_supported_fn( coordinator.data[serial_num], sensor_desc.key ) ) diff --git a/homeassistant/components/alexa_devices/button.py b/homeassistant/components/alexa_devices/button.py index a9712201ca50e..73cac4afb0373 100644 --- a/homeassistant/components/alexa_devices/button.py +++ b/homeassistant/components/alexa_devices/button.py @@ -51,6 +51,7 @@ async def async_setup_entry( def _check_routines_devices() -> None: current_routines = set(coordinator.api.routines) + known_routines.intersection_update(current_routines) new_routines = current_routines - known_routines if new_routines: known_routines.update(new_routines) @@ -59,6 +60,7 @@ def _check_routines_devices() -> None: ) current_devices = set(coordinator.data) + known_devices.intersection_update(current_devices) new_devices = current_devices - known_devices if new_devices: known_devices.update(new_devices) diff --git a/homeassistant/components/alexa_devices/event.py b/homeassistant/components/alexa_devices/event.py index bc343f2d29d48..16a0b9f47e65e 100644 --- a/homeassistant/components/alexa_devices/event.py +++ b/homeassistant/components/alexa_devices/event.py @@ -47,6 +47,7 @@ async def async_setup_entry( def _check_device() -> None: current_devices = set(coordinator.data) + known_devices.intersection_update(current_devices) new_devices = current_devices - known_devices if new_devices: known_devices.update(new_devices) @@ -96,6 +97,8 @@ def _handle_coordinator_update(self) -> None: "intent": vocal_record.intent, "voice_command": vocal_record.title, "voice_reply": vocal_record.sub_title, + "person_first_name": vocal_record.person_first_name, + "person_type": vocal_record.person_type, }, ) self.async_write_ha_state() diff --git a/homeassistant/components/alexa_devices/media_player.py b/homeassistant/components/alexa_devices/media_player.py index ca47db6c4cfc0..8fe0c38359f7c 100644 --- a/homeassistant/components/alexa_devices/media_player.py +++ b/homeassistant/components/alexa_devices/media_player.py @@ -50,6 +50,7 @@ def _check_device() -> None: """Add entities for newly discovered devices.""" new_entities: list[AlexaDevicesMediaPlayer] = [] + known_devices.intersection_update(coordinator.data) for serial_num, device in coordinator.data.items(): if serial_num in known_devices or not device.media_player_supported: continue diff --git a/homeassistant/components/alexa_devices/notify.py b/homeassistant/components/alexa_devices/notify.py index 37b1f7f7137e8..a7ad2c52e374f 100644 --- a/homeassistant/components/alexa_devices/notify.py +++ b/homeassistant/components/alexa_devices/notify.py @@ -22,7 +22,7 @@ class AmazonNotifyEntityDescription(NotifyEntityDescription): """Alexa Devices notify entity description.""" - is_supported: Callable[[AmazonDevice], bool] = lambda _device: True + is_supported_fn: Callable[[AmazonDevice], bool] = lambda _device: True is_available_fn: Callable[[AmazonDevice], bool] = lambda _device: True method: Callable[[AmazonEchoApi, AmazonDevice, str], Awaitable[None]] subkey: str @@ -33,7 +33,7 @@ class AmazonNotifyEntityDescription(NotifyEntityDescription): key="speak", translation_key="speak", subkey="AUDIO_PLAYER", - is_supported=lambda _device: _device.device_family != SPEAKER_GROUP_FAMILY, + is_supported_fn=lambda _device: _device.device_family != SPEAKER_GROUP_FAMILY, method=lambda api, device, message: api.call_alexa_speak(device, message), ), AmazonNotifyEntityDescription( @@ -63,6 +63,7 @@ async def async_setup_entry( def _check_device() -> None: current_devices = set(coordinator.data) + known_devices.intersection_update(current_devices) new_devices = current_devices - known_devices if new_devices: known_devices.update(new_devices) @@ -71,7 +72,7 @@ def _check_device() -> None: for sensor_desc in NOTIFY for serial_num in new_devices if sensor_desc.subkey in coordinator.data[serial_num].capabilities - and sensor_desc.is_supported(coordinator.data[serial_num]) + and sensor_desc.is_supported_fn(coordinator.data[serial_num]) ) _check_device() diff --git a/homeassistant/components/alexa_devices/select.py b/homeassistant/components/alexa_devices/select.py index 49c559c1583d0..9027659978feb 100644 --- a/homeassistant/components/alexa_devices/select.py +++ b/homeassistant/components/alexa_devices/select.py @@ -63,6 +63,7 @@ async def async_setup_entry( def _check_device() -> None: current_devices = set(coordinator.data) + known_devices.intersection_update(current_devices) new_devices = current_devices - known_devices if new_devices: known_devices.update(new_devices) diff --git a/homeassistant/components/alexa_devices/sensor.py b/homeassistant/components/alexa_devices/sensor.py index 648954360e0c1..c443c9a37c02c 100644 --- a/homeassistant/components/alexa_devices/sensor.py +++ b/homeassistant/components/alexa_devices/sensor.py @@ -159,6 +159,7 @@ async def async_setup_entry( def _check_device() -> None: current_devices = set(coordinator.data) + known_devices.intersection_update(current_devices) new_devices = current_devices - known_devices if new_devices: known_devices.update(new_devices) diff --git a/homeassistant/components/alexa_devices/switch.py b/homeassistant/components/alexa_devices/switch.py index e5031d18773e4..05af1ab429123 100644 --- a/homeassistant/components/alexa_devices/switch.py +++ b/homeassistant/components/alexa_devices/switch.py @@ -101,6 +101,7 @@ async def async_setup_entry( def _check_device() -> None: current_devices = set(coordinator.data) + known_devices.intersection_update(current_devices) new_devices = current_devices - known_devices if new_devices: known_devices.update(new_devices) diff --git a/homeassistant/components/alexa_devices/todo.py b/homeassistant/components/alexa_devices/todo.py index 5b9190a070393..449e0660b5cb6 100644 --- a/homeassistant/components/alexa_devices/todo.py +++ b/homeassistant/components/alexa_devices/todo.py @@ -40,6 +40,7 @@ async def async_setup_entry( def _check_lists() -> None: current_list_ids = {todo_list.id for todo_list in coordinator.api.todo_lists} + known_list_ids.intersection_update(current_list_ids) new_list_ids = current_list_ids - known_list_ids if new_list_ids: known_list_ids.update(new_list_ids) diff --git a/homeassistant/components/cync/light.py b/homeassistant/components/cync/light.py index b53a78a842a41..5a18d4a4b0f0b 100644 --- a/homeassistant/components/cync/light.py +++ b/homeassistant/components/cync/light.py @@ -58,6 +58,7 @@ class CyncLightEntity(CyncBaseEntity, LightEntity): """Representation of a Cync light.""" _attr_color_mode = ColorMode.ONOFF + _attr_supported_color_modes: set[ColorMode] _attr_min_color_temp_kelvin = 2000 _attr_max_color_temp_kelvin = 7000 _attr_translation_key = "light" @@ -129,10 +130,12 @@ def color_mode(self) -> ColorMode: and self._device.color_mode == 254 ): return ColorMode.RGB - if self._device.supports_capability(CyncCapability.DIMMING): + if ColorMode.BRIGHTNESS in self._attr_supported_color_modes: return ColorMode.BRIGHTNESS + if ColorMode.ONOFF in self._attr_supported_color_modes: + return ColorMode.ONOFF - return ColorMode.ONOFF + return ColorMode.UNKNOWN @override async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/flo/const.py b/homeassistant/components/flo/const.py index b2abe1150c51d..4eceb8f4f5a34 100644 --- a/homeassistant/components/flo/const.py +++ b/homeassistant/components/flo/const.py @@ -10,3 +10,8 @@ FLO_AWAY = "away" FLO_SLEEP = "sleep" FLO_MODES = [FLO_HOME, FLO_AWAY, FLO_SLEEP] + +# Valves without a water-temperature sensor report a fixed placeholder instead of +# omitting tempF. No domestic supply reaches boiling, so a reading at or above +# this is a sentinel rather than a measurement. +IMPLAUSIBLE_WATER_TEMP_F = 212.0 diff --git a/homeassistant/components/flo/coordinator.py b/homeassistant/components/flo/coordinator.py index 93d894b4d97c8..0280bdbf26ace 100644 --- a/homeassistant/components/flo/coordinator.py +++ b/homeassistant/components/flo/coordinator.py @@ -14,7 +14,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import DOMAIN, LOGGER +from .const import DOMAIN, IMPLAUSIBLE_WATER_TEMP_F, LOGGER type FloConfigEntry = ConfigEntry[FloRuntimeData] @@ -144,9 +144,12 @@ def current_psi(self) -> float: return self._device_information["telemetry"]["current"]["psi"] @property - def temperature(self) -> float: - """Return the current temperature in degrees F.""" - return self._device_information["telemetry"]["current"]["tempF"] + def temperature(self) -> float | None: + """Return the current temperature in degrees F, or None if not measured.""" + temperature = self._device_information["telemetry"]["current"]["tempF"] + if temperature is None or temperature >= IMPLAUSIBLE_WATER_TEMP_F: + return None + return temperature @property def humidity(self) -> float: diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 59c14de4460e2..5285a5f2f4119 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -186,15 +186,28 @@ async def _async_get_or_create_supervisor_user( if user is None and legacy_user_id is not None: user = await hass.auth.async_get_user(legacy_user_id) + if user is None: + # The storage naming the Supervisor user may have been lost. Reuse an + # existing Supervisor system user instead of creating a duplicate. + user = next( + ( + existing + for existing in await hass.auth.async_get_users() + if existing.system_generated and existing.name == HASSIO_USER_NAME + ), + None, + ) + if user is None: user = await hass.auth.async_create_system_user( HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN] ) - if entry is not None: - hass.config_entries.async_update_entry( - entry, - data={**entry.data, ENTRY_DATA_USER: user.id}, - ) + + if entry is not None and entry.data.get(ENTRY_DATA_USER) != user.id: + hass.config_entries.async_update_entry( + entry, + data={**entry.data, ENTRY_DATA_USER: user.id}, + ) # Migrate old Hass.io users to be admin. if not user.is_admin: diff --git a/homeassistant/components/imou/coordinator.py b/homeassistant/components/imou/coordinator.py index 883cb89c9f3c0..179b15db1bc1b 100644 --- a/homeassistant/components/imou/coordinator.py +++ b/homeassistant/components/imou/coordinator.py @@ -102,41 +102,22 @@ async def _async_update_data(self) -> None: try: async with asyncio.timeout(UPDATE_TIMEOUT): - results = await asyncio.gather( - *( - self._device_manager.async_update_device_status(device) - for device in devices - ), - return_exceptions=True, - ) + await self._device_manager.async_update_devices_status(devices) except TimeoutError as err: raise UpdateFailed(f"Timeout while fetching data: {err}") from err - - failures: list[Exception] = [] - for device, result in zip(devices, results, strict=True): - if isinstance(result, BaseException) and not isinstance(result, Exception): - # Propagate CancelledError and other BaseExceptions instead of - # swallowing them as a regular device failure. - raise result - if not isinstance(result, Exception): - continue - device_key = imou_device_identifier(device) - _LOGGER.warning( - "Error updating status for Imou device %s: %s", - device_key, - result, - ) - failures.append(result) - if failures and len(failures) == len(devices): - raise UpdateFailed( - f"Error updating Imou devices: {failures[0]}" - ) from failures[0] + except InvalidAppIdOrSecretException as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err + except ImouException as err: + raise UpdateFailed(f"Error updating Imou devices: {err}") from err def _async_add_remove_devices(self, fresh_by_key: dict[str, ImouHaDevice]) -> None: """Add new devices, remove devices no longer in the account. This only tracks which devices exist on the account; per-device state - is updated in place by `async_update_device_status`, so devices that + is updated in place by `async_update_devices_status`, so devices that remain on the account keep their existing object and are not replaced. """ if not self._devices_initialized: diff --git a/homeassistant/components/kodi/browse_media.py b/homeassistant/components/kodi/browse_media.py index 1106a2ea80ae8..aa4dfd8ccbdb5 100644 --- a/homeassistant/components/kodi/browse_media.py +++ b/homeassistant/components/kodi/browse_media.py @@ -351,12 +351,27 @@ async def get_media_info(media_library, search_id, search_type): title = season["seasondetails"]["label"] elif search_type == MediaType.CHANNEL: - media = await media_library.get_channels( - channel_group_id="alltv", - properties=["thumbnail", "channeltype", "channel", "broadcastnow"], + # A channel is asked for by id when its thumbnail is fetched through the + # media player proxy, the route an external client takes. There is no + # call for a single channel, so it is picked out of the list; the EPG + # that the listing needs is then not worth fetching. + channel_properties = ["thumbnail"] + if not search_id: + channel_properties += ["channeltype", "channel", "broadcastnow"] + + channels = await media_library.get_channels( + channel_group_id="alltv", properties=channel_properties ) - media = media.get("channels") + media = channels.get("channels") title = "Channels" + if search_id: + channel = next( + (item for item in media or [] if str(item["channelid"]) == search_id), + None, + ) + if channel: + thumbnail = media_library.thumbnail_url(channel.get("thumbnail")) + return thumbnail, title, media diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 16b37e5f1bb57..48e3dc1af199c 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -55,6 +55,7 @@ config_validation as cv, device_registry as dr, entity_registry as er, + instance_id, template, ) from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -738,6 +739,7 @@ async def webhook_get_config( "longitude": hass_config["longitude"], "elevation": hass_config["elevation"], "hass_device_id": device.id, + "instance_id": await instance_id.async_get(hass), "unit_system": hass_config["unit_system"], "location_name": hass_config["location_name"], "time_zone": hass_config["time_zone"], diff --git a/homeassistant/components/nyt_games/manifest.json b/homeassistant/components/nyt_games/manifest.json index db3ad6a85f11f..c4e2ba1cc9be3 100644 --- a/homeassistant/components/nyt_games/manifest.json +++ b/homeassistant/components/nyt_games/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/nyt_games", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["nyt_games==0.5.0"] + "requirements": ["nyt_games==1.0.0"] } diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index f22cfc24d7eb5..8fd6793445f0e 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -156,6 +156,9 @@ "api_timeout": { "message": "Timeout communicating with Ring API" }, + "chime_type_unknown": { + "message": "The in-home chime type changed and is no longer supported. Reload the Ring integration." + }, "no_subscription": { "message": "Ring Protect subscription required for snapshots" }, diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index a5b95fe4d4d63..35b2766023606 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -11,10 +11,12 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import RingConfigEntry +from .const import DOMAIN from .coordinator import RingDataCoordinator from .entity import ( DeprecatedInfo, @@ -34,6 +36,40 @@ IN_HOME_CHIME_IS_PRESENT = {v for k, v in DOORBELL_EXISTING_TYPE.items() if k != 2} +def _in_home_chime_exists(device: RingDoorBell) -> bool: + """Return True if the doorbell has an in-home chime.""" + if device.family != "doorbots": + return False + try: + return device.existing_doorbell_type in IN_HOME_CHIME_IS_PRESENT + except KeyError as ex: + _LOGGER.debug( + "Unknown doorbell chime type %s; skipping in-home chime for %s", + ex, + device.device_api_id, + ) + return False + + +def _in_home_chime_is_on(device: RingDoorBell) -> bool | None: + """Return if the in-home chime is enabled; None when the chime type is unknown.""" + try: + return device.existing_doorbell_type_enabled or False + except KeyError: + return None + + +async def _async_set_in_home_chime_enabled(device: RingDoorBell, enabled: bool) -> None: + """Enable or disable the in-home chime.""" + try: + await device.async_set_existing_doorbell_type_enabled(enabled) + except KeyError as ex: + _LOGGER.debug("In-home chime type unknown for %s", device.device_api_id) + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="chime_type_unknown" + ) from ex + + @dataclass(frozen=True, kw_only=True) class RingSwitchEntityDescription( SwitchEntityDescription, @@ -46,7 +82,7 @@ class RingSwitchEntityDescription( unique_id_fn: Callable[[Self, RingDeviceT], str] = lambda self, device: ( f"{device.device_api_id}-{self.key}" ) - is_on_fn: Callable[[RingDeviceT], bool] + is_on_fn: Callable[[RingDeviceT], bool | None] turn_on_fn: Callable[[RingDeviceT], Coroutine[Any, Any, None]] turn_off_fn: Callable[[RingDeviceT], Coroutine[Any, Any, None]] @@ -66,15 +102,10 @@ class RingSwitchEntityDescription( RingSwitchEntityDescription[RingDoorBell]( key="in_home_chime", translation_key="in_home_chime", - exists_fn=lambda device: ( - device.family == "doorbots" - and device.existing_doorbell_type in IN_HOME_CHIME_IS_PRESENT - ), - is_on_fn=lambda device: device.existing_doorbell_type_enabled or False, - turn_on_fn=lambda device: device.async_set_existing_doorbell_type_enabled(True), - turn_off_fn=lambda device: device.async_set_existing_doorbell_type_enabled( - False - ), + exists_fn=_in_home_chime_exists, + is_on_fn=_in_home_chime_is_on, + turn_on_fn=lambda device: _async_set_in_home_chime_enabled(device, True), + turn_off_fn=lambda device: _async_set_in_home_chime_enabled(device, False), ), RingSwitchEntityDescription[RingDoorBell]( key="motion_detection", diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 703fc92a85283..c0cebc4b2caed 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -38,5 +38,5 @@ "iot_class": "cloud_push", "loggers": ["pysmartthings"], "quality_scale": "bronze", - "requirements": ["pysmartthings==4.0.1"] + "requirements": ["pysmartthings==4.0.3"] } diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index 60466d9e8913b..0b9cc4ab62d49 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -90,6 +90,7 @@ Platform.EVENT, Platform.LIGHT, Platform.SENSOR, + Platform.SIREN, Platform.SWITCH, ] diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 5acec22995188..185675c3564fb 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["uiprotect"], "quality_scale": "platinum", - "requirements": ["uiprotect==16.8.0"] + "requirements": ["uiprotect==16.12.0"] } diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 3325a5b907e65..67af70c8bb234 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -4,14 +4,10 @@ from dataclasses import dataclass from datetime import timedelta import logging -from typing import cast, override +from typing import override from uiprotect.data import Camera, Chime, Light, ModelType, ProtectAdoptableDeviceModel -from uiprotect.data.public_devices import ( - PublicDeviceModel, - PublicLight, - SensorFeatureCapability, -) +from uiprotect.data.public_devices import PublicLight, SensorFeatureCapability from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.const import PERCENTAGE, EntityCategory, Platform, UnitOfTime @@ -46,12 +42,6 @@ class ProtectNumberEntityDescription( ufp_step: int | float -def _get_pir_duration_public(obj: PublicDeviceModel) -> int | None: - # Public API reports the PIR auto-shutoff duration in milliseconds. - duration = cast(PublicLight, obj).light_device_settings.pir_duration - return None if duration is None else round(duration / 1000) - - async def _set_pir_duration(obj: PublicLight, value: float) -> None: await obj.set_duration(timedelta(seconds=value)) @@ -185,7 +175,7 @@ async def _set_chime_volume(obj: Chime, value: float) -> None: ufp_min=15, ufp_max=900, ufp_step=15, - ufp_public_value_fn=_get_pir_duration_public, + ufp_public_value="light_device_settings.pir_duration_seconds", ufp_set_method_fn=_set_pir_duration, ufp_perm=PermRequired.WRITE, ), diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index d4a4a0ee5e266..3686158ff44a9 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from enum import Enum import logging -from typing import Any, cast, override +from typing import Any, override from uiprotect.api import ProtectApiClient from uiprotect.data import ( @@ -27,7 +27,6 @@ ) from uiprotect.data.public_devices import ( PublicCamera, - PublicDeviceModel, PublicLight, SensorFeatureCapability, ) @@ -223,16 +222,6 @@ async def _set_ptz_patrol(obj: Camera, patrol_slot: str) -> None: "always": PublicHdrMode.ON, "off": PublicHdrMode.OFF, } -_HDR_MODE_MAP_INVERSE = {v: k for k, v in _HDR_MODE_MAP.items()} - - -def _get_hdr_mode_public(obj: PublicDeviceModel) -> str | None: - """Return the HDR option id from the public camera's ``hdr_type``. - - ``hdr_type`` is non-optional on the public model; ``.get`` still yields - ``None`` for any value missing from the map. - """ - return _HDR_MODE_MAP_INVERSE.get(cast(PublicCamera, obj).hdr_type) async def _set_hdr_mode(obj: PublicCamera, mode: str) -> None: @@ -298,7 +287,7 @@ async def _set_hdr_mode(obj: PublicCamera, mode: str) -> None: entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_hdr", ufp_options=HDR_MODES, - ufp_public_value_fn=_get_hdr_mode_public, + ufp_public_value="hdr_mode_display", ufp_set_method_fn=_set_hdr_mode, ufp_perm=PermRequired.WRITE, ), diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index c53b7125f71c1..854640eddcabd 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -5,7 +5,8 @@ from datetime import datetime from functools import partial import logging -from typing import Any, cast, override +import operator +from typing import Any, override from uiprotect.data import ( NVR, @@ -18,12 +19,7 @@ ProtectDeviceModel, Sensor, ) -from uiprotect.data.public_devices import ( - PublicDeviceModel, - PublicLight, - SensorFeatureCapability, -) -from uiprotect.utils import convert_to_datetime +from uiprotect.data.public_devices import PublicDeviceModel, SensorFeatureCapability from homeassistant.components.sensor import ( SensorDeviceClass, @@ -99,11 +95,6 @@ class ProtectSensorEventEntityDescription( """Describes UniFi Protect Sensor entity.""" -def _get_last_motion_public(obj: PublicDeviceModel) -> datetime | None: - # Public API reports last motion as a JS epoch (ms); private side a datetime. - return convert_to_datetime(cast(PublicLight, obj).last_motion) - - def _get_uptime(obj: ProtectDeviceModel) -> datetime | None: if obj.up_since is None: return None @@ -325,8 +316,8 @@ def _get_alarm_sound(obj: Sensor) -> str: native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, - ufp_value="stats.light.value", - ufp_enabled="is_light_sensor_enabled", + ufp_public_value="stats.light.value", + ufp_public_enabled_fn=operator.attrgetter("is_light_sensor_enabled"), ufp_capability=SensorFeatureCapability.LIGHT, ), ProtectSensorEntityDescription( @@ -334,8 +325,8 @@ def _get_alarm_sound(obj: Sensor) -> str: native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, - ufp_value="stats.humidity.value", - ufp_enabled="is_humidity_sensor_enabled", + ufp_public_value="stats.humidity.value", + ufp_public_enabled_fn=operator.attrgetter("is_humidity_sensor_enabled"), ufp_capability=SensorFeatureCapability.HUMIDITY, ), ProtectSensorEntityDescription( @@ -343,8 +334,8 @@ def _get_alarm_sound(obj: Sensor) -> str: native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - ufp_value="stats.temperature.value", - ufp_enabled="is_temperature_sensor_enabled", + ufp_public_value="stats.temperature.value", + ufp_public_enabled_fn=operator.attrgetter("is_temperature_sensor_enabled"), ufp_capability=SensorFeatureCapability.TEMPERATURE, ), ProtectSensorEntityDescription[Sensor]( @@ -358,7 +349,7 @@ def _get_alarm_sound(obj: Sensor) -> str: key="door_last_trip_time", translation_key="last_open", device_class=SensorDeviceClass.TIMESTAMP, - ufp_value="open_status_changed_at", + ufp_public_value="open_status_changed_at_dt", ufp_capability=SensorFeatureCapability.OPEN, entity_registry_enabled_default=False, ), @@ -366,7 +357,7 @@ def _get_alarm_sound(obj: Sensor) -> str: key="motion_last_trip_time", translation_key="last_motion_detected", device_class=SensorDeviceClass.TIMESTAMP, - ufp_value="motion_detected_at", + ufp_public_value="motion_detected_at_dt", ufp_capability=SensorFeatureCapability.MOTION, entity_registry_enabled_default=False, ), @@ -375,7 +366,7 @@ def _get_alarm_sound(obj: Sensor) -> str: translation_key="last_tampering_detected", ufp_capability=SensorFeatureCapability.TAMPER, device_class=SensorDeviceClass.TIMESTAMP, - ufp_value="tampering_detected_at", + ufp_public_value="tampering_detected_at_dt", entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( @@ -522,7 +513,7 @@ def _get_alarm_sound(obj: Sensor) -> str: key="motion_last_trip_time", translation_key="last_motion_detected", device_class=SensorDeviceClass.TIMESTAMP, - ufp_public_value_fn=_get_last_motion_public, + ufp_public_value="last_motion_dt", entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( diff --git a/homeassistant/components/unifiprotect/siren.py b/homeassistant/components/unifiprotect/siren.py index 7f22fe2be4106..897973656c351 100644 --- a/homeassistant/components/unifiprotect/siren.py +++ b/homeassistant/components/unifiprotect/siren.py @@ -1,6 +1,5 @@ """UniFi Protect siren platform (Public API).""" -from datetime import datetime import logging from typing import Any, override @@ -12,14 +11,12 @@ SirenEntity, SirenEntityFeature, ) -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.event import async_call_later -from homeassistant.util import dt as dt_util from .const import DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN from .data import ProtectData, UFPConfigEntry @@ -89,7 +86,6 @@ def __init__(self, data: ProtectData, siren: Siren) -> None: via_device_id=data.nvr_device_id, ) self._siren_mac = siren.mac - self._cancel_scheduled_off: CALLBACK_TYPE | None = None self._update_from_siren(siren) @property @@ -115,11 +111,9 @@ def _async_updated(self, _obj: PublicDeviceModel | None) -> None: The state is always re-read from the public bootstrap: the library merges WS updates into it before dispatching, and ``None`` carries no - object to read. + object to read. A timed run ending is announced by the library as a + regular update. """ - # Cancel any previous auto-off timer before scheduling a new one. - self._cancel_off_timer() - prev_state = (self._attr_available, self._attr_is_on) if (siren := self._siren) is None: @@ -129,38 +123,9 @@ def _async_updated(self, _obj: PublicDeviceModel | None) -> None: else: self._update_from_siren(siren) - # The server never emits a WS message when a timed run expires, so - # we must schedule our own callback. Both activated_at and - # duration are in milliseconds in the WS payload. - status = siren.siren_status - if ( - status.is_active - and status.activated_at is not None - and status.duration is not None - ): - delay = ( - status.activated_at + status.duration - ) / 1000 - dt_util.utcnow().timestamp() - if delay <= 0: - # Already expired (e.g. stale bootstrap after a reconnect): - # override the is_active=True from the payload immediately - # so we never briefly write ON into the state machine. - self._attr_is_on = False - else: - self._cancel_scheduled_off = async_call_later( - self.hass, delay, self._async_scheduled_off - ) - if (self._attr_available, self._attr_is_on) != prev_state: self.async_write_ha_state() - @callback - def _async_scheduled_off(self, _now: datetime) -> None: - """Timed siren run has expired — push state to OFF.""" - self._cancel_scheduled_off = None - self._attr_is_on = False - self.async_write_ha_state() - @override async def async_added_to_hass(self) -> None: """Subscribe to public WS updates dispatched by ProtectData.""" @@ -168,20 +133,10 @@ async def async_added_to_hass(self) -> None: self.async_on_remove( self.data.async_subscribe_public(self._siren_mac, self._async_updated) ) - self.async_on_remove(self._cancel_off_timer) # Refresh from the bootstrap: a WS update or delete that landed between - # entity construction and this subscription would otherwise be missed, - # and an already-active timed run needs its auto-off timer scheduled so - # a siren that was running when HA started does not remain stuck ON. + # entity construction and this subscription would otherwise be missed. self._async_updated(None) - @callback - def _cancel_off_timer(self) -> None: - """Cancel the pending auto-off timer if any.""" - if self._cancel_scheduled_off is not None: - self._cancel_scheduled_off() - self._cancel_scheduled_off = None - @async_ufp_instance_command @override async def async_turn_on(self, **kwargs: Any) -> None: @@ -235,7 +190,6 @@ async def async_turn_off(self, **kwargs: Any) -> None: ) await siren.stop() # The server does not emit a WS event after a manual stop, so we set - # the state optimistically and cancel any pending auto-off timer. - self._cancel_off_timer() + # the state optimistically. self._attr_is_on = False self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index 0fc189d473c97..b8562bba66d48 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -100,9 +100,6 @@ async def _set_hdr(obj: Camera, value: bool) -> None: key="high_fps", translation_key="high_fps", entity_category=EntityCategory.CONFIG, - # has_highfps has no public counterpart yet (uilibs/uiprotect#1201), so - # this stays unreachable in API-key-only mode even though the value - # and setter are migrated. ufp_required_field="feature_flags.has_highfps", ufp_public_value="is_high_fps_enabled", ufp_set_method_fn=_set_highfps, diff --git a/homeassistant/components/vistapool/entity.py b/homeassistant/components/vistapool/entity.py index 9b47f6e524d73..68a1b8c577546 100644 --- a/homeassistant/components/vistapool/entity.py +++ b/homeassistant/components/vistapool/entity.py @@ -32,16 +32,6 @@ def available(self) -> bool: """Return if entity is available.""" return super().available and self.coordinator.push_connected - @property - def pool_id(self) -> str: - """Return the pool ID for the entity.""" - return self.coordinator.pool_id - - @property - def pool_name(self) -> str: - """Return the friendly pool name for the entity.""" - return self.coordinator.pool_name - def build_unique_id(self, suffix: str) -> str: """Return a consistent unique ID for the entity.""" return f"{self.coordinator.pool_id}-{suffix}" diff --git a/homeassistant/components/vistapool/number.py b/homeassistant/components/vistapool/number.py index d7230f916132e..0553bcede5fac 100644 --- a/homeassistant/components/vistapool/number.py +++ b/homeassistant/components/vistapool/number.py @@ -44,13 +44,12 @@ class VistapoolNumberEntityDescription(NumberEntityDescription): def _max_electrolysis(coordinator: VistapoolDataUpdateCoordinator) -> float: """Read the cell's hardware max, falling back to a safe default.""" + # The path is typed in the library's coercion map, so an unparsable value + # already comes back as None rather than reaching float(). raw = coordinator.get_value("hidro.maxAllowedValue") if raw is None: return 50.0 - try: - return float(raw) / 10 - except TypeError, ValueError: - return 50.0 + return float(raw) / 10 NUMBER_DESCRIPTIONS: tuple[VistapoolNumberEntityDescription, ...] = ( @@ -231,14 +230,12 @@ def native_max_value(self) -> float: @override def native_value(self) -> float | None: """Return the scaled current value.""" + # Every number path is typed in the library's coercion map, so an + # unparsable value already comes back as None rather than reaching float(). raw = self.coordinator.get_value(self.entity_description.value_path) if raw is None: return None - try: - value = float(raw) - except TypeError, ValueError: - return None - return value / self.entity_description.scale + return float(raw) / self.entity_description.scale @override async def async_set_native_value(self, value: float) -> None: diff --git a/homeassistant/components/vistapool/select.py b/homeassistant/components/vistapool/select.py index b7101cbcef65e..42e3160803edd 100644 --- a/homeassistant/components/vistapool/select.py +++ b/homeassistant/components/vistapool/select.py @@ -41,10 +41,8 @@ class VistapoolSelectEntityDescription(SelectEntityDescription): """Describes a Vistapool select entity.""" value_path: str - # A capability flag that must be set, such as main.hasPH. - exists_path: str | tuple[str, ...] | None = None - # A field the controller only reports when it supports the feature. Unlike - # exists_path this is a presence check, so a valid zero still counts. + # A field the controller only reports when it supports the feature. This + # is a presence check, so a valid zero still counts. presence_path: str | None = None value_map: dict[str, int] | None = None @@ -93,14 +91,6 @@ def _build_select_entities( """Build the select entities for a single pool.""" entities: list[SelectEntity] = [] for description in SELECT_DESCRIPTIONS: - if description.exists_path is not None: - required = ( - (description.exists_path,) - if isinstance(description.exists_path, str) - else description.exists_path - ) - if not all(coordinator.get_value(path) for path in required): - continue if ( description.presence_path is not None and coordinator.get_value(description.presence_path) is None @@ -135,13 +125,14 @@ def _async_add_pool(coordinator: VistapoolDataUpdateCoordinator) -> None: def _to_index(raw: Any) -> int | None: - """Convert a coordinator value into an options-list index, or None if not possible.""" + """Convert a coordinator value into an options-list index, or None if missing. + + Every select path is typed in the library's coercion map, so get_value + already returns an int or None; an unparsable value never reaches here. + """ if raw is None: return None - try: - return int(raw) - except TypeError, ValueError: - return None + return int(raw) class VistapoolSelect(VistapoolEntity, SelectEntity): diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index f9af2eb1d7181..db8fe76b0d0a7 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -136,6 +136,13 @@ def _validate_selector_reorder_config(config: Any) -> Any: return config +def _validate_media_selector_config(config: Any) -> Any: + """Validate media selectors with image_upload option.""" + if config.get("image_upload") and not config.get("accept"): + raise probatio.Invalid("image_upload can only be used when accept is not empty") + return config + + def make_selector_config_schema(schema_dict: dict | None = None) -> probatio.Schema: """Make selector config schema.""" if schema_dict is None: @@ -1358,6 +1365,7 @@ class MediaSelectorConfig(BaseSelectorConfig, total=False): accept: list[str] multiple: bool + image_upload: bool @SELECTORS.register("media") @@ -1366,11 +1374,15 @@ class MediaSelector(Selector[MediaSelectorConfig]): selector_type = "media" - CONFIG_SCHEMA = make_selector_config_schema( - { - probatio.Optional("accept"): [str], - probatio.Optional("multiple", default=False): cv.boolean, - } + CONFIG_SCHEMA = probatio.All( + make_selector_config_schema( + { + probatio.Optional("accept"): [str], + probatio.Optional("multiple", default=False): cv.boolean, + probatio.Optional("image_upload", default=False): cv.boolean, + } + ), + _validate_media_selector_config, ) DATA_SCHEMA = probatio.Schema( { diff --git a/requirements_all.txt b/requirements_all.txt index 6fca6a30171d5..77bcf3e99e070 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1785,7 +1785,7 @@ numato-gpio==0.13.0 numpy==2.3.2 # homeassistant.components.nyt_games -nyt_games==0.5.0 +nyt_games==1.0.0 # homeassistant.components.oasa_telematics oasatelematics==0.4 @@ -2645,7 +2645,7 @@ pysmappee==0.2.29 pysmarlaapi==1.0.2 # homeassistant.components.smartthings -pysmartthings==4.0.1 +pysmartthings==4.0.3 # homeassistant.components.smarty pysmarty2==0.10.3 @@ -3347,7 +3347,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==16.8.0 +uiprotect==16.12.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.6.1 diff --git a/tests/components/alexa_devices/__init__.py b/tests/components/alexa_devices/__init__.py index 24348248e0c97..2e1e8a39f3223 100644 --- a/tests/components/alexa_devices/__init__.py +++ b/tests/components/alexa_devices/__init__.py @@ -1,8 +1,15 @@ """Tests for the Alexa Devices integration.""" +from collections.abc import Mapping +from unittest.mock import AsyncMock + +from aioamazondevices.structures import AmazonDevice +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.alexa_devices.coordinator import SCAN_INTERVAL from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: @@ -11,3 +18,33 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + + +async def assert_device_removed_and_readded( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + config_entry: MockConfigEntry, + entity_id: str, + devices_with: Mapping[str, AmazonDevice], + devices_without: Mapping[str, AmazonDevice], +) -> None: + """Assert an entity is recreated when its device is removed and re-added.""" + mock_amazon_devices_client.get_devices_data.return_value = dict(devices_with) + await setup_integration(hass, config_entry) + + assert hass.states.get(entity_id) is not None + + mock_amazon_devices_client.get_devices_data.return_value = dict(devices_without) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id) is None + + mock_amazon_devices_client.get_devices_data.return_value = dict(devices_with) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id) is not None diff --git a/tests/components/alexa_devices/const.py b/tests/components/alexa_devices/const.py index 9bd339bc9a696..a6e7b639bb645 100644 --- a/tests/components/alexa_devices/const.py +++ b/tests/components/alexa_devices/const.py @@ -127,6 +127,8 @@ intent="PlayMusicIntent", title="Play some music", sub_title="Echo Test", + person_first_name="John", + person_type="CHILD", ) TEST_VOCAL_RECORD_EVENT = AmazonVocalRecord( @@ -135,4 +137,6 @@ intent="PlayMusicIntent", title="Play some music", sub_title="Echo Test", + person_first_name="Jane", + person_type="ADULT", ) diff --git a/tests/components/alexa_devices/snapshots/test_event.ambr b/tests/components/alexa_devices/snapshots/test_event.ambr index 03ef39503774d..05228361112ed 100644 --- a/tests/components/alexa_devices/snapshots/test_event.ambr +++ b/tests/components/alexa_devices/snapshots/test_event.ambr @@ -65,6 +65,8 @@ ]), : 'Echo Test Voice event', 'intent': 'PlayMusicIntent', + 'person_first_name': 'Jane', + 'person_type': 'ADULT', 'voice_command': 'Play some music', 'voice_reply': 'Echo Test', }) diff --git a/tests/components/alexa_devices/test_binary_sensor.py b/tests/components/alexa_devices/test_binary_sensor.py index e6384e1fbd981..483e5ffc97d33 100644 --- a/tests/components/alexa_devices/test_binary_sensor.py +++ b/tests/components/alexa_devices/test_binary_sensor.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import setup_integration +from . import assert_device_removed_and_readded, setup_integration from .const import TEST_DEVICE_1, TEST_DEVICE_1_SN, TEST_DEVICE_2, TEST_DEVICE_2_SN from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -142,6 +142,24 @@ async def test_dynamic_device( assert state.state == STATE_ON +async def test_device_removed_and_readded( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test entities are recreated when a device is removed and re-added.""" + await assert_device_removed_and_readded( + hass, + freezer, + mock_amazon_devices_client, + mock_config_entry, + entity_id="binary_sensor.echo_test_2_connectivity", + devices_with={TEST_DEVICE_1_SN: TEST_DEVICE_1, TEST_DEVICE_2_SN: TEST_DEVICE_2}, + devices_without={TEST_DEVICE_1_SN: TEST_DEVICE_1}, + ) + + @pytest.mark.parametrize( "key", [ diff --git a/tests/components/alexa_devices/test_button.py b/tests/components/alexa_devices/test_button.py index 265ae612b2082..8d96c9b1ef2c2 100644 --- a/tests/components/alexa_devices/test_button.py +++ b/tests/components/alexa_devices/test_button.py @@ -13,8 +13,14 @@ from homeassistant.helpers import entity_registry as er from homeassistant.util import slugify -from . import setup_integration -from .const import TEST_DEVICE_1, TEST_USERNAME +from . import assert_device_removed_and_readded, setup_integration +from .const import ( + TEST_DEVICE_1, + TEST_DEVICE_1_SN, + TEST_DEVICE_2, + TEST_DEVICE_2_SN, + TEST_USERNAME, +) from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -96,6 +102,55 @@ async def test_dynamic_entities( assert hass.states.get(entity_id) is None +async def test_routine_removed_and_readded( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test routine button is recreated when removed and re-added.""" + + entity_id = f"button.{slugify(TEST_USERNAME)}_test_routine" + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(entity_id) is not None + + mock_amazon_devices_client.routines = [] + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id) is None + + mock_amazon_devices_client.routines = ["Test Routine"] + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id) is not None + + +async def test_device_removed_and_readded( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test device button is recreated when its device is removed and re-added.""" + await assert_device_removed_and_readded( + hass, + freezer, + mock_amazon_devices_client, + mock_config_entry, + entity_id="button.echo_test_2_restart", + devices_with={TEST_DEVICE_1_SN: TEST_DEVICE_1, TEST_DEVICE_2_SN: TEST_DEVICE_2}, + devices_without={TEST_DEVICE_1_SN: TEST_DEVICE_1}, + ) + + async def test_restart_button( hass: HomeAssistant, mock_amazon_devices_client: AsyncMock, diff --git a/tests/components/alexa_devices/test_event.py b/tests/components/alexa_devices/test_event.py index ddf90e744a546..3cc5b70de7867 100644 --- a/tests/components/alexa_devices/test_event.py +++ b/tests/components/alexa_devices/test_event.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -9,8 +10,14 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import setup_integration -from .const import TEST_DEVICE_1_SN, TEST_VOCAL_RECORD_EVENT +from . import assert_device_removed_and_readded, setup_integration +from .const import ( + TEST_DEVICE_1, + TEST_DEVICE_1_SN, + TEST_DEVICE_2, + TEST_DEVICE_2_SN, + TEST_VOCAL_RECORD_EVENT, +) from tests.common import MockConfigEntry, snapshot_platform @@ -69,3 +76,21 @@ async def test_no_vocal_record_skips_event_trigger( assert (state := hass.states.get(ENTITY_ID)) assert state.state == STATE_UNKNOWN assert state.attributes.get("event_type") is None + + +async def test_device_removed_and_readded( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test entities are recreated when a device is removed and re-added.""" + await assert_device_removed_and_readded( + hass, + freezer, + mock_amazon_devices_client, + mock_config_entry, + entity_id="event.echo_test_2_voice_event", + devices_with={TEST_DEVICE_1_SN: TEST_DEVICE_1, TEST_DEVICE_2_SN: TEST_DEVICE_2}, + devices_without={TEST_DEVICE_1_SN: TEST_DEVICE_1}, + ) diff --git a/tests/components/alexa_devices/test_media_player.py b/tests/components/alexa_devices/test_media_player.py index c470eeafa4730..65680fa9a0e7c 100644 --- a/tests/components/alexa_devices/test_media_player.py +++ b/tests/components/alexa_devices/test_media_player.py @@ -39,8 +39,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import setup_integration -from .const import TEST_DEVICE_1_SN +from . import assert_device_removed_and_readded, setup_integration +from .const import TEST_DEVICE_1, TEST_DEVICE_1_SN from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -259,6 +259,24 @@ async def test_offline_device_recovers( assert state.state != STATE_UNAVAILABLE +async def test_device_removed_and_readded( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test entity is recreated when a device is removed and re-added.""" + await assert_device_removed_and_readded( + hass, + freezer, + mock_amazon_devices_client, + mock_config_entry, + entity_id=ENTITY_ID, + devices_with={TEST_DEVICE_1_SN: TEST_DEVICE_1}, + devices_without={}, + ) + + @pytest.mark.parametrize( ("player_state", "expected_ha_state"), [ diff --git a/tests/components/alexa_devices/test_notify.py b/tests/components/alexa_devices/test_notify.py index 3132b967487a0..485bb9ce354d7 100644 --- a/tests/components/alexa_devices/test_notify.py +++ b/tests/components/alexa_devices/test_notify.py @@ -18,8 +18,8 @@ from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util -from . import setup_integration -from .const import TEST_DEVICE_1, TEST_DEVICE_1_SN +from . import assert_device_removed_and_readded, setup_integration +from .const import TEST_DEVICE_1, TEST_DEVICE_1_SN, TEST_DEVICE_2, TEST_DEVICE_2_SN from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -104,6 +104,24 @@ async def test_offline_device( assert state.state != STATE_UNAVAILABLE +async def test_device_removed_and_readded( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test entities are recreated when a device is removed and re-added.""" + await assert_device_removed_and_readded( + hass, + freezer, + mock_amazon_devices_client, + mock_config_entry, + entity_id="notify.echo_test_2_announce", + devices_with={TEST_DEVICE_1_SN: TEST_DEVICE_1, TEST_DEVICE_2_SN: TEST_DEVICE_2}, + devices_without={TEST_DEVICE_1_SN: TEST_DEVICE_1}, + ) + + async def test_announce_unavailable_when_communications_off( hass: HomeAssistant, freezer: FrozenDateTimeFactory, diff --git a/tests/components/alexa_devices/test_select.py b/tests/components/alexa_devices/test_select.py index 55dc4b2ccb053..b86a382956d37 100644 --- a/tests/components/alexa_devices/test_select.py +++ b/tests/components/alexa_devices/test_select.py @@ -21,7 +21,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er -from . import setup_integration +from . import assert_device_removed_and_readded, setup_integration from .const import TEST_DEVICE_1, TEST_DEVICE_1_SN, TEST_DEVICE_2, TEST_DEVICE_2_SN from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -106,6 +106,24 @@ async def test_offline_device( assert state.state == STATE_UNAVAILABLE +async def test_device_removed_and_readded( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test entities are recreated when a device is removed and re-added.""" + await assert_device_removed_and_readded( + hass, + freezer, + mock_amazon_devices_client, + mock_config_entry, + entity_id=ENTITY_ID, + devices_with={TEST_DEVICE_1_SN: TEST_DEVICE_1}, + devices_without={}, + ) + + async def test_service_select_option( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, diff --git a/tests/components/alexa_devices/test_sensor.py b/tests/components/alexa_devices/test_sensor.py index 58930f78b3fe8..902024dd81da6 100644 --- a/tests/components/alexa_devices/test_sensor.py +++ b/tests/components/alexa_devices/test_sensor.py @@ -18,8 +18,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import setup_integration -from .const import TEST_DEVICE_1_SN +from . import assert_device_removed_and_readded, setup_integration +from .const import TEST_DEVICE_1, TEST_DEVICE_1_SN, TEST_DEVICE_2, TEST_DEVICE_2_SN from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -103,6 +103,24 @@ async def test_offline_device( assert state.state != STATE_UNAVAILABLE +async def test_device_removed_and_readded( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test entities are recreated when a device is removed and re-added.""" + await assert_device_removed_and_readded( + hass, + freezer, + mock_amazon_devices_client, + mock_config_entry, + entity_id="sensor.echo_test_2_temperature", + devices_with={TEST_DEVICE_1_SN: TEST_DEVICE_1, TEST_DEVICE_2_SN: TEST_DEVICE_2}, + devices_without={TEST_DEVICE_1_SN: TEST_DEVICE_1}, + ) + + @pytest.mark.parametrize( ("sensor", "api_value", "scale", "state_value", "unit"), [ diff --git a/tests/components/alexa_devices/test_switch.py b/tests/components/alexa_devices/test_switch.py index f2cb257575bd7..4d45f52a992bd 100644 --- a/tests/components/alexa_devices/test_switch.py +++ b/tests/components/alexa_devices/test_switch.py @@ -24,7 +24,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import setup_integration +from . import assert_device_removed_and_readded, setup_integration from .const import TEST_DEVICE_1, TEST_DEVICE_1_SN from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -164,6 +164,24 @@ async def test_offline_device( assert state.state != STATE_UNAVAILABLE +async def test_device_removed_and_readded( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test entities are recreated when a device is removed and re-added.""" + await assert_device_removed_and_readded( + hass, + freezer, + mock_amazon_devices_client, + mock_config_entry, + entity_id=ENTITY_ID, + devices_with={TEST_DEVICE_1_SN: TEST_DEVICE_1}, + devices_without={}, + ) + + async def test_switch_communication( hass: HomeAssistant, freezer: FrozenDateTimeFactory, diff --git a/tests/components/alexa_devices/test_todo.py b/tests/components/alexa_devices/test_todo.py index 64663d74ab8f1..0ca91203e5809 100644 --- a/tests/components/alexa_devices/test_todo.py +++ b/tests/components/alexa_devices/test_todo.py @@ -570,6 +570,37 @@ def get_entity_id(alexa_list: AmazonListInfo) -> str: assert hass.states.get(entity_id) is None +async def test_list_removed_and_readded( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test entity is recreated when a list is removed and re-added.""" + mock_amazon_devices_client.todo_lists = [MOCK_TODO_LIST] + mock_amazon_devices_client.get_todo_list_items = AsyncMock(return_value={}) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(MOCK_TODO_LIST_ENTITY_ID) is not None + + mock_amazon_devices_client.todo_lists = [] + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(MOCK_TODO_LIST_ENTITY_ID) is None + + mock_amazon_devices_client.todo_lists = [MOCK_TODO_LIST] + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(MOCK_TODO_LIST_ENTITY_ID) is not None + + async def test_dynamic_add_list_and_add_item( hass: HomeAssistant, freezer: FrozenDateTimeFactory, diff --git a/tests/components/cync/test_light.py b/tests/components/cync/test_light.py index aabf9e072289a..e5a716e983715 100644 --- a/tests/components/cync/test_light.py +++ b/tests/components/cync/test_light.py @@ -1,10 +1,13 @@ """Tests for the Cync integration light platform.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, PropertyMock, patch +from pycync import CyncLight +from pycync.devices.capabilities import CyncCapability import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.light import ColorMode from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -71,3 +74,80 @@ async def test_turn_on( test_device.set_combo.assert_called_once_with( True, expected_brightness, expected_color_temp, expected_rgb ) + + +@pytest.mark.parametrize( + ("capabilities", "expected_fallback"), + [ + pytest.param( + { + CyncCapability.ON_OFF, + CyncCapability.DIMMING, + CyncCapability.CCT_COLOR, + CyncCapability.RGB_COLOR, + }, + ColorMode.UNKNOWN, + id="full-color", + ), + pytest.param( + {CyncCapability.ON_OFF, CyncCapability.DIMMING, CyncCapability.CCT_COLOR}, + ColorMode.UNKNOWN, + id="color-temperature", + ), + pytest.param( + {CyncCapability.ON_OFF, CyncCapability.DIMMING, CyncCapability.RGB_COLOR}, + ColorMode.UNKNOWN, + id="rgb", + ), + pytest.param( + {CyncCapability.ON_OFF, CyncCapability.DIMMING}, + ColorMode.BRIGHTNESS, + id="dimmer", + ), + pytest.param({CyncCapability.ON_OFF}, ColorMode.ONOFF, id="on-off"), + ], +) +@pytest.mark.parametrize("device_mode", [0, 101, 253, 255]) +async def test_unrecognized_color_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + capabilities: set[CyncCapability], + expected_fallback: ColorMode, + device_mode: int, +) -> None: + """Unrecognized device modes must not produce an invalid light state.""" + with ( + patch.object( + CyncLight, "supports_capability", side_effect=capabilities.__contains__ + ), + patch.object( + CyncLight, "color_mode", new_callable=PropertyMock, return_value=device_mode + ), + ): + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("light.bedroom_bedroom_lamp") + assert state.state == "on" + assert state.attributes["color_mode"] == expected_fallback + assert "white" not in state.attributes["supported_color_modes"] + + +@pytest.mark.parametrize( + ("device_mode", "expected_mode"), + [(1, ColorMode.COLOR_TEMP), (100, ColorMode.COLOR_TEMP), (254, ColorMode.RGB)], +) +async def test_recognized_color_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_mode: int, + expected_mode: ColorMode, +) -> None: + """Recognized temperature and RGB modes retain their existing mapping.""" + with patch.object( + CyncLight, "color_mode", new_callable=PropertyMock, return_value=device_mode + ): + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("light.bedroom_bedroom_lamp") + assert state.state == "on" + assert state.attributes["color_mode"] == expected_mode diff --git a/tests/components/flo/conftest.py b/tests/components/flo/conftest.py index 5b303d5c4b455..0cc0a5e400835 100644 --- a/tests/components/flo/conftest.py +++ b/tests/components/flo/conftest.py @@ -11,7 +11,7 @@ from .common import TEST_EMAIL_ADDRESS, TEST_PASSWORD, TEST_TOKEN, TEST_USER_ID -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_fixture, load_json_object_fixture from tests.test_util.aiohttp import AiohttpClientMocker @@ -26,7 +26,18 @@ def config_entry() -> MockConfigEntry: @pytest.fixture -def aioclient_mock_fixture(aioclient_mock: AiohttpClientMocker) -> None: +def device_info_response(request: pytest.FixtureRequest) -> str: + """Shutoff valve device info, with tempF overridden when parametrized.""" + device_info = load_json_object_fixture("flo/device_info_response.json") + if hasattr(request, "param"): + device_info["telemetry"]["current"]["tempF"] = request.param + return json.dumps(device_info) + + +@pytest.fixture +def aioclient_mock_fixture( + aioclient_mock: AiohttpClientMocker, device_info_response: str +) -> None: """Fixture to provide a aioclient mocker.""" now = round(time.time()) # Mocks the login response for flo. @@ -56,7 +67,7 @@ def aioclient_mock_fixture(aioclient_mock: AiohttpClientMocker) -> None: # Mocks the devices for flo. aioclient_mock.get( "https://api-gw.meetflo.com/api/v2/devices/98765", - text=load_fixture("flo/device_info_response.json"), + text=device_info_response, status=HTTPStatus.OK, headers={"Content-Type": CONTENT_TYPE_JSON}, ) diff --git a/tests/components/flo/test_sensor.py b/tests/components/flo/test_sensor.py index 8c12da7c679a9..118e27a259bcd 100644 --- a/tests/components/flo/test_sensor.py +++ b/tests/components/flo/test_sensor.py @@ -1,5 +1,7 @@ """Test Flo by Moen sensor entities.""" +import json + import pytest from homeassistant.components.homeassistant import ( @@ -7,7 +9,7 @@ SERVICE_UPDATE_ENTITY, ) from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -105,3 +107,40 @@ async def test_manual_update_entity( blocking=True, ) assert aioclient_mock.call_count == call_count + 3 + + +@pytest.mark.parametrize("device_info_response", [225, 212, 220], indirect=True) +@pytest.mark.usefixtures("aioclient_mock_fixture") +async def test_water_temperature_placeholder_is_not_published( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """A valve with no temperature sensor reports a placeholder, not a reading.""" + hass.config.units = US_CUSTOMARY_SYSTEM + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + hass.states.get("sensor.smart_water_shutoff_water_temperature").state + == STATE_UNKNOWN + ) + + # The detector measures ambient air and is unaffected. + assert hass.states.get("sensor.kitchen_sink_temperature").state == "61" + + +@pytest.mark.parametrize("device_info_response", [211.9, 70], indirect=True) +@pytest.mark.usefixtures("aioclient_mock_fixture") +async def test_water_temperature_below_threshold_is_published( + hass: HomeAssistant, config_entry: MockConfigEntry, device_info_response: str +) -> None: + """Anything below boiling is a real reading and is published.""" + hass.config.units = US_CUSTOMARY_SYSTEM + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + expected = json.loads(device_info_response)["telemetry"]["current"]["tempF"] + assert hass.states.get("sensor.smart_water_shutoff_water_temperature").state == str( + round(expected, 1) + ) diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 933df318454cd..7c57ef891cd85 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -33,6 +33,7 @@ import pytest from homeassistant.auth.const import GROUP_ID_ADMIN +from homeassistant.auth.models import User from homeassistant.components import frontend, hassio from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.hassio import ( @@ -69,6 +70,7 @@ ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import HASSIO_USER_NAME from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, issue_registry as ir @@ -444,6 +446,60 @@ async def test_setup_api_existing_hassio_user( assert hass.auth.async_validate_access_token(access_token) is None +async def _async_assert_supervisor_user_adopted( + hass: HomeAssistant, user: User, human: User +) -> None: + """Assert setup adopted the existing Supervisor system user.""" + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.data[ENTRY_DATA_USER] == user.id + assert entry.data[ENTRY_DATA_USER] != human.id + assert [ + existing + for existing in await hass.auth.async_get_users() + if existing.system_generated and existing.name == HASSIO_USER_NAME + ] == [user] + + +async def test_setup_adopts_existing_supervisor_user(hass: HomeAssistant) -> None: + """Test setup reuses an existing Supervisor system user. + + When the config entry data (and legacy store) naming the Supervisor user + is lost, an existing Supervisor system user must be adopted instead of + creating a duplicate. A regular user named alike must not be picked up. + """ + human = await hass.auth.async_create_user(HASSIO_USER_NAME) + user = await hass.auth.async_create_system_user( + HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN] + ) + MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN).add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + assert await async_setup_component(hass, DOMAIN, {"hassio": {}}) + await hass.async_block_till_done() + + await _async_assert_supervisor_user_adopted(hass, user, human) + + +async def test_setup_adopts_existing_supervisor_user_without_entry( + hass: HomeAssistant, +) -> None: + """Test setup reuses an existing Supervisor system user without config entry. + + Same as above, but with the config entry itself gone: the entry created by + the system flow must name the adopted user. + """ + human = await hass.auth.async_create_user(HASSIO_USER_NAME) + user = await hass.auth.async_create_system_user( + HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN] + ) + + with patch.dict(os.environ, MOCK_ENVIRON): + assert await async_setup_component(hass, DOMAIN, {"hassio": {}}) + await hass.async_block_till_done() + + await _async_assert_supervisor_user_adopted(hass, user, human) + + async def test_setup_migrates_legacy_hassio_store_to_config_entry( hass: HomeAssistant, hass_storage: dict[str, Any], diff --git a/tests/components/imou/conftest.py b/tests/components/imou/conftest.py index eff6c0c90da6c..9ae5fd1be9ff7 100644 --- a/tests/components/imou/conftest.py +++ b/tests/components/imou/conftest.py @@ -66,6 +66,7 @@ def mock_imou_ha_device_manager( with patch(PATCH_IMOU_HA_DEVICE_MANAGER, autospec=True) as mock_manager: device_manager = mock_manager.return_value device_manager.async_get_devices.return_value = imou_mock_devices + device_manager.async_update_devices_status.return_value = set() yield device_manager diff --git a/tests/components/imou/test_binary_sensor.py b/tests/components/imou/test_binary_sensor.py index 09485961615aa..41f22c6fabac3 100644 --- a/tests/components/imou/test_binary_sensor.py +++ b/tests/components/imou/test_binary_sensor.py @@ -158,11 +158,12 @@ async def test_binary_sensor_unavailable_when_device_offline( ) -> None: """Binary sensors become unavailable when the device is offline.""" - async def set_device_offline(device: ImouHaDevice) -> None: - device._sensors[PARAM_STATUS] = {PARAM_STATE: DeviceStatus.OFFLINE.value} + async def set_devices_offline(devices: list[ImouHaDevice]) -> None: + for device in devices: + device._sensors[PARAM_STATUS] = {PARAM_STATE: DeviceStatus.OFFLINE.value} - mock_imou_ha_device_manager.async_update_device_status.side_effect = ( - set_device_offline + mock_imou_ha_device_manager.async_update_devices_status.side_effect = ( + set_devices_offline ) freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) diff --git a/tests/components/imou/test_button.py b/tests/components/imou/test_button.py index f1586d9176542..3a44f76a37535 100644 --- a/tests/components/imou/test_button.py +++ b/tests/components/imou/test_button.py @@ -198,11 +198,12 @@ async def test_press_unavailable_offline_device_via_service( if entry.unique_id == "d1$mute" ) - async def set_device_offline(device: ImouHaDevice) -> None: - device._sensors[PARAM_STATUS] = {PARAM_STATE: DeviceStatus.OFFLINE.value} + async def set_devices_offline(devices: list[ImouHaDevice]) -> None: + for device in devices: + device._sensors[PARAM_STATUS] = {PARAM_STATE: DeviceStatus.OFFLINE.value} - mock_imou_ha_device_manager.async_update_device_status.side_effect = ( - set_device_offline + mock_imou_ha_device_manager.async_update_devices_status.side_effect = ( + set_devices_offline ) freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) diff --git a/tests/components/imou/test_init.py b/tests/components/imou/test_init.py index 3d1f1282338fa..4ad12e77fd6c8 100644 --- a/tests/components/imou/test_init.py +++ b/tests/components/imou/test_init.py @@ -11,7 +11,7 @@ from homeassistant.components.imou.button import PARAM_MUTE, PARAM_PTZ_UP from homeassistant.components.imou.const import DOMAIN from homeassistant.components.imou.coordinator import SCAN_INTERVAL -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -312,11 +312,12 @@ async def test_offline_device_marked_unavailable_after_refresh( ) assert hass.states.get(mute_entry.entity_id).state != STATE_UNAVAILABLE - async def set_device_offline(device: ImouHaDevice) -> None: - device._sensors[PARAM_STATUS] = {PARAM_STATE: DeviceStatus.OFFLINE.value} + async def set_devices_offline(devices: list[ImouHaDevice]) -> None: + for device in devices: + device._sensors[PARAM_STATUS] = {PARAM_STATE: DeviceStatus.OFFLINE.value} - mock_imou_ha_device_manager.async_update_device_status.side_effect = ( - set_device_offline + mock_imou_ha_device_manager.async_update_devices_status.side_effect = ( + set_devices_offline ) freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) @@ -343,7 +344,7 @@ async def test_coordinator_update_fails_when_all_devices_fail( ) assert hass.states.get(mute_entry.entity_id).state != STATE_UNAVAILABLE - mock_imou_ha_device_manager.async_update_device_status.side_effect = ImouException( + mock_imou_ha_device_manager.async_update_devices_status.side_effect = ImouException( "cloud failure" ) freezer.tick(SCAN_INTERVAL) @@ -354,6 +355,25 @@ async def test_coordinator_update_fails_when_all_devices_fail( assert hass.states.get(mute_entry.entity_id).state == STATE_UNAVAILABLE +@pytest.mark.usefixtures("init_integration") +async def test_coordinator_status_refresh_invalid_auth( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_imou_ha_device_manager: MagicMock, +) -> None: + """Invalid credentials during status refresh start reauthentication.""" + mock_imou_ha_device_manager.async_update_devices_status.side_effect = ( + InvalidAppIdOrSecretException("bad credentials") + ) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + + @pytest.mark.parametrize( "imou_mock_devices", [ diff --git a/tests/components/imou/test_select.py b/tests/components/imou/test_select.py index 922f5d1008cc5..d1a321be30428 100644 --- a/tests/components/imou/test_select.py +++ b/tests/components/imou/test_select.py @@ -250,11 +250,12 @@ async def test_select_option_unavailable_offline_device( if entry.unique_id == "d1$night_vision_mode" ) - async def set_device_offline(device: ImouHaDevice) -> None: - device._sensors[PARAM_STATUS] = {PARAM_STATE: DeviceStatus.OFFLINE.value} + async def set_devices_offline(devices: list[ImouHaDevice]) -> None: + for device in devices: + device._sensors[PARAM_STATUS] = {PARAM_STATE: DeviceStatus.OFFLINE.value} - mock_imou_ha_device_manager.async_update_device_status.side_effect = ( - set_device_offline + mock_imou_ha_device_manager.async_update_devices_status.side_effect = ( + set_devices_offline ) freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) diff --git a/tests/components/imou/test_sensor.py b/tests/components/imou/test_sensor.py index 01934ea3d45ff..a60c538981399 100644 --- a/tests/components/imou/test_sensor.py +++ b/tests/components/imou/test_sensor.py @@ -96,14 +96,15 @@ async def test_sensor_availability_when_device_offline( ) -> None: """Status stays available offline; other sensors become unavailable.""" - async def set_device_offline(device: ImouHaDevice) -> None: - device._sensors[PARAM_STATUS] = { - PARAM_STATE: DeviceStatus.OFFLINE.value, - PARAM_STATE_VARIANT: STATE_VARIANT_ENUM, - } - - mock_imou_ha_device_manager.async_update_device_status.side_effect = ( - set_device_offline + async def set_devices_offline(devices: list[ImouHaDevice]) -> None: + for device in devices: + device._sensors[PARAM_STATUS] = { + PARAM_STATE: DeviceStatus.OFFLINE.value, + PARAM_STATE_VARIANT: STATE_VARIANT_ENUM, + } + + mock_imou_ha_device_manager.async_update_devices_status.side_effect = ( + set_devices_offline ) freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) diff --git a/tests/components/imou/test_switch.py b/tests/components/imou/test_switch.py index 04f2a7f4a9d27..2c5b88c89beb0 100644 --- a/tests/components/imou/test_switch.py +++ b/tests/components/imou/test_switch.py @@ -243,11 +243,12 @@ async def test_turn_off_unavailable_offline_device_via_service( if entry.unique_id == "d1$motion_detect" ) - async def set_device_offline(device: ImouHaDevice) -> None: - device._sensors[PARAM_STATUS] = {PARAM_STATE: DeviceStatus.OFFLINE.value} + async def set_devices_offline(devices: list[ImouHaDevice]) -> None: + for device in devices: + device._sensors[PARAM_STATUS] = {PARAM_STATE: DeviceStatus.OFFLINE.value} - mock_imou_ha_device_manager.async_update_device_status.side_effect = ( - set_device_offline + mock_imou_ha_device_manager.async_update_devices_status.side_effect = ( + set_devices_offline ) freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) diff --git a/tests/components/kodi/test_browse_media.py b/tests/components/kodi/test_browse_media.py new file mode 100644 index 0000000000000..767f45fb7efc3 --- /dev/null +++ b/tests/components/kodi/test_browse_media.py @@ -0,0 +1,103 @@ +"""Tests for the Kodi media browser.""" + +from unittest.mock import AsyncMock, MagicMock + +from homeassistant.components.kodi.browse_media import get_media_info +from homeassistant.components.media_player import MediaType + +CHANNELS = { + "channels": [ + { + "channelid": 917, + "label": "Das Erste HD", + "thumbnail": "image://pvrchannel_tv%40das_erste/", + }, + { + "channelid": 902, + "label": "ZDF HD", + "thumbnail": "image://pvrchannel_tv%40zdf/", + }, + { + "channelid": 850, + "label": "3sat HD", + }, + ] +} + + +def create_media_library() -> MagicMock: + """Return a Kodi library that answers with the TV channels above.""" + library = MagicMock() + library.get_channels = AsyncMock(return_value=CHANNELS) + library.thumbnail_url = MagicMock( + side_effect=lambda thumbnail: ( + f"http://1.1.1.1:8080/image/{thumbnail}" if thumbnail else None + ) + ) + return library + + +async def test_channel_folder_lists_the_channels() -> None: + """The folder itself is listed and has no thumbnail of its own.""" + library = create_media_library() + + thumbnail, title, media = await get_media_info(library, "", MediaType.CHANNEL) + + assert title == "Channels" + assert media == CHANNELS["channels"] + assert thumbnail is None + + +async def test_channel_asked_for_by_id_carries_its_thumbnail() -> None: + """A single channel returns the image the media player proxy serves. + + An external client fetches a browse thumbnail over + /api/media_player_proxy//browse_media/channel/, which asks the + integration for the image by id. Without this the view answers 404. + """ + library = create_media_library() + + thumbnail, _, _ = await get_media_info(library, "902", MediaType.CHANNEL) + + assert thumbnail == "http://1.1.1.1:8080/image/image://pvrchannel_tv%40zdf/" + + +async def test_channel_without_a_thumbnail_has_no_picture() -> None: + """Kodi does not promise a thumbnail for every channel.""" + library = create_media_library() + + thumbnail, _, _ = await get_media_info(library, "850", MediaType.CHANNEL) + + assert thumbnail is None + + +async def test_channel_no_longer_in_the_group_has_no_thumbnail() -> None: + """An id that is not in the list leaves the thumbnail unset.""" + library = create_media_library() + + thumbnail, _, _ = await get_media_info(library, "123", MediaType.CHANNEL) + + assert thumbnail is None + + +async def test_channel_listing_asks_for_the_epg() -> None: + """The listing needs what it shows: the current broadcast.""" + library = create_media_library() + + await get_media_info(library, "", MediaType.CHANNEL) + + assert library.get_channels.call_args.kwargs["properties"] == [ + "thumbnail", + "channeltype", + "channel", + "broadcastnow", + ] + + +async def test_single_channel_asks_for_no_more_than_the_thumbnail() -> None: + """One image needs no EPG for a hundred and fifty channels.""" + library = create_media_library() + + await get_media_info(library, "902", MediaType.CHANNEL) + + assert library.get_channels.call_args.kwargs["properties"] == ["thumbnail"] diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 3c1ec10c93e10..1d6a26d95d3bc 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -24,7 +24,11 @@ ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + instance_id, +) from homeassistant.setup import async_setup_component from .const import CALL_SERVICE, FIRE_EVENT, REGISTER_CLEARTEXT, RENDER_TEMPLATE, UPDATE @@ -297,6 +301,7 @@ async def test_webhook_handle_get_config( "longitude": hass_config["longitude"], "elevation": hass_config["elevation"], "hass_device_id": device.id, + "instance_id": await instance_id.async_get(hass), "unit_system": hass_config["unit_system"], "location_name": hass_config["location_name"], "time_zone": hass_config["time_zone"], diff --git a/tests/components/ring/test_switch.py b/tests/components/ring/test_switch.py index 22b90253c2374..71bbb45245f46 100644 --- a/tests/components/ring/test_switch.py +++ b/tests/components/ring/test_switch.py @@ -1,9 +1,11 @@ """The tests for the Ring switch platform.""" +from typing import Any from unittest.mock import Mock import pytest import ring_doorbell +from ring_doorbell import RingCapability from syrupy.assertion import SnapshotAssertion from homeassistant.components.ring.const import DOMAIN @@ -15,6 +17,7 @@ SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNKNOWN, Platform, ) from homeassistant.core import HomeAssistant @@ -26,6 +29,50 @@ from tests.common import snapshot_platform +class BadChimeDoorbell: + """Doorbell whose chime type is unknown to ring_doorbell.""" + + family = "doorbots" + id = 987654321 + device_api_id = 987654321 + device_id = "aa:bb:cc:dd:ee:ff" + name = "Bad Chime Doorbell" + + @property + def existing_doorbell_type(self) -> str: + """Mimic ring_doorbell raising for an unknown chime type.""" + raise KeyError(3) + + def has_capability(self, capability: RingCapability) -> bool: + """Return False for all capabilities.""" + return False + + +class ChimeCapabilityLostDoorbell: + """Doorbell whose chime enabled-state read raises after setup.""" + + family = "doorbots" + id = 987654322 + device_api_id = 987654322 + device_id = "aa:bb:cc:dd:ee:00" + name = "Chime Capability Lost Doorbell" + model = "doorbots" + existing_doorbell_type = "Mechanical" + + @property + def existing_doorbell_type_enabled(self) -> bool: + """Mimic ring_doorbell raising when the chime type becomes unknown.""" + raise KeyError(3) + + async def async_set_existing_doorbell_type_enabled(self, value: bool) -> None: + """Mimic ring_doorbell raising when the chime type becomes unknown.""" + raise KeyError(3) + + def has_capability(self, capability: RingCapability) -> bool: + """Return False for all capabilities.""" + return False + + @pytest.fixture def create_deprecated_siren_entity( hass: HomeAssistant, @@ -169,3 +216,56 @@ async def test_switch_errors_when_turned_on( ) == reauth_expected ) + + +@pytest.mark.usefixtures("mock_ring_client", "create_deprecated_siren_entity") +async def test_switch_setup_succeeds_with_unknown_chime_type( + hass: HomeAssistant, mock_ring_devices: Any +) -> None: + """Test that an unknown doorbell chime type does not abort switch setup.""" + mock_ring_devices.all_devices.append(BadChimeDoorbell()) + + await setup_platform(hass, Platform.SWITCH) + + assert hass.states.get("switch.front_siren") + assert hass.states.get("switch.front_motion_detection") + assert not hass.states.get("switch.bad_chime_doorbell_in_home_chime") + + +@pytest.mark.usefixtures("mock_ring_client", "create_deprecated_siren_entity") +async def test_in_home_chime_unknown_when_type_becomes_unreadable( + hass: HomeAssistant, mock_ring_devices: Any +) -> None: + """Test that an in-home chime switch reads as unknown when its type becomes unreadable.""" + mock_ring_devices.all_devices.append(ChimeCapabilityLostDoorbell()) + + await setup_platform(hass, Platform.SWITCH) + + state = hass.states.get("switch.chime_capability_lost_doorbell_in_home_chime") + assert state + assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("mock_ring_client", "create_deprecated_siren_entity") +async def test_in_home_chime_toggle_errors_when_type_unreadable( + hass: HomeAssistant, mock_ring_devices: Any +) -> None: + """Test that toggling raises a translated error when the chime type becomes unreadable.""" + mock_ring_devices.all_devices.append(ChimeCapabilityLostDoorbell()) + + await setup_platform(hass, Platform.SWITCH) + + state = hass.states.get("switch.chime_capability_lost_doorbell_in_home_chime") + assert state + assert state.state == STATE_UNKNOWN + + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": state.entity_id}, + blocking=True, + ) + assert err.value.translation_key == "chime_type_unknown" + assert err.value.translation_domain == DOMAIN + await hass.async_block_till_done() diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index 960c03d5a677c..58223014cb679 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -477,8 +477,11 @@ def sensor_fixture(fixed_now: datetime): data = load_json_object_fixture("sample_sensor.json", DOMAIN) sensor: Sensor = Sensor.from_unifi_dict(**data) + # Distinct offsets: these map to different public fields, and equal values + # would hide a swapped path. tampering stays unset on purpose, a value there + # would flip the tampering binary sensor. sensor.motion_detected_at = fixed_now - timedelta(hours=1) - sensor.open_status_changed_at = fixed_now - timedelta(hours=1) + sensor.open_status_changed_at = fixed_now - timedelta(hours=2) sensor.alarm_triggered_at = fixed_now - timedelta(hours=1) yield sensor diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index ab8e9c9fcc3f9..fc5595d5937a4 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -130,6 +130,28 @@ async def test_sensor_sense_capability_creation_filter( assert (entity_registry.async_get(entity_id) is not None) is created, key +async def test_sensor_sense_metrics_read_their_own_public_path( + hass: HomeAssistant, + ufp: MockUFPFixture, + sensor_all: Sensor, +) -> None: + """Each environmental sensor reads its own metric from the public object. + + The fixture reports the same number for light, humidity and temperature, so + a swapped ``ufp_public_value`` path would go unnoticed without diverging + values here. + """ + setup_public_sensor( + ufp, light_value=11.0, humidity_value=22.0, temperature_value=33.0 + ) + await init_entry(hass, ufp, [sensor_all]) + + name = sensor_all.name.lower().replace(" ", "_") + assert hass.states.get(f"sensor.{name}_illuminance").state == "11.0" + assert hass.states.get(f"sensor.{name}_humidity").state == "22.0" + assert hass.states.get(f"sensor.{name}_temperature").state == "33.0" + + async def test_sensor_setup_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -657,6 +679,7 @@ async def test_sensor_update_alarm_with_last_trip_time( ) -> None: """Test sensor motion entity with last trip time.""" + setup_public_sensor(ufp, tampering_detected_at=fixed_now - timedelta(hours=3)) await init_entry(hass, ufp, [sensor_all]) assert_entity_counts(hass, Platform.SENSOR, 22, 22) @@ -676,10 +699,38 @@ async def test_sensor_update_alarm_with_last_trip_time( assert state assert ( state.state - == (fixed_now - timedelta(hours=1)).replace(microsecond=0).isoformat() + == (fixed_now - timedelta(hours=2)).replace(microsecond=0).isoformat() ) assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + # Door and motion map to different public fields; asserting both with + # different offsets is what catches a swapped path. + _, motion_entity_id = await ids_from_device_description( + hass, + Platform.SENSOR, + sensor_all, + get_sensor_by_key(SENSE_SENSORS, "motion_last_trip_time"), + ) + motion_state = hass.states.get(motion_entity_id) + assert motion_state + assert ( + motion_state.state + == (fixed_now - timedelta(hours=1)).replace(microsecond=0).isoformat() + ) + + _, tamper_entity_id = await ids_from_device_description( + hass, + Platform.SENSOR, + sensor_all, + get_sensor_by_key(SENSE_SENSORS, "tampering_last_trip_time"), + ) + tamper_state = hass.states.get(tamper_entity_id) + assert tamper_state + assert ( + tamper_state.state + == (fixed_now - timedelta(hours=3)).replace(microsecond=0).isoformat() + ) + async def test_sensor_precision( hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor, fixed_now: datetime diff --git a/tests/components/unifiprotect/test_siren.py b/tests/components/unifiprotect/test_siren.py index 55c8b9e5af0ca..b91f512d389b3 100644 --- a/tests/components/unifiprotect/test_siren.py +++ b/tests/components/unifiprotect/test_siren.py @@ -1,18 +1,17 @@ """Tests for the UniFi Protect siren (Public API) entities.""" -from datetime import timedelta -from unittest.mock import AsyncMock, Mock +from collections.abc import Callable, Coroutine +from typing import Any +from unittest.mock import AsyncMock, Mock, PropertyMock, patch import pytest -from uiprotect.data import ( - DeviceState, - ModelType, - PublicSirenStatus, - Siren, - SirenDuration, - WSAction, +from uiprotect.data import DeviceState, ModelType, Siren, SirenDuration, WSAction +from uiprotect.exceptions import ( + BadRequest, + ClientError, + NotAuthorized, + PublicOnlyModeError, ) -from uiprotect.exceptions import ClientError, NotAuthorized from uiprotect.websocket import WebsocketState from homeassistant.components.siren import ( @@ -20,7 +19,11 @@ ATTR_VOLUME_LEVEL, DOMAIN as SIREN_DOMAIN, ) -from homeassistant.components.unifiprotect.const import DOMAIN +from homeassistant.components.unifiprotect.const import ( + CONF_CONNECTION_MODE, + CONNECTION_MODE_API_KEY_ONLY, + DOMAIN, +) from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -33,7 +36,6 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.util import dt as dt_util from .utils import ( MockUFPFixture, @@ -42,8 +44,6 @@ make_public_bootstrap, ) -from tests.common import async_fire_time_changed - SIREN_ID = "siren-id-1" SIREN_MAC = "AA:BB:CC:DD:EE:02" SIREN_NAME = "Garage Siren" @@ -55,11 +55,6 @@ def _make_siren( *, is_active: bool = False, state: DeviceState = DeviceState.CONNECTED ) -> Mock: """Build a mock :class:`Siren`.""" - status = Mock(spec=PublicSirenStatus) - status.is_active = is_active - status.activated_at = None - status.duration = None - status.turn_off_at = None siren = Mock(spec=Siren) siren.id = SIREN_ID siren.mac = SIREN_MAC @@ -67,7 +62,6 @@ def _make_siren( siren.model = ModelType.SIREN siren.state = state siren.volume = 50 - siren.siren_status = status siren.is_active = is_active siren.play = AsyncMock() siren.stop = AsyncMock() @@ -397,7 +391,7 @@ async def test_siren_state_updates_from_public_ws( ufp_with_siren: MockUFPFixture, siren: Mock, ) -> None: - """A public devices WS update for the siren refreshes the entity state.""" + """Public devices WS updates flip the entity on and back off.""" await init_entry(hass, ufp_with_siren, []) state = hass.states.get(SIREN_ENTITY_ID) @@ -415,6 +409,15 @@ async def test_siren_state_updates_from_public_ws( assert state is not None assert state.state == STATE_ON + # A timed run ending arrives the same way, as an update with the flag off. + siren.is_active = False + ufp_with_siren.devices_ws_subscription(_make_ws_msg(siren)) + await hass.async_block_till_done() + + state = hass.states.get(SIREN_ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + async def test_siren_ws_update_no_state_change( hass: HomeAssistant, @@ -466,149 +469,6 @@ async def test_siren_availability_follows_websocket_state( assert state.state == STATE_OFF -async def test_siren_auto_off_after_timed_duration( - hass: HomeAssistant, - ufp_with_siren: MockUFPFixture, - siren: Mock, -) -> None: - """State flips to OFF automatically when a timed duration expires. - - The public devices WS never sends an 'off' event for timed runs, so the - entity must schedule its own callback via async_call_later. - """ - await init_entry(hass, ufp_with_siren, []) - - state = hass.states.get(SIREN_ENTITY_ID) - assert state is not None - assert state.state == STATE_OFF - - # Simulate a WS update: siren becomes active for 10 seconds. - now = dt_util.utcnow() - - active_status = Mock(spec=PublicSirenStatus) - active_status.is_active = True - active_status.activated_at = int(now.timestamp() * 1000) - active_status.duration = 10000 - active_status.turn_off_at = ( - None # implementation uses activated_at+duration directly - ) - - siren.is_active = True - siren.siren_status = active_status - - mock_msg = _make_ws_msg(siren) - assert ufp_with_siren.devices_ws_subscription is not None - ufp_with_siren.devices_ws_subscription(mock_msg) - await hass.async_block_till_done() - - state = hass.states.get(SIREN_ENTITY_ID) - assert state is not None - assert state.state == STATE_ON - - # Advance HA time past turn_off_at — the scheduled callback should fire. - async_fire_time_changed(hass, now + timedelta(seconds=11)) - await hass.async_block_till_done() - - state = hass.states.get(SIREN_ENTITY_ID) - assert state is not None - assert state.state == STATE_OFF - - -async def test_siren_turn_off_cancels_scheduled_timer( - hass: HomeAssistant, - ufp_with_siren: MockUFPFixture, - siren: Mock, -) -> None: - """Manual turn_off cancels the pending auto-off timer. - - When a timed run is active the entity holds a scheduled callback. A - manual turn_off must cancel that callback so the timer never fires and - the state stays OFF afterwards. - """ - await init_entry(hass, ufp_with_siren, []) - - # Start a timed run — schedules an auto-off callback 30 s from now. - now = dt_util.utcnow() - active_status = Mock(spec=PublicSirenStatus) - active_status.is_active = True - active_status.activated_at = int(now.timestamp() * 1000) - active_status.duration = 30000 # 30 s — won't expire on its own - active_status.turn_off_at = None - - siren.is_active = True - siren.siren_status = active_status - - mock_msg = _make_ws_msg(siren) - assert ufp_with_siren.devices_ws_subscription is not None - ufp_with_siren.devices_ws_subscription(mock_msg) - await hass.async_block_till_done() - - state = hass.states.get(SIREN_ENTITY_ID) - assert state is not None - assert state.state == STATE_ON - - # Manually turn off — must cancel the scheduled timer. - await hass.services.async_call( - SIREN_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: SIREN_ENTITY_ID}, - blocking=True, - ) - state = hass.states.get(SIREN_ENTITY_ID) - assert state is not None - assert state.state == STATE_OFF - - # Advance time past the original timer — state must stay OFF. - async_fire_time_changed(hass, now + timedelta(seconds=35)) - await hass.async_block_till_done() - - state = hass.states.get(SIREN_ENTITY_ID) - assert state is not None - assert state.state == STATE_OFF - - -async def test_siren_auto_off_when_already_expired_at_update( - hass: HomeAssistant, - ufp_with_siren: MockUFPFixture, - siren: Mock, -) -> None: - """State flips to OFF when a WS update arrives with an already-expired duration. - - On reconnect, the public bootstrap may still report is_active=True with an - activated_at+duration that is already in the past. The entity must treat - delay<=0 as immediately expired and set its state to OFF immediately. - """ - await init_entry(hass, ufp_with_siren, []) - - state = hass.states.get(SIREN_ENTITY_ID) - assert state is not None - assert state.state == STATE_OFF - - # Build a status whose turn-off time is 5 seconds in the PAST. - now = dt_util.utcnow() - expired_activated_at = int((now.timestamp() - 15) * 1000) # 15 s ago - - expired_status = Mock(spec=PublicSirenStatus) - expired_status.is_active = True - expired_status.activated_at = expired_activated_at - expired_status.duration = 10000 # 10 s → expired 5 s ago - expired_status.turn_off_at = None - - siren.is_active = True - siren.siren_status = expired_status - - mock_msg = _make_ws_msg(siren) - assert ufp_with_siren.devices_ws_subscription is not None - ufp_with_siren.devices_ws_subscription(mock_msg) - await hass.async_block_till_done() - - # Entity stays OFF: delay<=0 overrides is_active=True inline, so the state - # machine never sees ON. - state = hass.states.get(SIREN_ENTITY_ID) - assert state is not None - assert state.state == STATE_OFF - - @pytest.mark.parametrize( "state", [DeviceState.DISCONNECTED, DeviceState.CONNECTING, DeviceState.UNKNOWN], @@ -681,67 +541,132 @@ async def test_siren_unavailable_on_delete_event( assert state.state == STATE_UNAVAILABLE -async def test_siren_auto_off_timer_scheduled_at_startup( +@pytest.fixture(name="setup_hybrid") +def setup_hybrid_fixture( + hass: HomeAssistant, ufp: MockUFPFixture +) -> Callable[[], Coroutine[Any, Any, None]]: + """Return a callable setting up the hybrid entry without a siren.""" + ufp.api.has_public_bootstrap = True + pb = _make_public_bootstrap(None) + ufp.api.public_bootstrap = pb + ufp.api.update_public = AsyncMock(return_value=pb) + + async def _setup() -> None: + await init_entry(hass, ufp, []) + + return _setup + + +def _add_siren_frame(ufp: MockUFPFixture, siren: Mock) -> None: + """Deliver a public devices websocket add frame for ``siren``.""" + ufp.api.public_bootstrap.sirens[siren.id] = siren + msg = _make_ws_msg(siren) + msg.action = WSAction.ADD + ufp.devices_ws_subscription(msg) + + +@pytest.mark.parametrize( + ("ufp_fixture", "setup_fixture"), + [ + pytest.param("ufp_public_only", "setup_public_only", id="public_only"), + pytest.param("ufp", "setup_hybrid", id="hybrid"), + ], +) +async def test_siren_added_after_setup( hass: HomeAssistant, - ufp_with_siren: MockUFPFixture, + request: pytest.FixtureRequest, + entity_registry: er.EntityRegistry, + ufp_fixture: str, + setup_fixture: str, siren: Mock, ) -> None: - """Auto-off timer is scheduled for an already-active siren. + """A siren adopted after setup gets its entity in both modes. - If a timed run is already in progress when HA starts, the entity must - schedule its own auto-off callback immediately (not wait for a WS update) - so the siren does not remain stuck ON after the run expires. + The private bootstrap has no store for sirens, so the adopt path never + sees one; discovery goes through the public add signal in both modes. """ - # Configure the siren as already active with 10 s remaining. - now = dt_util.utcnow() - active_status = Mock(spec=PublicSirenStatus) - active_status.is_active = True - active_status.activated_at = int(now.timestamp() * 1000) - active_status.duration = 10000 - active_status.turn_off_at = None + ufp: MockUFPFixture = request.getfixturevalue(ufp_fixture) + setup: Callable[[], Coroutine[Any, Any, None]] = request.getfixturevalue( + setup_fixture + ) + await setup() + assert entity_registry.async_get(SIREN_ENTITY_ID) is None - siren.is_active = True - siren.siren_status = active_status + _add_siren_frame(ufp, siren) + await hass.async_block_till_done() - await init_entry(hass, ufp_with_siren, []) + assert entity_registry.async_get(SIREN_ENTITY_ID) is not None - state = hass.states.get(SIREN_ENTITY_ID) - assert state is not None - assert state.state == STATE_ON - # Advance HA time past the expiry — the startup-scheduled timer must fire. - async_fire_time_changed(hass, now + timedelta(seconds=11)) - await hass.async_block_till_done() +async def test_public_only_siren_end_to_end( + hass: HomeAssistant, + ufp_public_only: MockUFPFixture, + setup_public_only: Callable[[], Coroutine[Any, Any, None]], + siren: Mock, +) -> None: + """An API-key-only entry with a siren creates a working entity. + + Exercises the real public-only setup path: reading the private bootstrap + raises on that client, so the siren platform must not touch it. + """ + ufp_public_only.api.public_bootstrap.sirens = {siren.id: siren} + + await setup_public_only() state = hass.states.get(SIREN_ENTITY_ID) assert state is not None assert state.state == STATE_OFF + # Commands go to the public object; there is no private one to fall back to. + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: SIREN_ENTITY_ID}, + blocking=True, + ) + siren.play.assert_awaited_once_with(duration=None) + -async def test_siren_added_after_setup_in_hybrid( +async def test_siren_survives_switch_to_public_only( hass: HomeAssistant, entity_registry: er.EntityRegistry, - ufp: MockUFPFixture, - siren: Mock, + ufp_with_siren: MockUFPFixture, ) -> None: - """A siren adopted after setup gets its entity in hybrid mode too. + """An entry switched to API-key-only keeps its siren entity. - The private bootstrap has no store for sirens, so the adopt path never - sees one; discovery goes through the public add signal in both modes. + The entity is built from the public object in both modes, so after the + switch the existing registry entry is re-adopted instead of being left + behind without an entity. """ - ufp.api.has_public_bootstrap = True - pb = _make_public_bootstrap(None) - ufp.api.public_bootstrap = pb - ufp.api.update_public = AsyncMock(return_value=pb) - + ufp = ufp_with_siren await init_entry(hass, ufp, []) - assert entity_registry.async_get(SIREN_ENTITY_ID) is None + registry_entry = entity_registry.async_get(SIREN_ENTITY_ID) + assert registry_entry is not None + assert hass.states.get(SIREN_ENTITY_ID).state == STATE_OFF - pb.sirens = {siren.id: siren} - msg = _make_ws_msg(siren) - msg.action = WSAction.ADD - assert ufp.devices_ws_subscription is not None - ufp.devices_ws_subscription(msg) + await hass.config_entries.async_unload(ufp.entry.entry_id) await hass.async_block_till_done() - assert entity_registry.async_get(SIREN_ENTITY_ID) is not None + # The public-only setup path registers the NVR from the public bootstrap. + api = ufp.api + api.public_bootstrap.nvr = api.bootstrap.nvr + + # Flip both the stored mode and the client, as reconfiguring does. + hass.config_entries.async_update_entry( + ufp.entry, + data={**ufp.entry.data, CONF_CONNECTION_MODE: CONNECTION_MODE_API_KEY_ONLY}, + ) + api.is_public_only = True + type(api).bootstrap = PropertyMock(side_effect=BadRequest("public-only")) + api.update = AsyncMock(side_effect=PublicOnlyModeError("public-only")) + api.update_public = AsyncMock(return_value=api.public_bootstrap) + + with patch( + "homeassistant.components.unifiprotect.async_create_api_client", + return_value=api, + ): + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + assert entity_registry.async_get(SIREN_ENTITY_ID).id == registry_entry.id + assert hass.states.get(SIREN_ENTITY_ID).state == STATE_OFF diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index 5cc9468e34ba0..d2c69f75e8c0e 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -1294,14 +1294,15 @@ async def test_switch_hybrid_public_sensor_without_private_deferred( @pytest.mark.parametrize( - ("fixture_name", "make", "key", "setter", "absent_keys"), + ("fixture_name", "make", "key", "setter", "present_keys", "absent_keys"), [ pytest.param( "doorbell", _make_streamless_public_camera, "smart_person", "set_person_detection", - {"ssh", "motion", "high_fps", "privacy_mode", "color_night_vision"}, + {"high_fps"}, + {"ssh", "motion", "privacy_mode", "color_night_vision"}, id="camera", ), pytest.param( @@ -1313,6 +1314,7 @@ async def test_switch_hybrid_public_sensor_without_private_deferred( ), "motion", "set_motion_status", + set(), {"status_light", "temperature"}, id="sensor", ), @@ -1321,6 +1323,7 @@ async def test_switch_hybrid_public_sensor_without_private_deferred( partial(make_public_light, is_indicator_enabled=True), "status_light", "set_status_light", + set(), {"ssh"}, id="light", ), @@ -1337,6 +1340,7 @@ async def test_public_only_switch_end_to_end( make: Callable[[Any], Mock], key: str, setter: str, + present_keys: set[str], absent_keys: set[str], ) -> None: """A public-only entry builds the migrated switches from the public object. @@ -1354,6 +1358,7 @@ async def test_public_only_switch_end_to_end( assert ufp_public_only.entry.state is ConfigEntryState.LOADED keys = _switch_keys(entity_registry, device.mac) assert key in keys + assert present_keys <= keys assert not keys & absent_keys assert hass.states.get("switch.unifiprotect_insights_enabled") is None diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 79c350e76e8e1..8f16f1cca9991 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -2,7 +2,7 @@ from collections.abc import Callable, Iterator, Sequence from dataclasses import dataclass -from datetime import timedelta +from datetime import datetime, timedelta from functools import partial from typing import Any from unittest.mock import AsyncMock, Mock @@ -43,7 +43,9 @@ PublicSensor, PublicSensorAlarmSettingsRead, PublicSensorLeakSettings, + PublicSensorMetric, PublicSensorMotionSettingsRead, + PublicSensorStats, PublicSensorThresholdSettings, PublicSmartDetectSettings, PublicWirelessBatteryStatus, @@ -51,6 +53,7 @@ SensorFeatureCapability, ) from uiprotect.test_util.anonymize import random_hex +from uiprotect.utils import to_js_time from uiprotect.websocket import WebsocketState from homeassistant.const import Platform @@ -354,6 +357,10 @@ def make_public_sensor( capabilities: set[SensorFeatureCapability] | None = None, leak_internal_enabled: bool = False, leak_external_enabled: bool = False, + light_value: float | None = None, + humidity_value: float | None = None, + temperature_value: float | None = None, + tampering_detected_at: datetime | None = None, ) -> Mock: """Build a public-API sensor mirroring a private sensor's migrated fields. @@ -375,12 +382,6 @@ def make_public_sensor( public.model = ModelType.SENSOR public.state = DeviceState[sensor.state.name] if state is None else state public.mount_type = sensor.mount_type if mount_type is None else mount_type - public.is_contact_sensor_enabled = public.mount_type in { - MountType.DOOR, - MountType.WINDOW, - MountType.GARAGE, - } - public.is_leak_sensor_enabled = public.mount_type is MountType.LEAK public.is_opened = sensor.is_opened if is_opened is None else is_opened public.is_leak_detected = ( sensor.is_leak_detected if is_leak_detected is None else is_leak_detected @@ -447,6 +448,45 @@ def make_public_sensor( is_low=sensor.battery_status.is_low if is_low is None else is_low, ) ) + # The fixture reports the same number for all three metrics, so a test that + # has to tell the value paths apart passes its own. + public.stats = PublicSensorStats( + **{ + name: PublicSensorMetric( + value=getattr(sensor.stats, name).value if value is None else value + ) + for name, value in ( + ("light", light_value), + ("humidity", humidity_value), + ("temperature", temperature_value), + ) + } + ) + # The public API reports these as a JS epoch; the fixture leaves tampering + # unset, so a test asserting that path passes its own instant. + public.open_status_changed_at = to_js_time(sensor.open_status_changed_at) + public.motion_detected_at = to_js_time(sensor.motion_detected_at) + public.tampering_detected_at = to_js_time( + sensor.tampering_detected_at + if tampering_detected_at is None + else tampering_detected_at + ) + # Mocks do not evaluate properties, so derive them with the library's own + # logic: a wrong assumption about what gates a metric, or about how the + # epoch fields convert, fails the test. + for name in ( + "is_contact_sensor_enabled", + "is_leak_sensor_enabled", + "is_motion_sensor_enabled", + "is_alarm_sensor_enabled", + "is_temperature_sensor_enabled", + "is_humidity_sensor_enabled", + "is_light_sensor_enabled", + "open_status_changed_at_dt", + "motion_detected_at_dt", + "tampering_detected_at_dt", + ): + setattr(public, name, getattr(PublicSensor, name).fget(public)) return public @@ -517,6 +557,7 @@ def make_public_light( lds.pir_sensitivity if pir_sensitivity is None else pir_sensitivity ), ) + public.last_motion_dt = PublicLight.last_motion_dt.fget(public) return public @@ -637,9 +678,10 @@ def make_public_camera( if hdr_type is None else hdr_type ) + public.hdr_mode_display = PublicCamera.hdr_mode_display.fget(public) flags = camera.feature_flags public.has_package_camera = flags.has_package_camera - # Spec'd so a private-only flag (e.g. ``has_highfps``) reads as absent. + # Spec'd so a private-only flag reads as absent. public.feature_flags = Mock(spec=PublicCameraFeatureFlags) public.feature_flags.support_full_hd_snapshot = flags.support_full_hd_snapshot public.feature_flags.has_hdr = flags.has_hdr @@ -651,6 +693,10 @@ def make_public_camera( public.feature_flags.smart_detect_audio_types = list( flags.smart_detect_audio_types or [] ) + # Derived from the mirrored video modes with the library's own logic. + public.feature_flags.has_highfps = PublicCameraFeatureFlags.has_highfps.fget( + public.feature_flags + ) # The capability gate runs the library's own logic on the mirrored flags. public.can_detect = Mock(side_effect=partial(PublicCamera.can_detect, public)) qualities = [ChannelQuality.HIGH, ChannelQuality.MEDIUM, ChannelQuality.LOW] @@ -663,17 +709,19 @@ def make_public_camera( def setup_public_sensor( ufp: MockUFPFixture, capabilities: set[SensorFeatureCapability] | None = None, + **mirror_overrides: Any, ) -> None: """Expose private sensors over the public API via a real ``PublicBootstrap``. Lookups go through the real ``PublicBootstrap.get``; the mirror resolves against the private bootstrap at call time, so it is robust to ``init_entry`` regenerating device ids. ``capabilities`` is forwarded to the mirror to model - newer firmware with a capability map. + newer firmware with a capability map. Further keyword arguments are handed + to ``make_public_sensor``, so a test can diverge a mirrored value. """ public_bootstrap = PublicBootstrap() pb = make_public_bootstrap(sensors=public_bootstrap.sensors) - make = partial(make_public_sensor, capabilities=capabilities) + make = partial(make_public_sensor, capabilities=capabilities, **mirror_overrides) def _get(model: ModelType, obj_id: str) -> ProtectModelWithId | None: if ( diff --git a/tests/components/vistapool/test_number.py b/tests/components/vistapool/test_number.py index dfc0f801f347c..21be9b11b9590 100644 --- a/tests/components/vistapool/test_number.py +++ b/tests/components/vistapool/test_number.py @@ -13,7 +13,7 @@ DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -113,6 +113,44 @@ async def test_number_electrolysis_max_fallback( assert state.attributes["max"] == 50.0 +@pytest.mark.parametrize( + ("entity_id", "pool_data"), + [ + pytest.param( + "number.my_pool_ph_maximum", + { + "main": {"hasPH": 1, "version": 1}, + "modules": {"ph": {"status": {"high_value": "garbage"}}}, + }, + id="ph_maximum", + ), + pytest.param( + "number.my_pool_redox_setpoint", + { + "main": {"hasRX": 1, "version": 1}, + "modules": {"rx": {"status": {"value": "garbage"}}}, + }, + id="redox_setpoint", + ), + ], +) +async def test_number_unknown_when_unparsable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + entity_id: str, + pool_data: dict[str, Any], +) -> None: + """Test an unparsable raw setpoint reads as unknown rather than raising.""" + mock_vistapool_client.fetch_pool_data.return_value = pool_data + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNKNOWN + + async def test_number_hydrolysis_setpoint_branch( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/vistapool/test_select.py b/tests/components/vistapool/test_select.py index 853c660be7612..f379946e0b595 100644 --- a/tests/components/vistapool/test_select.py +++ b/tests/components/vistapool/test_select.py @@ -276,6 +276,34 @@ async def test_light_mode_current_option( assert hass.states.get("select.my_pool_light_mode").state == expected +@pytest.mark.parametrize( + "light_data", + [ + pytest.param({"status": 0}, id="mode_missing"), + pytest.param({"mode": 0}, id="status_missing"), + ], +) +async def test_light_mode_unknown_after_partial_push( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + light_data: dict[str, Any], +) -> None: + """Test the light mode reports unknown when a push drops a field it derives from.""" + mock_vistapool_client.fetch_pool_data.return_value = deepcopy(_LIGHT_SCHEDULE_DATA) + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get("select.my_pool_light_mode").state == "auto" + + on_data = mock_vistapool_client.subscribe_pool_resilient.call_args.args[1] + on_data({"main": {"version": 1}, "light": light_data}) + await hass.async_block_till_done() + + assert hass.states.get("select.my_pool_light_mode").state == STATE_UNKNOWN + + @pytest.mark.parametrize( ("option", "expected_updates"), [ diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 188871d07e47c..f002412ba497b 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -1935,6 +1935,35 @@ def test_theme_selector_schema(schema, valid_selections, invalid_selections) -> }, ), ), + ( + { + "accept": ["image/*"], + "image_upload": True, + }, + ( + { + "media_content_id": "abc", + "media_content_type": "def", + }, + { + "media_content_id": "abc", + "media_content_type": "def", + "metadata": {}, + }, + ), + ( + None, + "abc", + {}, + { + # We do not allow entity_id when accept is set + "entity_id": "sensor.abc", + "media_content_id": "abc", + "media_content_type": "def", + "metadata": {}, + }, + ), + ), ], ) def test_media_selector_schema(schema, valid_selections, invalid_selections) -> None: @@ -1942,6 +1971,24 @@ def test_media_selector_schema(schema, valid_selections, invalid_selections) -> _test_selector("media", schema, valid_selections, invalid_selections) +@pytest.mark.parametrize( + "schema", + [ + # image_upload can only be used when accept is not empty + {"image_upload": True}, + {"image_upload": True, "accept": []}, + ], +) +def test_media_selector_schema_error( + schema: dict[str, bool | list[str]], +) -> None: + """Test media selector with invalid config.""" + with pytest.raises( + probatio.Invalid, match="image_upload can only be used when accept is not empty" + ): + selector.validate_selector({"media": schema}) + + @pytest.mark.parametrize( ("schema", "valid_selections", "invalid_selections"), [