diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bead8a7590d589..649bf7b918ddf9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -39,7 +39,7 @@ on: env: CACHE_VERSION: 4 MYPY_CACHE_VERSION: 1 - HA_SHORT_VERSION: "2026.8" + HA_SHORT_VERSION: "2026.9" ADDITIONAL_PYTHON_VERSIONS: "[]" # 10.3 is the oldest supported version # - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0fb964c2d28110..cf8e3ac596d8b7 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -33,6 +33,6 @@ jobs: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e0647621c2984b5ed2f768cb892365bf2a616ad1 # v4.37.2 + uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 with: category: "/language:python" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 27db7fa14ee263..cce59b79eaabc9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.22 + rev: v0.16.0 hooks: - id: ruff-check args: diff --git a/.strict-typing b/.strict-typing index e7e3bd1c8870d7..78f468eb9bccd3 100644 --- a/.strict-typing +++ b/.strict-typing @@ -195,6 +195,7 @@ homeassistant.components.elgato.* homeassistant.components.elkm1.* homeassistant.components.emulated_hue.* homeassistant.components.energenie_power_sockets.* +homeassistant.components.energieleser.* homeassistant.components.energy.* homeassistant.components.energyid.* homeassistant.components.energyzero.* @@ -408,6 +409,7 @@ homeassistant.components.nfandroidtv.* homeassistant.components.nightscout.* homeassistant.components.nissan_leaf.* homeassistant.components.no_ip.* +homeassistant.components.nobo_hub.* homeassistant.components.nordpool.* homeassistant.components.notify.* homeassistant.components.notion.* diff --git a/homeassistant/components/acmeda/helpers.py b/homeassistant/components/acmeda/helpers.py index 06f6c048655e37..b1f4ceab45a882 100644 --- a/homeassistant/components/acmeda/helpers.py +++ b/homeassistant/components/acmeda/helpers.py @@ -49,7 +49,7 @@ async def update_devices( for api_item in api.values(): # Update Device name device = dev_registry.async_get_device_by_identifier( - (DOMAIN, api_item.id), config_entry.entry_id + (DOMAIN, str(api_item.id)), config_entry.entry_id ) if device is not None: dev_registry.async_update_device( diff --git a/homeassistant/components/ai_task/__init__.py b/homeassistant/components/ai_task/__init__.py index 3840fc515b0ab5..bc3b5db2cf27c6 100644 --- a/homeassistant/components/ai_task/__init__.py +++ b/homeassistant/components/ai_task/__init__.py @@ -181,8 +181,8 @@ async def async_load(self) -> None: def async_set_preferences( self, *, - gen_data_entity_id: str | None | UndefinedType = UNDEFINED, - gen_image_entity_id: str | None | UndefinedType = UNDEFINED, + gen_data_entity_id: str | UndefinedType | None = UNDEFINED, + gen_image_entity_id: str | UndefinedType | None = UNDEFINED, ) -> None: """Set the preferences.""" changed = False diff --git a/homeassistant/components/arwn/sensor.py b/homeassistant/components/arwn/sensor.py index ba5615415ffbdc..6ed178012e8c88 100644 --- a/homeassistant/components/arwn/sensor.py +++ b/homeassistant/components/arwn/sensor.py @@ -41,7 +41,7 @@ def async_sensor_event_received(msg: mqtt.ReceiveMessage) -> None: try: event = json_loads_object(msg.payload) device = parse_message(msg.topic, event) - except Exception: # noqa: BLE001 + except Exception: _LOGGER.debug( "Failed to parse ARWN message on topic %s", msg.topic, diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index c7932f7adee970..a54bec88a861c8 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -342,13 +342,13 @@ async def async_update_pipeline( conversation_language: str | UndefinedType = UNDEFINED, language: str | UndefinedType = UNDEFINED, name: str | UndefinedType = UNDEFINED, - stt_engine: str | None | UndefinedType = UNDEFINED, - stt_language: str | None | UndefinedType = UNDEFINED, - tts_engine: str | None | UndefinedType = UNDEFINED, - tts_language: str | None | UndefinedType = UNDEFINED, - tts_voice: str | None | UndefinedType = UNDEFINED, - wake_word_entity: str | None | UndefinedType = UNDEFINED, - wake_word_id: str | None | UndefinedType = UNDEFINED, + stt_engine: str | UndefinedType | None = UNDEFINED, + stt_language: str | UndefinedType | None = UNDEFINED, + tts_engine: str | UndefinedType | None = UNDEFINED, + tts_language: str | UndefinedType | None = UNDEFINED, + tts_voice: str | UndefinedType | None = UNDEFINED, + wake_word_entity: str | UndefinedType | None = UNDEFINED, + wake_word_id: str | UndefinedType | None = UNDEFINED, prefer_local_intents: bool | UndefinedType = UNDEFINED, ) -> None: """Update a pipeline.""" diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 00c245380deef8..557ed76408d3bb 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -231,7 +231,7 @@ async def _async_get_stream_image( height: int | None = None, wait_for_next_keyframe: bool = False, ) -> bytes | None: - if (provider := camera._webrtc_provider) and ( # noqa: SLF001 + if (provider := camera.webrtc_provider) and ( image := await provider.async_get_image(camera, width=width, height=height) ) is not None: return image @@ -407,6 +407,21 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.data[DATA_COMPONENT].async_unload_entry(entry) +async def _async_call_webrtc_provider( + coro: Coroutine[Any, Any, None], description: str, entity_id: str +) -> None: + """Await a WebRTC provider callback without letting exceptions propagate. + + Provider callbacks can do I/O and must not break camera setup or removal. + """ + try: + await coro + except HomeAssistantError as ex: + _LOGGER.error("Error %s %s: %s", description, entity_id, ex) + except Exception: + _LOGGER.exception("Unexpected error %s %s", description, entity_id) + + CACHED_PROPERTIES_WITH_ATTR_ = { "brand", "frame_interval", @@ -514,6 +529,12 @@ def available(self) -> bool: return False return super().available + @final + @property + def webrtc_provider(self) -> CameraWebRTCProvider | None: + """Return the WebRTC provider.""" + return self._webrtc_provider + async def async_create_stream(self) -> Stream | None: """Create a Stream for stream_source.""" # There is at most one stream (a decode worker) per camera @@ -683,6 +704,18 @@ async def async_internal_added_to_hass(self) -> None: self.__supports_stream = self.supported_features & CameraEntityFeature.STREAM await self.async_refresh_providers(write_state=False) + @override + async def async_internal_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + if self._webrtc_provider: + await _async_call_webrtc_provider( + self._webrtc_provider.async_unregister_camera(self), + "unregistering WebRTC provider for", + self.entity_id, + ) + self._webrtc_provider = None + await super().async_internal_will_remove_from_hass() + async def async_refresh_providers(self, *, write_state: bool = True) -> None: """Determine if any of the registered providers are suitable for this entity. @@ -699,11 +732,27 @@ async def async_refresh_providers(self, *, write_state: bool = True) -> None: async_get_supported_provider ) - if old_provider != new_provider: - self._webrtc_provider = new_provider - self._invalidate_camera_capabilities_cache() - if write_state: - self.async_write_ha_state() + if old_provider == new_provider: + return + + if old_provider: + await _async_call_webrtc_provider( + old_provider.async_unregister_camera(self), + "unregistering WebRTC provider for", + self.entity_id, + ) + + if new_provider: + await _async_call_webrtc_provider( + new_provider.async_register_camera(self), + "registering WebRTC provider for", + self.entity_id, + ) + + self._webrtc_provider = new_provider + self._invalidate_camera_capabilities_cache() + if write_state: + self.async_write_ha_state() async def _async_get_supported_webrtc_provider[_T]( self, fn: Callable[[HomeAssistant, Camera], Coroutine[None, None, _T | None]] @@ -969,6 +1018,14 @@ async def websocket_update_prefs( _LOGGER.error("Error setting camera preferences: %s", ex) connection.send_error(msg["id"], "update_failed", str(ex)) else: + if (camera := hass.data[DATA_COMPONENT].get_entity(entity_id)) and ( + provider := camera.webrtc_provider + ): + await _async_call_webrtc_provider( + provider.async_on_camera_prefs_update(camera), + "notifying WebRTC provider of preferences update for", + entity_id, + ) connection.send_result(msg["id"], entity_prefs) diff --git a/homeassistant/components/camera/webrtc.py b/homeassistant/components/camera/webrtc.py index 6ac905c8f049be..64cf62119b20fc 100644 --- a/homeassistant/components/camera/webrtc.py +++ b/homeassistant/components/camera/webrtc.py @@ -145,7 +145,7 @@ async def async_on_webrtc_candidate( @callback def async_close_session(self, session_id: str) -> None: """Close the session.""" - return ## This is an optional method so we need a default here. + return # This is an optional method so we need a default here. async def async_get_image( self, @@ -156,6 +156,18 @@ async def async_get_image( """Get an image from the camera.""" return None + async def async_register_camera(self, camera: Camera) -> None: + """Will be called when the provider is registered for a camera.""" + return # This is an optional method so we need a default here. + + async def async_unregister_camera(self, camera: Camera) -> None: + """Will be called when the provider is unregistered for a camera.""" + return # This is an optional method so we need a default here. + + async def async_on_camera_prefs_update(self, camera: Camera) -> None: + """Will be called when the camera preferences are updated.""" + return # This is an optional method so we need a default here. + @callback def async_register_webrtc_provider( diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 7fcf1fbd7d4284..e291820b32b36e 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -175,12 +175,12 @@ async def async_update( google_connected: bool | UndefinedType = UNDEFINED, google_enabled: bool | UndefinedType = UNDEFINED, google_report_state: bool | UndefinedType = UNDEFINED, - google_secure_devices_pin: str | None | UndefinedType = UNDEFINED, + google_secure_devices_pin: str | UndefinedType | None = UNDEFINED, google_settings_version: int | UndefinedType = UNDEFINED, remote_allow_remote_enable: bool | UndefinedType = UNDEFINED, - remote_domain: str | None | UndefinedType = UNDEFINED, + remote_domain: str | UndefinedType | None = UNDEFINED, onboarded_items: list[str] | UndefinedType = UNDEFINED, - onboarding_postponed_until: str | None | UndefinedType = UNDEFINED, + onboarding_postponed_until: str | UndefinedType | None = UNDEFINED, remote_enabled: bool | UndefinedType = UNDEFINED, tts_default_voice: tuple[str, str] | UndefinedType = UNDEFINED, ) -> None: diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py index 612daec7a46ee7..601f5df569dcbe 100644 --- a/homeassistant/components/daikin/climate.py +++ b/homeassistant/components/daikin/climate.py @@ -392,7 +392,11 @@ class DaikinZoneClimate(DaikinEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_has_entity_name = True - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_target_temperature_step = 1 def __init__(self, coordinator: DaikinCoordinator, zone_id: int) -> None: @@ -403,18 +407,26 @@ def __init__(self, coordinator: DaikinCoordinator, zone_id: int) -> None: zone_name = self.device.zones[self._zone_id][0] self._attr_name = f"{zone_name} temperature" + @property + def _main_hvac_mode(self) -> HVACMode: + """Return the main unit HVAC mode.""" + daikin_mode = self.device.represent(HA_ATTR_TO_DAIKIN[ATTR_HVAC_MODE])[1] + return DAIKIN_TO_HA_STATE.get(daikin_mode, HVACMode.HEAT_COOL) + @property @override def hvac_modes(self) -> list[HVACMode]: """Return the hvac modes (mirrors the main unit).""" - return [self.hvac_mode] + return [self._main_hvac_mode] @property @override def hvac_mode(self) -> HVACMode: """Return the current HVAC mode.""" - daikin_mode = self.device.represent(HA_ATTR_TO_DAIKIN[ATTR_HVAC_MODE])[1] - return DAIKIN_TO_HA_STATE.get(daikin_mode, HVACMode.HEAT_COOL) + main_mode = self._main_hvac_mode + if main_mode == HVACMode.OFF or self.device.zones[self._zone_id][1] != "1": + return HVACMode.OFF + return main_mode @property @override @@ -427,7 +439,7 @@ def hvac_action(self) -> HVACAction | None: def target_temperature(self) -> float | None: """Return the zone target temperature for the active mode.""" heating, cooling = _zone_temperature_lists(self.device) - mode = self.hvac_mode + mode = self._main_hvac_mode if mode == HVACMode.HEAT: return _zone_temperature_from_list(heating, self._zone_id) if mode == HVACMode.COOL: @@ -499,7 +511,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None: if target is None: raise _zone_error("zone_parameters_unavailable") - mode = self.hvac_mode + mode = self._main_hvac_mode if mode == HVACMode.HEAT: zone_key = DAIKIN_ZONE_TEMP_HEAT elif mode == HVACMode.COOL: @@ -515,6 +527,26 @@ async def async_set_temperature(self, **kwargs: Any) -> None: await self.coordinator.async_request_refresh() + @override + async def async_turn_on(self) -> None: + """Turn the zone on.""" + await self.device.set_zone(self._zone_id, "zone_onoff", "1") + await self.coordinator.async_refresh() + + @override + async def async_turn_off(self) -> None: + """Turn the zone off.""" + await self.device.set_zone(self._zone_id, "zone_onoff", "0") + await self.coordinator.async_refresh() + + @override + async def async_toggle(self) -> None: + """Toggle the zone.""" + if self.device.zones[self._zone_id][1] == "1": + await self.async_turn_off() + else: + await self.async_turn_on() + @override async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Disallow changing HVAC mode via zone climate.""" diff --git a/homeassistant/components/daikin/config_flow.py b/homeassistant/components/daikin/config_flow.py index 0c44e378cd5da3..265f6f9240e609 100644 --- a/homeassistant/components/daikin/config_flow.py +++ b/homeassistant/components/daikin/config_flow.py @@ -104,12 +104,11 @@ async def _create_device( data_schema=self.schema, errors={"base": "invalid_auth"}, ) - except DaikinException as daikin_exp: - _LOGGER.error(daikin_exp) + except DaikinException: return self.async_show_form( step_id="user", data_schema=self.schema, - errors={"base": "unknown"}, + errors={"base": "cannot_connect"}, ) except Exception: _LOGGER.exception("Unexpected error creating device") diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index 48f6077d53717a..a33848ccd7dc38 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -233,14 +233,17 @@ def async_process_client( registered_devices_domains = matchers.registered_devices_domains dev_reg = dr.async_get(self.hass) - if device := dev_reg.async_get_device( + # Several config entries can each own a device for the same MAC, so check every + # matching device, not just the first, or a shared-MAC integration is dropped. + for device in dev_reg.async_get_devices( connections={(CONNECTION_NETWORK_MAC, formatted_mac)} ): - for entry_id in device.config_entries: - if ( - entry := self.hass.config_entries.async_get_entry(entry_id) - ) and entry.domain in registered_devices_domains: - matched_domains.add(entry.domain) + if ( + entry := self.hass.config_entries.async_get_entry( + device.config_entry_id + ) + ) and entry.domain in registered_devices_domains: + matched_domains.add(entry.domain) oui = uppercase_mac[:6] lowercase_hostname_first_char = ( diff --git a/homeassistant/components/doorbird/const.py b/homeassistant/components/doorbird/const.py index b4b9d6f3223016..da677ea54e3f2a 100644 --- a/homeassistant/components/doorbird/const.py +++ b/homeassistant/components/doorbird/const.py @@ -3,7 +3,7 @@ from homeassistant.const import Platform DOMAIN = "doorbird" -PLATFORMS = [Platform.BUTTON, Platform.CAMERA, Platform.EVENT] +PLATFORMS = [Platform.BUTTON, Platform.CAMERA, Platform.EVENT, Platform.IMAGE] CONF_EVENTS = "events" MANUFACTURER = "Bird Home Automation Group" diff --git a/homeassistant/components/doorbird/image.py b/homeassistant/components/doorbird/image.py new file mode 100644 index 00000000000000..61cde11e09ba91 --- /dev/null +++ b/homeassistant/components/doorbird/image.py @@ -0,0 +1,130 @@ +"""Last motion and last ring image entities for a DoorBird device.""" + +# These replace the same-named camera entities, which exposed stills through the +# camera UI even though no live video is involved. The legacy camera entities are +# kept to avoid breaking existing dashboards and automations; a follow-up should +# deprecate them via a repair issue once users have had time to migrate. + +from dataclasses import dataclass +from typing import override + +import aiohttp + +from homeassistant.components.image import ( + Image, + ImageEntity, + ImageEntityDescription, + infer_image_type, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util + +from .const import DOMAIN +from .entity import DoorBirdEntity +from .models import DoorBirdConfigEntry, DoorBirdData + + +@dataclass(frozen=True, kw_only=True) +class DoorBirdImageEntityDescription(ImageEntityDescription): + """Describes a DoorBird image entity.""" + + doorbird_event_type: str + + +IMAGE_DESCRIPTIONS: tuple[DoorBirdImageEntityDescription, ...] = ( + DoorBirdImageEntityDescription( + key="last_motion", + translation_key="last_motion", + doorbird_event_type="motion", + ), + DoorBirdImageEntityDescription( + key="last_ring", + translation_key="last_ring", + doorbird_event_type="doorbell", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: DoorBirdConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the DoorBird image platform.""" + door_bird_data = config_entry.runtime_data + async_add_entities( + DoorBirdLastEventImage(hass, door_bird_data, description) + for description in IMAGE_DESCRIPTIONS + ) + + +class DoorBirdLastEventImage(ImageEntity, DoorBirdEntity): + """An image of the last motion or last ring on a DoorBird device.""" + + entity_description: DoorBirdImageEntityDescription + + def __init__( + self, + hass: HomeAssistant, + door_bird_data: DoorBirdData, + description: DoorBirdImageEntityDescription, + ) -> None: + """Initialize the image entity.""" + ImageEntity.__init__(self, hass) + DoorBirdEntity.__init__(self, door_bird_data) + self.entity_description = description + self._attr_unique_id = f"{self._mac_addr}_{description.key}" + history_type = ( + "doorbell" + if description.doorbird_event_type == "doorbell" + else "motionsensor" + ) + self._image_url = self._door_station.device.history_image_url(1, history_type) + self._matching_event_names = [ + event.event + for event in self._door_station.event_descriptions + if event.event_type == description.doorbird_event_type + ] + + @override + async def async_image(self) -> bytes | None: + """Return bytes of the last event image.""" + if self._cached_image: + return self._cached_image.content + try: + # No explicit timeout here — the image framework wraps async_image() in its + # own asyncio.timeout(IMAGE_TIMEOUT) and raises HTTP 500 on expiry. + image_bytes = await self._door_station.device.get_image(self._image_url) + except aiohttp.ClientError as error: + raise HomeAssistantError( + f"Error getting image from DoorBird: {error}" + ) from error + content_type = infer_image_type(image_bytes) + if content_type is None: + raise HomeAssistantError("DoorBird returned an unrecognized image") + self._cached_image = Image(content_type=content_type, content=image_bytes) + self._attr_content_type = content_type + return image_bytes + + @override + async def async_added_to_hass(self) -> None: + """Subscribe to the underlying DoorBird events.""" + await super().async_added_to_hass() + for event_name in self._matching_event_names: + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{event_name}", + self._async_handle_event, + ) + ) + + @callback + def _async_handle_event(self) -> None: + """Bust the cache and bump the last-updated timestamp on a new event.""" + self._cached_image = None + self._attr_image_last_updated = dt_util.utcnow() + self.async_write_ha_state() diff --git a/homeassistant/components/doorbird/strings.json b/homeassistant/components/doorbird/strings.json index 482c625a05992d..e40fb3f420ab15 100644 --- a/homeassistant/components/doorbird/strings.json +++ b/homeassistant/components/doorbird/strings.json @@ -74,6 +74,14 @@ } } } + }, + "image": { + "last_motion": { + "name": "[%key:component::doorbird::entity::camera::last_motion::name%]" + }, + "last_ring": { + "name": "[%key:component::doorbird::entity::camera::last_ring::name%]" + } } }, "issues": { diff --git a/homeassistant/components/duckdns/helpers.py b/homeassistant/components/duckdns/helpers.py index e7a76093c8aada..982c8596b625b0 100644 --- a/homeassistant/components/duckdns/helpers.py +++ b/homeassistant/components/duckdns/helpers.py @@ -12,7 +12,7 @@ async def update_duckdns( domain: str, token: str, *, - txt: str | None | UndefinedType = UNDEFINED, + txt: str | UndefinedType | None = UNDEFINED, clear: bool = False, ) -> bool: """Update DuckDNS.""" diff --git a/homeassistant/components/ecovacs/const.py b/homeassistant/components/ecovacs/const.py index f0a86d54e4aad8..cc5c8276cbcca4 100644 --- a/homeassistant/components/ecovacs/const.py +++ b/homeassistant/components/ecovacs/const.py @@ -16,15 +16,20 @@ LifeSpan.AIR_FRESHENER, LifeSpan.BLADE, LifeSpan.BRUSH, + LifeSpan.CLEANING_SOLUTION, LifeSpan.DUST_BAG, LifeSpan.FILTER, LifeSpan.HAND_FILTER, LifeSpan.LENS_BRUSH, LifeSpan.ROUND_MOP, + LifeSpan.SEWAGE_BOX, LifeSpan.SIDE_BRUSH, LifeSpan.STATION_FILTER, + LifeSpan.TRIMMER_BRUSH, LifeSpan.UNIT_CARE, LifeSpan.UV_SANITIZER, + LifeSpan.WATER_SINK, + LifeSpan.WEED_ROPE, ) SUPPORTED_STATION_ACTIONS = ( diff --git a/homeassistant/components/ecovacs/icons.json b/homeassistant/components/ecovacs/icons.json index da93d8a1beaec6..3b92ee778ffd7f 100644 --- a/homeassistant/components/ecovacs/icons.json +++ b/homeassistant/components/ecovacs/icons.json @@ -21,6 +21,9 @@ "reset_lifespan_brush": { "default": "mdi:broom" }, + "reset_lifespan_cleaning_solution": { + "default": "mdi:flask-outline" + }, "reset_lifespan_dust_bag": { "default": "mdi:delete-outline" }, @@ -36,18 +39,30 @@ "reset_lifespan_round_mop": { "default": "mdi:broom" }, + "reset_lifespan_sewage_box": { + "default": "mdi:trash-can-outline" + }, "reset_lifespan_side_brush": { "default": "mdi:broom" }, "reset_lifespan_station_filter": { "default": "mdi:air-filter" }, + "reset_lifespan_trimmer_brush": { + "default": "mdi:broom" + }, "reset_lifespan_unit_care": { "default": "mdi:robot-vacuum" }, "reset_lifespan_uv_sanitizer": { "default": "mdi:lightbulb-on-outline" }, + "reset_lifespan_water_sink": { + "default": "mdi:water-outline" + }, + "reset_lifespan_weed_rope": { + "default": "mdi:grass" + }, "station_action_clean_base": { "default": "mdi:home" }, @@ -104,6 +119,9 @@ "lifespan_brush": { "default": "mdi:broom" }, + "lifespan_cleaning_solution": { + "default": "mdi:flask-outline" + }, "lifespan_dust_bag": { "default": "mdi:delete-outline" }, @@ -119,18 +137,30 @@ "lifespan_round_mop": { "default": "mdi:broom" }, + "lifespan_sewage_box": { + "default": "mdi:trash-can-outline" + }, "lifespan_side_brush": { "default": "mdi:broom" }, "lifespan_station_filter": { "default": "mdi:air-filter" }, + "lifespan_trimmer_brush": { + "default": "mdi:broom" + }, "lifespan_unit_care": { "default": "mdi:robot-vacuum" }, "lifespan_uv_sanitizer": { "default": "mdi:lightbulb-on-outline" }, + "lifespan_water_sink": { + "default": "mdi:water-outline" + }, + "lifespan_weed_rope": { + "default": "mdi:grass" + }, "network_ip": { "default": "mdi:ip-network-outline" }, diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index f8d6066fcbdd76..5714cbebe9abae 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -55,6 +55,9 @@ "reset_lifespan_brush": { "name": "Reset main brush lifespan" }, + "reset_lifespan_cleaning_solution": { + "name": "Reset cleaning solution lifespan" + }, "reset_lifespan_dust_bag": { "name": "Reset dust bag lifespan" }, @@ -70,18 +73,30 @@ "reset_lifespan_round_mop": { "name": "Reset round mop lifespan" }, + "reset_lifespan_sewage_box": { + "name": "Reset sewage box lifespan" + }, "reset_lifespan_side_brush": { "name": "Reset side brush lifespan" }, "reset_lifespan_station_filter": { "name": "Reset station filter lifespan" }, + "reset_lifespan_trimmer_brush": { + "name": "Reset edge trimmer brush lifespan" + }, "reset_lifespan_unit_care": { "name": "Reset unit care lifespan" }, "reset_lifespan_uv_sanitizer": { "name": "Reset UV sanitizer lifespan" }, + "reset_lifespan_water_sink": { + "name": "Reset water sink lifespan" + }, + "reset_lifespan_weed_rope": { + "name": "Reset edge trimmer line lifespan" + }, "station_action_clean_base": { "name": "Clean base" }, @@ -176,6 +191,9 @@ "lifespan_brush": { "name": "Main brush lifespan" }, + "lifespan_cleaning_solution": { + "name": "Cleaning solution lifespan" + }, "lifespan_dust_bag": { "name": "Dust bag lifespan" }, @@ -194,15 +212,27 @@ "lifespan_round_mop": { "name": "Round mop lifespan" }, + "lifespan_sewage_box": { + "name": "Sewage box lifespan" + }, "lifespan_side_brush": { "name": "Side brush lifespan" }, + "lifespan_trimmer_brush": { + "name": "Edge trimmer brush lifespan" + }, "lifespan_unit_care": { "name": "Unit care lifespan" }, "lifespan_uv_sanitizer": { "name": "UV sanitizer lifespan" }, + "lifespan_water_sink": { + "name": "Water sink lifespan" + }, + "lifespan_weed_rope": { + "name": "Edge trimmer line lifespan" + }, "network_ip": { "name": "IP address" }, diff --git a/homeassistant/components/edifier_infrared/__init__.py b/homeassistant/components/edifier_infrared/__init__.py index 9c92c66d9ebf5b..9afd20c9916e1d 100644 --- a/homeassistant/components/edifier_infrared/__init__.py +++ b/homeassistant/components/edifier_infrared/__init__.py @@ -19,7 +19,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate old config entries.""" - if entry.version > 2: + if entry.version > 3: return False if entry.version == 1: @@ -38,6 +38,19 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: version=2, ) + if entry.version == 2: + data = {**entry.data} + # The R2000DB and R2730DB/RC10D1 models got their own command sets, + # split from the R1280DB one, which they were incorrectly grouped with. + command_set = MODEL_TO_COMMAND_SET[EdifierModel(data[CONF_MODEL])] + data[CONF_COMMAND_SET] = command_set.value + hass.config_entries.async_update_entry( + entry, + data=data, + unique_id=f"{command_set.value}_{data[CONF_INFRARED_ENTITY_ID]}", + version=3, + ) + return True diff --git a/homeassistant/components/edifier_infrared/button.py b/homeassistant/components/edifier_infrared/button.py index 029487f6c393f8..d653afecb10d19 100644 --- a/homeassistant/components/edifier_infrared/button.py +++ b/homeassistant/components/edifier_infrared/button.py @@ -8,6 +8,8 @@ from infrared_protocols.codes.edifier.r1700bt_2017 import EdifierR1700BT2017Code from infrared_protocols.codes.edifier.r1700bt_pre_2017 import EdifierR1700BTPre2017Code from infrared_protocols.codes.edifier.r1700bts import EdifierR1700BTsCode +from infrared_protocols.codes.edifier.r2000db import EdifierR2000DBCode +from infrared_protocols.codes.edifier.r2730db import EdifierR2730DBCode from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode from infrared_protocols.codes.edifier.s3000pro import EdifierS3000ProCode @@ -114,6 +116,65 @@ class EdifierIrButtonEntityDescription(ButtonEntityDescription): command_code=EdifierR1280DBCode.COAX, ), ), + EdifierCommandSet.R2000DB: ( + EdifierIrButtonEntityDescription( + key="bluetooth", + translation_key="bluetooth", + command_code=EdifierR2000DBCode.BLUETOOTH, + ), + EdifierIrButtonEntityDescription( + key="line_1", + translation_key="line_1", + command_code=EdifierR2000DBCode.LINE_1, + ), + EdifierIrButtonEntityDescription( + key="line_2", + translation_key="line_2", + command_code=EdifierR2000DBCode.LINE_2, + ), + EdifierIrButtonEntityDescription( + key="optical", + translation_key="optical", + command_code=EdifierR2000DBCode.OPTICAL, + ), + EdifierIrButtonEntityDescription( + key="eq_classic", + translation_key="eq_classic", + command_code=EdifierR2000DBCode.EQ_CLASSIC, + ), + EdifierIrButtonEntityDescription( + key="eq_dynamic", + translation_key="eq_dynamic", + command_code=EdifierR2000DBCode.EQ_DYNAMIC, + ), + ), + EdifierCommandSet.R2730DB: ( + EdifierIrButtonEntityDescription( + key="bluetooth", + translation_key="bluetooth", + command_code=EdifierR2730DBCode.BLUETOOTH, + ), + EdifierIrButtonEntityDescription( + key="line_1", + translation_key="line_1", + command_code=EdifierR2730DBCode.LINE_1, + ), + EdifierIrButtonEntityDescription( + key="line_2", + translation_key="line_2", + command_code=EdifierR2730DBCode.LINE_2, + ), + EdifierIrButtonEntityDescription( + key="optical", + translation_key="optical", + command_code=EdifierR2730DBCode.OPTICAL, + ), + EdifierIrButtonEntityDescription( + key="coax", + translation_key="coax", + command_code=EdifierR2730DBCode.COAX, + ), + ), EdifierCommandSet.S360DB: ( EdifierIrButtonEntityDescription( key="bluetooth", diff --git a/homeassistant/components/edifier_infrared/config_flow.py b/homeassistant/components/edifier_infrared/config_flow.py index 76fde9b7c5f4a9..805ec1e8dc60fb 100644 --- a/homeassistant/components/edifier_infrared/config_flow.py +++ b/homeassistant/components/edifier_infrared/config_flow.py @@ -25,7 +25,7 @@ class EdifierIrConfigFlow(ConfigFlow, domain=DOMAIN): """Handle config flow for Edifier IR.""" - VERSION = 2 + VERSION = 3 MINOR_VERSION = 1 @override diff --git a/homeassistant/components/edifier_infrared/const.py b/homeassistant/components/edifier_infrared/const.py index 9575ad58fdd992..e05d126a46162d 100644 --- a/homeassistant/components/edifier_infrared/const.py +++ b/homeassistant/components/edifier_infrared/const.py @@ -5,6 +5,8 @@ from infrared_protocols.codes.edifier.r1700bt_2017 import EdifierR1700BT2017Code from infrared_protocols.codes.edifier.r1700bt_pre_2017 import EdifierR1700BTPre2017Code from infrared_protocols.codes.edifier.r1700bts import EdifierR1700BTsCode +from infrared_protocols.codes.edifier.r2000db import EdifierR2000DBCode +from infrared_protocols.codes.edifier.r2730db import EdifierR2730DBCode from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode from infrared_protocols.codes.edifier.s3000pro import EdifierS3000ProCode @@ -19,6 +21,8 @@ | EdifierR1700BTsCode | EdifierR1280DBCode | EdifierR1280TCode + | EdifierR2000DBCode + | EdifierR2730DBCode | EdifierS360DBCode | EdifierRC20GCode | EdifierS3000ProCode diff --git a/homeassistant/components/edifier_infrared/media_player.py b/homeassistant/components/edifier_infrared/media_player.py index b591d9bb7b5d45..d5bf89731a1d53 100644 --- a/homeassistant/components/edifier_infrared/media_player.py +++ b/homeassistant/components/edifier_infrared/media_player.py @@ -8,6 +8,8 @@ from infrared_protocols.codes.edifier.r1700bt_2017 import EdifierR1700BT2017Code from infrared_protocols.codes.edifier.r1700bt_pre_2017 import EdifierR1700BTPre2017Code from infrared_protocols.codes.edifier.r1700bts import EdifierR1700BTsCode +from infrared_protocols.codes.edifier.r2000db import EdifierR2000DBCode +from infrared_protocols.codes.edifier.r2730db import EdifierR2730DBCode from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode from infrared_protocols.codes.edifier.s3000pro import EdifierS3000ProCode @@ -86,6 +88,24 @@ ), MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1280TCode.MUTE,), }, + EdifierCommandSet.R2000DB: { + MediaPlayerEntityFeature.TURN_ON: (EdifierR2000DBCode.POWER,), + MediaPlayerEntityFeature.TURN_OFF: (EdifierR2000DBCode.POWER,), + MediaPlayerEntityFeature.VOLUME_STEP: ( + (EdifierR2000DBCode.VOLUME_UP,), + (EdifierR2000DBCode.VOLUME_DOWN,), + ), + MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR2000DBCode.MUTE,), + }, + EdifierCommandSet.R2730DB: { + MediaPlayerEntityFeature.TURN_ON: (EdifierR2730DBCode.POWER,), + MediaPlayerEntityFeature.TURN_OFF: (EdifierR2730DBCode.POWER,), + MediaPlayerEntityFeature.VOLUME_STEP: ( + (EdifierR2730DBCode.VOLUME_UP,), + (EdifierR2730DBCode.VOLUME_DOWN,), + ), + MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR2730DBCode.MUTE,), + }, EdifierCommandSet.S360DB: { MediaPlayerEntityFeature.TURN_ON: (EdifierS360DBCode.POWER,), MediaPlayerEntityFeature.TURN_OFF: (EdifierS360DBCode.POWER,), diff --git a/homeassistant/components/emonitor/config_flow.py b/homeassistant/components/emonitor/config_flow.py index 24b8a9087bda72..c5057531252e29 100644 --- a/homeassistant/components/emonitor/config_flow.py +++ b/homeassistant/components/emonitor/config_flow.py @@ -83,7 +83,7 @@ async def async_step_dhcp( self.discovered_info = await fetch_mac_and_title( self.hass, self.discovered_ip ) - except Exception as ex: # noqa: BLE001 + except Exception as ex: _LOGGER.debug( "Unable to fetch status, falling back to manual entry", exc_info=ex ) diff --git a/homeassistant/components/energieleser/manifest.json b/homeassistant/components/energieleser/manifest.json index 160cffb2c9d31f..72b5f99f0cd957 100644 --- a/homeassistant/components/energieleser/manifest.json +++ b/homeassistant/components/energieleser/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/energieleser", "integration_type": "device", "iot_class": "local_polling", - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["energieleser==0.1.5"], "zeroconf": [ { diff --git a/homeassistant/components/energieleser/quality_scale.yaml b/homeassistant/components/energieleser/quality_scale.yaml index ea4d7d13f73142..f20cdd9094a7eb 100644 --- a/homeassistant/components/energieleser/quality_scale.yaml +++ b/homeassistant/components/energieleser/quality_scale.yaml @@ -52,13 +52,13 @@ rules: diagnostics: done discovery-update-info: done discovery: done - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: exempt comment: Each device is a separate config entry; the integration does not add devices on the fly to an existing entry. @@ -67,7 +67,9 @@ rules: entity-disabled-by-default: done entity-translations: done exception-translations: done - icon-translations: todo + icon-translations: + status: exempt + comment: Entities rely on device-class and default icons; no custom icon translations are needed. reconfiguration-flow: done repair-issues: done stale-devices: @@ -77,4 +79,4 @@ rules: # Platinum async-dependency: done inject-websession: done - strict-typing: todo + strict-typing: done diff --git a/homeassistant/components/eurotronic_cometblue/__init__.py b/homeassistant/components/eurotronic_cometblue/__init__.py index 48a5ce8f770423..f52ffdc12c57cf 100644 --- a/homeassistant/components/eurotronic_cometblue/__init__.py +++ b/homeassistant/components/eurotronic_cometblue/__init__.py @@ -3,7 +3,11 @@ from bleak.exc import BleakError from eurotronic_cometblue_ha import AsyncCometBlue -from homeassistant.components.bluetooth import async_ble_device_from_address +from homeassistant.components.bluetooth import ( + BluetoothReachabilityIntent, + async_address_reachability_diagnostics, + async_ble_device_from_address, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_PIN, Platform from homeassistant.core import HomeAssistant @@ -33,7 +37,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: CometBlueConfigEntry) -> if not ble_device: raise ConfigEntryNotReady( - f"Couldn't find a nearby device for address: {entry.data[CONF_ADDRESS]}" + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={ + "address": address, + "reason": async_address_reachability_diagnostics( + hass, + address.upper(), + BluetoothReachabilityIntent.CONNECTION, + ), + }, ) cometblue_device = AsyncCometBlue( diff --git a/homeassistant/components/eurotronic_cometblue/config_flow.py b/homeassistant/components/eurotronic_cometblue/config_flow.py index f304053b5cb002..658c661ddbc1b9 100644 --- a/homeassistant/components/eurotronic_cometblue/config_flow.py +++ b/homeassistant/components/eurotronic_cometblue/config_flow.py @@ -87,7 +87,7 @@ async def _try_connect(self, user_input: dict[str, Any]) -> dict[str, str]: except BleakError: LOGGER.debug("Failed to connect to device", exc_info=True) return {"base": "cannot_connect"} - except Exception: # noqa: BLE001 + except Exception: LOGGER.debug("Unknown error", exc_info=True) return {"base": "unknown"} return {} diff --git a/homeassistant/components/eurotronic_cometblue/strings.json b/homeassistant/components/eurotronic_cometblue/strings.json index add0b79e373fde..f7ddc18c29f92a 100644 --- a/homeassistant/components/eurotronic_cometblue/strings.json +++ b/homeassistant/components/eurotronic_cometblue/strings.json @@ -48,6 +48,11 @@ } } }, + "exceptions": { + "device_not_found": { + "message": "Could not find Comet Blue device with address {address}: {reason}" + } + }, "services": { "get_schedule": { "description": "Retrieves the configured heating time ranges of one or multiple devices.", diff --git a/homeassistant/components/fish_audio/config_flow.py b/homeassistant/components/fish_audio/config_flow.py index c1b3edb4291b12..f104857615374d 100644 --- a/homeassistant/components/fish_audio/config_flow.py +++ b/homeassistant/components/fish_audio/config_flow.py @@ -21,6 +21,9 @@ from homeassistant.helpers.selector import ( LanguageSelector, LanguageSelectorConfig, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, SelectOptionDict, SelectSelector, SelectSelectorConfig, @@ -34,13 +37,18 @@ CONF_LATENCY, CONF_SELF_ONLY, CONF_SORT_BY, + CONF_SPEED, CONF_TITLE, CONF_USER_ID, CONF_VOICE_ID, + DEFAULT_SPEED, DOMAIN, LATENCY_OPTIONS, + MAX_SPEED, + MIN_SPEED, SIGNUP_URL, SORT_BY_OPTIONS, + SPEED_STEP, TTS_SUPPORTED_LANGUAGES, ) from .error import ( @@ -128,6 +136,17 @@ def get_model_selection_schema( mode=SelectSelectorMode.DROPDOWN, ) ), + vol.Optional( + CONF_SPEED, + default=options.get(CONF_SPEED, DEFAULT_SPEED), + ): NumberSelector( + NumberSelectorConfig( + min=MIN_SPEED, + max=MAX_SPEED, + step=SPEED_STEP, + mode=NumberSelectorMode.SLIDER, + ) + ), # Name field is no longer allowed in config flow schemas # pylint: disable-next=home-assistant-config-flow-name-field vol.Required( diff --git a/homeassistant/components/fish_audio/const.py b/homeassistant/components/fish_audio/const.py index d61a018634e3be..8bfa947347584b 100644 --- a/homeassistant/components/fish_audio/const.py +++ b/homeassistant/components/fish_audio/const.py @@ -10,6 +10,7 @@ CONF_SELF_ONLY: Literal["self_only"] = "self_only" CONF_SORT_BY: Literal["sort_by"] = "sort_by" CONF_LATENCY: Literal["latency"] = "latency" +CONF_SPEED: Literal["speed"] = "speed" CONF_TITLE: Literal["title"] = "title" DEVELOPER_ID = "1e9f9baadce144f5b16dd94cbc0314c8" @@ -31,6 +32,12 @@ SORT_BY_OPTIONS = ["task_count", "score", "created_at"] LATENCY_OPTIONS = ["normal", "balanced"] +# Speech speed multiplier accepted by the Fish Audio API (1.0 = normal speed). +DEFAULT_SPEED = 1.0 +MIN_SPEED = 0.5 +MAX_SPEED = 2.0 +SPEED_STEP = 0.05 + SIGNUP_URL = "https://fish.audio/" BILLING_URL = "https://fish.audio/app/billing/" API_KEYS_URL = "https://fish.audio/app/api-keys/" diff --git a/homeassistant/components/fish_audio/strings.json b/homeassistant/components/fish_audio/strings.json index 060aadec179119..2cc5450c081d4c 100644 --- a/homeassistant/components/fish_audio/strings.json +++ b/homeassistant/components/fish_audio/strings.json @@ -65,12 +65,14 @@ "backend": "AI voice model", "latency": "Latency mode", "name": "[%key:common::config_flow::data::name%]", + "speed": "Speech speed", "voice_id": "Voice" }, "data_description": { "backend": "Select the AI model that will generate the audio.", "latency": "Choose the latency mode: 'normal' for standard processing or 'balanced' for optimized speed.", "name": "Enter a unique name for this TTS voice to easily identify it in Home Assistant.", + "speed": "Speed of the generated speech. 1.0 is the normal speed; lower values speak slower and higher values speak faster.", "voice_id": "Choose from the list of available voices, or manually enter a specific voice ID." }, "description": "Select your preferred voice and the AI model to use for speech synthesis.", diff --git a/homeassistant/components/fish_audio/tts.py b/homeassistant/components/fish_audio/tts.py index f15e096d084f02..19427ebd854ad3 100644 --- a/homeassistant/components/fish_audio/tts.py +++ b/homeassistant/components/fish_audio/tts.py @@ -16,8 +16,12 @@ from .const import ( CONF_BACKEND, CONF_LATENCY, + CONF_SPEED, CONF_VOICE_ID, + DEFAULT_SPEED, DOMAIN, + MAX_SPEED, + MIN_SPEED, TTS_SUPPORTED_LANGUAGES, ) from .error import UnexpectedError @@ -50,13 +54,17 @@ class FishAudioTTSEntity(TextToSpeechEntity): """Fish Audio TTS entity.""" _attr_has_entity_name = True - _attr_supported_options = [CONF_VOICE_ID, CONF_BACKEND, CONF_LATENCY] + _attr_supported_options = [CONF_VOICE_ID, CONF_BACKEND, CONF_LATENCY, CONF_SPEED] def __init__(self, entry: FishAudioConfigEntry, sub_entry: ConfigSubentry) -> None: """Initialize the TTS entity.""" self.client = entry.runtime_data self.sub_entry = sub_entry self._attr_unique_id = sub_entry.subentry_id + # Configured speed must be a default option so it is part of the TTS cache key. + self._attr_default_options = { + CONF_SPEED: sub_entry.data.get(CONF_SPEED, DEFAULT_SPEED) + } title = sub_entry.title backend = sub_entry.data[CONF_BACKEND] self._attr_name = title @@ -97,17 +105,25 @@ async def async_get_tts_audio( latency = options.get( CONF_LATENCY, self.sub_entry.data.get(CONF_LATENCY, "balanced") ) + speed = options.get( + CONF_SPEED, self.sub_entry.data.get(CONF_SPEED, DEFAULT_SPEED) + ) if voice_id is None: raise ServiceValidationError("Voice ID not configured") if backend is None: raise ServiceValidationError("Backend model not configured") + if not MIN_SPEED <= speed <= MAX_SPEED: + raise ServiceValidationError( + f"Speed must be between {MIN_SPEED} and {MAX_SPEED}" + ) try: audio = await self.client.tts.convert( text=message, reference_id=voice_id, latency=latency, + speed=speed, model=backend, format="mp3", ) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 19724c28504394..ce10da6d22d96c 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260624.6"] + "requirements": ["home-assistant-frontend==20260729.0"] } diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 749e090c7f689f..3c736aa03f1d79 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -173,7 +173,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) try: await server.start() - except Exception: # noqa: BLE001 + except Exception: _LOGGER.warning("Could not start go2rtc server", exc_info=True) await session.close() return False @@ -423,12 +423,55 @@ async def _update_stream_source(self, camera: Camera) -> None: ], ) + async def _update_preload_stream(self, camera: Camera) -> None: + identifier = get_camera_identifier(camera) + camera_prefs = await get_dynamic_camera_stream_settings( + self._hass, camera.entity_id + ) + preload_streams = await self._rest_client.preload.list() + + if camera_prefs.preload_stream == (identifier in preload_streams): + return + + if camera_prefs.preload_stream: + # We need to first add the stream source otherwise preload enabling will fail + await self._update_stream_source(camera) + await self._rest_client.preload.enable(identifier) + else: + await self._rest_client.preload.disable(identifier) + async def teardown(self) -> None: """Tear down the provider.""" for ws_client in self._sessions.values(): await ws_client.close() self._sessions.clear() + @override + async def async_register_camera( + self, + camera: Camera, + ) -> None: + """Will be called when the provider is registered for a camera.""" + await self._update_preload_stream(camera) + + @override + async def async_unregister_camera( + self, + camera: Camera, + ) -> None: + """Will be called when the provider is unregistered for a camera.""" + identifier = get_camera_identifier(camera) + if identifier in await self._rest_client.preload.list(): + await self._rest_client.preload.disable(identifier) + + @override + async def async_on_camera_prefs_update( + self, + camera: Camera, + ) -> None: + """Will be called when the camera preferences are updated.""" + await self._update_preload_stream(camera) + @dataclass class Go2RtcConfig: diff --git a/homeassistant/components/go2rtc/server.py b/homeassistant/components/go2rtc/server.py index 08af88f0929c94..e386d0677b231f 100644 --- a/homeassistant/components/go2rtc/server.py +++ b/homeassistant/components/go2rtc/server.py @@ -74,6 +74,7 @@ "/", # UI static page and version control "/api", # Main API path "/api/frame.jpeg", # Snapshot functionality + "/api/preload", # Preload functionality "/api/schemes", # Supported stream schemes "/api/streams", # Stream management "/api/webrtc", # Webrtc functionality diff --git a/homeassistant/components/harbor/const.py b/homeassistant/components/harbor/const.py index f9b5670e332bd6..a68a80de1a8733 100644 --- a/homeassistant/components/harbor/const.py +++ b/homeassistant/components/harbor/const.py @@ -6,7 +6,7 @@ MANUFACTURER = "Harbor" MODEL = "Harbor Camera" -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH] CONF_CERT_PEM = "cert_pem" CONF_KEY_PEM = "key_pem" diff --git a/homeassistant/components/harbor/coordinator.py b/homeassistant/components/harbor/coordinator.py index 55afb751b66359..f1f3b7fcc481e5 100644 --- a/homeassistant/components/harbor/coordinator.py +++ b/homeassistant/components/harbor/coordinator.py @@ -2,7 +2,7 @@ import asyncio import logging -from typing import Any, override +from typing import TYPE_CHECKING, Any, override from uuid import uuid4 from harbor.config import HarborCameraConfig @@ -148,6 +148,13 @@ async def async_shutdown(self) -> None: self._unsubscribe_updates() self.device.shutdown() + @property + def _client(self) -> HarborMQTTClient: + """Return the active MQTT client.""" + if TYPE_CHECKING: + assert self._mqtt_client is not None + return self._mqtt_client + @property def device_info(self) -> DeviceInfo: """Return device info for the Harbor camera.""" @@ -161,6 +168,18 @@ def device_info(self) -> DeviceInfo: sw_version=state.os_version, ) + async def async_set_camera_on(self, camera_on: bool) -> None: + """Turn the camera stream on or off.""" + await self._client.set_camera_on(camera_on) + + async def async_set_video_flip(self, video_flip: bool) -> None: + """Rotate the camera image 180 degrees, or restore it upright.""" + await self._client.set_video_flip(video_flip) + + async def async_set_clock_display(self, clock_display: bool) -> None: + """Show or hide the clock overlay burned into the video.""" + await self._client.set_clock_display(clock_display) + def _handle_device_update(self, state: HarborDeviceState) -> None: """Mirror a library device update into Home Assistant.""" self._data_event.set() diff --git a/homeassistant/components/harbor/icons.json b/homeassistant/components/harbor/icons.json index 50c18c3b3a7e5f..d24c591ef1b7ee 100644 --- a/homeassistant/components/harbor/icons.json +++ b/homeassistant/components/harbor/icons.json @@ -10,6 +10,17 @@ "wifi_strength": { "default": "mdi:wifi" } + }, + "switch": { + "camera_on": { + "default": "mdi:cctv" + }, + "clock_display": { + "default": "mdi:clock-outline" + }, + "video_flip": { + "default": "mdi:flip-vertical" + } } } } diff --git a/homeassistant/components/harbor/strings.json b/homeassistant/components/harbor/strings.json index 1d4c0bae7c108d..82915e832b1379 100644 --- a/homeassistant/components/harbor/strings.json +++ b/homeassistant/components/harbor/strings.json @@ -49,11 +49,28 @@ "name": "Wi-Fi strength", "unit_of_measurement": "bars" } + }, + "switch": { + "camera_on": { + "name": "Camera" + }, + "clock_display": { + "name": "Clock overlay" + }, + "video_flip": { + "name": "Flip image" + } } }, "exceptions": { "cannot_connect": { "message": "Could not connect to the Harbor camera. It may be offline or unreachable." + }, + "switch_turn_off_failed": { + "message": "Failed to turn off {switch}." + }, + "switch_turn_on_failed": { + "message": "Failed to turn on {switch}." } } } diff --git a/homeassistant/components/harbor/switch.py b/homeassistant/components/harbor/switch.py new file mode 100644 index 00000000000000..08950807cd0c89 --- /dev/null +++ b/homeassistant/components/harbor/switch.py @@ -0,0 +1,111 @@ +"""Switch entities for Harbor.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any, override + +from harbor import HarborCommandError + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import HarborConfigEntry, HarborCoordinator +from .entity import HarborEntity + +# Commands are sent over a single MQTT session to one camera, and each settings +# write is followed by a settings refresh, so they are serialized. +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class HarborSwitchEntityDescription(SwitchEntityDescription): + """Describes a Harbor switch entity.""" + + turn_on_fn: Callable[[HarborCoordinator], Coroutine[Any, Any, None]] + turn_off_fn: Callable[[HarborCoordinator], Coroutine[Any, Any, None]] + + +CAMERA_SWITCHES: tuple[HarborSwitchEntityDescription, ...] = ( + HarborSwitchEntityDescription( + key="camera_on", + translation_key="camera_on", + turn_on_fn=lambda coordinator: coordinator.async_set_camera_on(True), + turn_off_fn=lambda coordinator: coordinator.async_set_camera_on(False), + ), + HarborSwitchEntityDescription( + key="video_flip", + translation_key="video_flip", + entity_category=EntityCategory.CONFIG, + turn_on_fn=lambda coordinator: coordinator.async_set_video_flip(True), + turn_off_fn=lambda coordinator: coordinator.async_set_video_flip(False), + ), + HarborSwitchEntityDescription( + key="clock_display", + translation_key="clock_display", + entity_category=EntityCategory.CONFIG, + turn_on_fn=lambda coordinator: coordinator.async_set_clock_display(True), + turn_off_fn=lambda coordinator: coordinator.async_set_clock_display(False), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HarborConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Harbor switches from a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + HarborSwitch(coordinator, description) for description in CAMERA_SWITCHES + ) + + +class HarborSwitch(HarborEntity, SwitchEntity): + """A Harbor switch entity.""" + + entity_description: HarborSwitchEntityDescription + + def __init__( + self, + coordinator: HarborCoordinator, + description: HarborSwitchEntityDescription, + ) -> None: + """Initialize the Harbor switch.""" + self.entity_description = description + super().__init__(coordinator, description.key) + + @override + @property + def is_on(self) -> bool | None: + """Return true if the switch is on.""" + return self.coordinator.data.values.get(self.entity_description.key) + + @override + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self._async_call(self.entity_description.turn_on_fn, "turn_on") + + @override + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self._async_call(self.entity_description.turn_off_fn, "turn_off") + + async def _async_call( + self, + action: Callable[[HarborCoordinator], Coroutine[Any, Any, None]], + translation_key: str, + ) -> None: + """Run a switch command and translate library errors.""" + try: + await action(self.coordinator) + except (HarborCommandError, TimeoutError, ConnectionError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=f"switch_{translation_key}_failed", + translation_placeholders={"switch": self.entity_description.key}, + ) from err diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 509f2b72e1138d..8faaaeddb8c8f0 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -269,7 +269,7 @@ def update_native_value(self) -> None: class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): """Power switch class for Home Connect.""" - power_off_state: str | None | UndefinedType = UNDEFINED + power_off_state: str | UndefinedType | None = UNDEFINED @override async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/homeassistant_hardware/update.py b/homeassistant/components/homeassistant_hardware/update.py index ae10b7d2f2cc66..c226e1724a2e2d 100644 --- a/homeassistant/components/homeassistant_hardware/update.py +++ b/homeassistant/components/homeassistant_hardware/update.py @@ -198,7 +198,7 @@ def _firmware_info_callback(self, firmware_info: FirmwareInfo) -> None: self.entity_description.expected_firmware_type, self._current_firmware_info.firmware_type, ) - except Exception: # noqa: BLE001 + except Exception: _LOGGER.warning( "Failed to call firmware type changed callback", exc_info=True ) diff --git a/homeassistant/components/homeassistant_hardware/util.py b/homeassistant/components/homeassistant_hardware/util.py index 947851817cde5b..723c5654ccf00c 100644 --- a/homeassistant/components/homeassistant_hardware/util.py +++ b/homeassistant/components/homeassistant_hardware/util.py @@ -404,7 +404,7 @@ async def probe_silabs_firmware_info( else None ) ) - except Exception: # noqa: BLE001 + except Exception: _LOGGER.debug("Failed to probe application type", exc_info=True) if flasher.app_type is None: @@ -438,7 +438,7 @@ async def probe_silabs_firmware_type( try: await flasher.probe_app_type() - except Exception: # noqa: BLE001 + except Exception: _LOGGER.debug("Failed to probe application type", exc_info=True) if flasher.app_type is None: diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index ca05e7eb85e673..af77641b0b9733 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -177,15 +177,12 @@ async def async_step_user( def _hkid_is_homekit(self, hkid: str) -> bool: """Determine if the device is a homekit bridge or accessory.""" dev_reg = dr.async_get(self.hass) - device = dev_reg.async_get_device( + # Several config entries can each own a device for the same MAC, so check every + # matching device, not just the first. + for device in dev_reg.async_get_devices( connections={(dr.CONNECTION_NETWORK_MAC, hkid)} - ) - - if device is None: - return False - - for entry_id in device.config_entries: - entry = self.hass.config_entries.async_get_entry(entry_id) + ): + entry = self.hass.config_entries.async_get_entry(device.config_entry_id) if entry and entry.domain == HOMEKIT_BRIDGE_DOMAIN: return True diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 5cf378ba7e781f..2ed162759e6d77 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -484,19 +484,29 @@ def async_migrate_devices(self) -> None: (DOMAIN, IDENTIFIER_LEGACY_SERIAL_NUMBER, accessory.serial_number) ) - device = device_registry.async_get_device(identifiers=identifiers) # type: ignore[arg-type] + # Resolve to this config entry's own device. Several config entries can share + # the legacy identifier, in which case async_get_device returns a read-only + # composite spanning them and async_update_device would silently drop the + # identifier rename; scope the lookup to this entry instead. + candidates = device_registry.async_get_devices(identifiers=identifiers) # type: ignore[arg-type] + device = next( + ( + candidate + for candidate in candidates + if candidate.config_entry_id == self.config_entry.entry_id + ), + None, + ) if not device: - continue - - if self.config_entry.entry_id not in device.config_entries: - _LOGGER.warning( - ( - "Found candidate device for %s:aid:%s, but owned by a different" - " config entry, skipping" - ), - self.unique_id, - accessory.aid, - ) + if candidates: + _LOGGER.warning( + ( + "Found candidate device for %s:aid:%s, but owned by a" + " different config entry, skipping" + ), + self.unique_id, + accessory.aid, + ) continue _LOGGER.debug( @@ -575,22 +585,22 @@ def async_remove_legacy_device_serial_numbers(self) -> None: device_registry = dr.async_get(self.hass) for accessory in self.entity_map.accessories: - identifiers = { - ( - IDENTIFIER_ACCESSORY_ID, - f"{self.unique_id}:aid:{accessory.aid}", - ) - } + identifier = ( + IDENTIFIER_ACCESSORY_ID, + f"{self.unique_id}:aid:{accessory.aid}", + ) legacy_serial_identifier = ( IDENTIFIER_SERIAL_NUMBER, accessory.serial_number, ) - device = device_registry.async_get_device(identifiers=identifiers) + device = device_registry.async_get_device_by_identifier( + identifier, self.config_entry.entry_id + ) if not device or legacy_serial_identifier not in device.identifiers: continue - device_registry.async_update_device(device.id, new_identifiers=identifiers) + device_registry.async_update_device(device.id, new_identifiers={identifier}) @callback def async_reap_stale_entity_registry_entries(self) -> None: diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 06f81c1460e3c7..5f1fc69baa6e44 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -17,7 +17,6 @@ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, issue_registry as ir -from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.http import ( # noqa: F401 KEY_ALLOW_CONFIGURED_CORS, KEY_AUTHENTICATED, @@ -185,18 +184,6 @@ async def stop_server(event: Event) -> None: # or the recovery boot cannot bind the same address again. hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_server) - if CONF_SERVER_HOST in conf and is_hassio(hass): - issue_id = "server_host_deprecated_hassio" - ir.async_create_issue( - hass, - DOMAIN, - issue_id, - breaks_in_ha_version="2026.6.0", - is_fixable=False, - severity=ir.IssueSeverity.ERROR, - translation_key=issue_id, - ) - server_host = conf.get(CONF_SERVER_HOST, DEFAULT_BIND) server_port = conf[CONF_SERVER_PORT] ssl_certificate = conf.get(CONF_SSL_CERTIFICATE) diff --git a/homeassistant/components/http/strings.json b/homeassistant/components/http/strings.json index 2ef22ae18052fb..1952d64a309c62 100644 --- a/homeassistant/components/http/strings.json +++ b/homeassistant/components/http/strings.json @@ -8,10 +8,6 @@ "description": "Migrating the `http` configuration from `configuration.yaml` to the integration's storage failed. Please check the logs for details and configure the `http` integration from the UI under **Settings** > **System** > **Network**.", "title": "Failed to import HTTP YAML configuration" }, - "server_host_deprecated_hassio": { - "description": "The deprecated `server_host` configuration option in the HTTP integration is prone to break the communication between Home Assistant Core and Supervisor, and will be removed.\n\nIf you are using this option to bind Home Assistant to specific network interfaces, please remove it from your configuration. Home Assistant will automatically bind to all available interfaces by default.\n\nIf you have specific networking requirements, consider using firewall rules or other network configuration to control access to Home Assistant.", - "title": "The `server_host` HTTP configuration may break Home Assistant Core - Supervisor communication" - }, "ssl_configured_without_configured_urls": { "description": "Home Assistant detected that SSL has been set up on your instance, however, no custom external internet URL has been set.\n\nThis may result in unexpected behavior. Text-to-speech may fail, and integrations may not be able to connect back to your instance correctly.\n\nTo address this issue, go to Settings > System > Network; under the \"Home Assistant URL\" section, configure your new \"Internet\" and \"Local network\" addresses that match your new SSL configuration.", "title": "SSL is configured without an external URL or internal URL" diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 6b843bbcb39069..aee3966e9ed10b 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -264,7 +264,7 @@ def logout(self) -> None: ResponseErrorNotSupportedException, ): pass # Ok, normal, nothing to do - except Exception: # noqa: BLE001 + except Exception: _LOGGER.warning("Logout error", exc_info=True) def cleanup(self, *_: Any) -> None: diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index 47058fddbff245..2916848e05bf56 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -222,18 +222,18 @@ def get_device_info( client = Client(conn) try: device_info = client.device.information() - except Exception: # noqa: BLE001 + except Exception: _LOGGER.debug("Could not get device.information", exc_info=True) try: device_info = client.device.basic_information() - except Exception: # noqa: BLE001 + except Exception: _LOGGER.debug( "Could not get device.basic_information", exc_info=True ) device_info = {} try: wlan_settings = client.wlan.multi_basic_settings() - except Exception: # noqa: BLE001 + except Exception: _LOGGER.debug("Could not get wlan.multi_basic_settings", exc_info=True) wlan_settings = {} return device_info, wlan_settings diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index 5ccb2338ee2b52..6b4a4e8af1105d 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -203,7 +203,7 @@ class ImageEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): # Entity Properties _attr_content_type: str = DEFAULT_CONTENT_TYPE _attr_image_last_updated: datetime | None = None - _attr_image_url: str | None | UndefinedType = UNDEFINED + _attr_image_url: str | UndefinedType | None = UNDEFINED _attr_should_poll: bool = False # No need to poll image entities _attr_state: None = None # State is determined by last_updated _cached_image: Image | None = None @@ -233,7 +233,7 @@ def image_last_updated(self) -> datetime | None: return self._attr_image_last_updated @cached_property - def image_url(self) -> str | None | UndefinedType: + def image_url(self) -> str | UndefinedType | None: """Return URL of image.""" return self._attr_image_url diff --git a/homeassistant/components/indevolt/services.py b/homeassistant/components/indevolt/services.py index bbfc543353bb4d..3d3f09df00e5d4 100644 --- a/homeassistant/components/indevolt/services.py +++ b/homeassistant/components/indevolt/services.py @@ -146,7 +146,7 @@ async def _execute_realtime_action( target_soc: int, ) -> None: """Execute async_execute_realtime_action on all coordinators concurrently.""" - results: list[None | BaseException] = await asyncio.gather( + results: list[BaseException | None] = await asyncio.gather( *( coordinator.async_realtime_action(action, power, target_soc) for coordinator in coordinators diff --git a/homeassistant/components/iometer/__init__.py b/homeassistant/components/iometer/__init__.py index 98e3d84def4568..046cc7cbd423e1 100644 --- a/homeassistant/components/iometer/__init__.py +++ b/homeassistant/components/iometer/__init__.py @@ -1,11 +1,10 @@ """The IOmeter integration.""" -from iometer import IOmeterClient, IOmeterConnectionError +from iometer import IOmeterSSEClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .coordinator import IOmeterConfigEntry, IOMeterCoordinator @@ -15,19 +14,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: IOmeterConfigEntry) -> bool: """Set up IOmeter from a config entry.""" - host = entry.data[CONF_HOST] session = async_get_clientsession(hass) - client = IOmeterClient(host=host, session=session) - try: - await client.get_current_status() - except IOmeterConnectionError as err: - raise ConfigEntryNotReady from err + client = IOmeterSSEClient(host=host, session=session) coordinator = IOMeterCoordinator(hass, entry, client) - await coordinator.async_config_entry_first_refresh() + await coordinator.async_start() + try: + await coordinator.async_config_entry_first_refresh() + except Exception: + await coordinator.async_stop() + raise + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(coordinator.async_stop) return True diff --git a/homeassistant/components/iometer/config_flow.py b/homeassistant/components/iometer/config_flow.py index a551a7891223a6..86f65c053d86c9 100644 --- a/homeassistant/components/iometer/config_flow.py +++ b/homeassistant/components/iometer/config_flow.py @@ -5,8 +5,8 @@ from iometer import ( IOmeterClient, IOmeterConnectionError, - IOmeterNoReadingsError, IOmeterNoStatusError, + IOmeterTimeoutError, ) import voluptuous as vol @@ -40,19 +40,17 @@ async def async_step_zeroconf( client = IOmeterClient(host=host, session=session) try: status = await client.get_current_status() - _ = await client.get_current_reading() except IOmeterNoStatusError: return self.async_abort(reason="no_status") - except IOmeterNoReadingsError: - return self.async_abort(reason="no_readings") - except IOmeterConnectionError: + except IOmeterTimeoutError, IOmeterConnectionError: return self.async_abort(reason="cannot_connect") - self._meter_number = status.meter.number + if not status.meter: + return self.async_abort(reason="no_readings") + self._meter_number = status.meter.number await self.async_set_unique_id(status.device.id) self._abort_if_unique_id_configured() - self.context["title_placeholders"] = {"name": f"IOmeter {self._meter_number}"} return await self.async_step_zeroconf_confirm() @@ -82,18 +80,19 @@ async def async_step_user( client = IOmeterClient(host=self._host, session=session) try: status = await client.get_current_status() - _ = await client.get_current_reading() except IOmeterNoStatusError: errors["base"] = "no_status" - except IOmeterNoReadingsError: - errors["base"] = "no_readings" - except IOmeterConnectionError: + except IOmeterTimeoutError, IOmeterConnectionError: errors["base"] = "cannot_connect" else: - self._meter_number = status.meter.number - await self.async_set_unique_id(status.device.id) - self._abort_if_unique_id_configured() - return await self._async_create_entry() + if not status.meter: + errors["base"] = "no_readings" + else: + self._meter_number = status.meter.number + await self.async_set_unique_id(status.device.id) + self._abort_if_unique_id_configured() + return await self._async_create_entry() + return self.async_show_form( step_id="user", data_schema=CONFIG_SCHEMA, diff --git a/homeassistant/components/iometer/coordinator.py b/homeassistant/components/iometer/coordinator.py index e331749c553761..fe1bd1bb33b5cb 100644 --- a/homeassistant/components/iometer/coordinator.py +++ b/homeassistant/components/iometer/coordinator.py @@ -1,22 +1,30 @@ """DataUpdateCoordinator for IOmeter.""" +import asyncio +from collections.abc import Callable +import contextlib from dataclasses import dataclass -from datetime import timedelta import logging from typing import override -from iometer import IOmeterClient, IOmeterConnectionError, Reading, Status +from iometer import ( + IOmeterConnectionError, + IOmeterNoReadingsError, + IOmeterNoStatusError, + IOmeterSSEClient, + IOmeterTimeoutError, + Reading, + Status, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -DEFAULT_SCAN_INTERVAL = timedelta(seconds=10) type IOmeterConfigEntry = ConfigEntry[IOMeterCoordinator] @@ -33,50 +41,135 @@ class IOMeterCoordinator(DataUpdateCoordinator[IOmeterData]): """Class to manage fetching IOmeter data.""" config_entry: IOmeterConfigEntry - client: IOmeterClient + client: IOmeterSSEClient current_fw_version: str = "" def __init__( self, hass: HomeAssistant, config_entry: IOmeterConfigEntry, - client: IOmeterClient, + client: IOmeterSSEClient, ) -> None: """Initialize coordinator.""" - super().__init__( hass, _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=DEFAULT_SCAN_INTERVAL, - request_refresh_debouncer=Debouncer( - hass, _LOGGER, cooldown=1.0, immediate=False - ), ) self.client = client self.identifier = config_entry.entry_id + self._reading: Reading | None = None + self._status: Status | None = None + self._first_data_event: asyncio.Event = asyncio.Event() + self._cancel_readings: Callable[[], None] | None = None + self._cancel_status: Callable[[], None] | None = None + self._readings_task: asyncio.Task | None = None + self._status_task: asyncio.Task | None = None + + async def async_start(self) -> None: + """Register SSE subscriptions.""" + self._cancel_readings = self.client.subscribe_readings( + self._on_reading, + self._on_reading_error, + ) + self._readings_task = getattr(self._cancel_readings, "__self__", None) + self._cancel_status = self.client.subscribe_status( + self._on_status, + self._on_status_error, + ) + self._status_task = getattr(self._cancel_status, "__self__", None) + + async def async_stop(self) -> None: + """Cancel SSE subscriptions and await task teardown.""" + if self._cancel_readings: + self._cancel_readings() + self._cancel_readings = None + if self._cancel_status: + self._cancel_status() + self._cancel_status = None + for task in (self._readings_task, self._status_task): + if task and not task.done(): + with contextlib.suppress(asyncio.CancelledError): + await task + self._readings_task = None + self._status_task = None @override async def _async_update_data(self) -> IOmeterData: - """Update data async.""" + """Wait for first SSE data; subsequent updates arrive via async_set_updated_data.""" try: - reading = await self.client.get_current_reading() - status = await self.client.get_current_status() - except IOmeterConnectionError as error: - raise UpdateFailed(f"Error communicating with IOmeter: {error}") from error + async with asyncio.timeout(30): + await self._first_data_event.wait() + except TimeoutError as err: + raise UpdateFailed("Timeout waiting for IOmeter data") from err + assert self._reading is not None + assert self._status is not None + self._update_fw_version(self._status) + return IOmeterData(reading=self._reading, status=self._status) + + def _on_new_data(self) -> None: + """Called when a new reading or status arrives from SSE.""" + if self._reading is None or self._status is None: + return + if not self._first_data_event.is_set(): + self._first_data_event.set() + else: + self._update_fw_version(self._status) + self.async_set_updated_data( + IOmeterData(reading=self._reading, status=self._status) + ) + def _on_reading(self, reading: Reading) -> None: + """Handle a new reading from the SSE stream.""" + self._reading = reading + self._on_new_data() + + def _on_status(self, status: Status) -> None: + """Handle a new status from the SSE stream.""" + self._status = status + self._on_new_data() + + def _on_reading_error(self, err: Exception) -> None: + """Log reading stream errors before the library reconnects.""" + if isinstance(err, IOmeterTimeoutError): + _LOGGER.debug("IOmeter reading stream timed out, reconnecting") + elif isinstance(err, (IOmeterNoReadingsError, IOmeterConnectionError)): + self._async_set_unavailable() + _LOGGER.warning("IOmeter reading stream error: %s", err) + else: + self._async_set_unavailable() + _LOGGER.exception("Unexpected error in reading stream") + + def _on_status_error(self, err: Exception) -> None: + """Log status stream errors before the library reconnects.""" + if isinstance(err, IOmeterTimeoutError): + _LOGGER.debug("IOmeter status stream timed out, reconnecting") + elif isinstance(err, (IOmeterNoStatusError, IOmeterConnectionError)): + self._async_set_unavailable() + _LOGGER.warning("IOmeter status stream error: %s", err) + else: + self._async_set_unavailable() + _LOGGER.exception("Unexpected error in status stream") + + def _async_set_unavailable(self) -> None: + """Mark entities unavailable; skipped before first successful data.""" + if not self._first_data_event.is_set(): + return + self.last_update_success = False + self.async_update_listeners() + + def _update_fw_version(self, status: Status) -> None: + """Update device registry if firmware version changed.""" fw_version = f"{status.device.core.version}/{status.device.bridge.version}" if self.current_fw_version and fw_version != self.current_fw_version: device_registry = dr.async_get(self.hass) device_entry = device_registry.async_get_device_by_identifier( (DOMAIN, status.device.id), self.config_entry.entry_id ) - assert device_entry - device_registry.async_update_device( - device_entry.id, - sw_version=fw_version, - ) + if device_entry: + device_registry.async_update_device( + device_entry.id, + sw_version=fw_version, + ) self.current_fw_version = fw_version - - return IOmeterData(reading=reading, status=status) diff --git a/homeassistant/components/iometer/manifest.json b/homeassistant/components/iometer/manifest.json index 1c523d94f4bc11..7592910c237163 100644 --- a/homeassistant/components/iometer/manifest.json +++ b/homeassistant/components/iometer/manifest.json @@ -5,7 +5,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/iometer", "integration_type": "device", - "iot_class": "local_polling", + "iot_class": "local_push", "quality_scale": "bronze", "requirements": ["iometer==1.0.2"], "zeroconf": ["_iometer._tcp.local."] diff --git a/homeassistant/components/kitchen_sink/sensor.py b/homeassistant/components/kitchen_sink/sensor.py index dd6be92a6e52a8..1ff83f6b84112f 100644 --- a/homeassistant/components/kitchen_sink/sensor.py +++ b/homeassistant/components/kitchen_sink/sensor.py @@ -130,7 +130,7 @@ def __init__( device_unique_id: str, unique_id: str, device_name: str, - entity_name: str | None | UndefinedType, + entity_name: str | UndefinedType | None, state: StateType, device_class: SensorDeviceClass | None, state_class: SensorStateClass | None, diff --git a/homeassistant/components/knx/entity.py b/homeassistant/components/knx/entity.py index 7dff71d4c3c45f..a0272c26688546 100644 --- a/homeassistant/components/knx/entity.py +++ b/homeassistant/components/knx/entity.py @@ -25,7 +25,7 @@ from .knx_module import KNXModule -def _stable_group_address_repr(part: DeviceGroupAddress | None | int | str) -> str: +def _stable_group_address_repr(part: DeviceGroupAddress | int | str | None) -> str: """Render a unique_id part independent of `GroupAddress.address_format`.""" if isinstance(part, GroupAddress): # Always LONG (main/middle/sub) derived from raw, so the representation @@ -39,7 +39,7 @@ def _stable_group_address_repr(part: DeviceGroupAddress | None | int | str) -> s def build_yaml_unique_id( - *parts: DeviceGroupAddress | None | int | str, + *parts: DeviceGroupAddress | int | str | None, ) -> tuple[str, str]: """Return `(new_stable_id, legacy_id)` for a YAML entity. diff --git a/homeassistant/components/led_infrared/button.py b/homeassistant/components/led_infrared/button.py index 4df3ebe003bccb..2fb449a0aa7433 100644 --- a/homeassistant/components/led_infrared/button.py +++ b/homeassistant/components/led_infrared/button.py @@ -16,6 +16,32 @@ SUPPORTED_BUTTONS = { LEDIrDeviceType.GENERIC_24_KEY: ["brightness_up", "brightness_down"], LEDIrDeviceType.GENERIC_13_KEY: ["brightness_up", "brightness_down", "timer"], + LEDIrDeviceType.GENERIC_40_KEY: [ + "brightness_up", + "brightness_down", + "white_brightness_up", + "white_brightness_down", + "white_on", + "white_off", + "white_brightness_25", + "white_brightness_50", + "white_brightness_75", + "white_brightness_100", + "quick", + "slow", + ], + LEDIrDeviceType.GENERIC_44_KEY: [ + "brightness_up", + "brightness_down", + "red_up", + "green_up", + "blue_up", + "red_down", + "green_down", + "blue_down", + "quick", + "slow", + ], } diff --git a/homeassistant/components/led_infrared/icons.json b/homeassistant/components/led_infrared/icons.json index dd716b27d1da80..e833bfc4d0a1f9 100644 --- a/homeassistant/components/led_infrared/icons.json +++ b/homeassistant/components/led_infrared/icons.json @@ -1,14 +1,62 @@ { "entity": { "button": { + "blue_down": { + "default": "mdi:alpha-b-circle-outline" + }, + "blue_up": { + "default": "mdi:alpha-b-circle" + }, "brightness_down": { "default": "mdi:brightness-5" }, "brightness_up": { "default": "mdi:brightness-7" }, + "green_down": { + "default": "mdi:alpha-g-circle-outline" + }, + "green_up": { + "default": "mdi:alpha-g-circle" + }, + "quick": { + "default": "mdi:speedometer" + }, + "red_down": { + "default": "mdi:alpha-r-circle-outline" + }, + "red_up": { + "default": "mdi:alpha-r-circle" + }, + "slow": { + "default": "mdi:speedometer-slow" + }, "timer": { "default": "mdi:timer" + }, + "white_brightness_100": { + "default": "mdi:lightbulb-on" + }, + "white_brightness_25": { + "default": "mdi:lightbulb-on-30" + }, + "white_brightness_50": { + "default": "mdi:lightbulb-on-50" + }, + "white_brightness_75": { + "default": "mdi:lightbulb-on-80" + }, + "white_brightness_down": { + "default": "mdi:brightness-5" + }, + "white_brightness_up": { + "default": "mdi:brightness-7" + }, + "white_off": { + "default": "mdi:power-off" + }, + "white_on": { + "default": "mdi:power-on" } }, "event": { diff --git a/homeassistant/components/led_infrared/strings.json b/homeassistant/components/led_infrared/strings.json index 1bcd36930a504f..fd47bbbcd8e203 100644 --- a/homeassistant/components/led_infrared/strings.json +++ b/homeassistant/components/led_infrared/strings.json @@ -38,14 +38,62 @@ }, "entity": { "button": { + "blue_down": { + "name": "[%key:component::led_infrared::entity::event::received_command::state_attributes::event_type::state::blue_down%]" + }, + "blue_up": { + "name": "[%key:component::led_infrared::entity::event::received_command::state_attributes::event_type::state::blue_up%]" + }, "brightness_down": { - "name": "Brightness down" + "name": "[%key:component::led_infrared::entity::event::received_command::state_attributes::event_type::state::brightness_down%]" }, "brightness_up": { - "name": "Brightness up" + "name": "[%key:component::led_infrared::entity::event::received_command::state_attributes::event_type::state::brightness_up%]" + }, + "green_down": { + "name": "[%key:component::led_infrared::entity::event::received_command::state_attributes::event_type::state::green_down%]" + }, + "green_up": { + "name": "[%key:component::led_infrared::entity::event::received_command::state_attributes::event_type::state::green_up%]" + }, + "quick": { + "name": "[%key:component::led_infrared::entity::event::received_command::state_attributes::event_type::state::quick%]" + }, + "red_down": { + "name": "[%key:component::led_infrared::entity::event::received_command::state_attributes::event_type::state::red_down%]" + }, + "red_up": { + "name": "[%key:component::led_infrared::entity::event::received_command::state_attributes::event_type::state::red_up%]" + }, + "slow": { + "name": "[%key:component::led_infrared::entity::event::received_command::state_attributes::event_type::state::slow%]" }, "timer": { - "name": "Timer" + "name": "[%key:component::led_infrared::entity::event::received_command::state_attributes::event_type::state::timer%]" + }, + "white_brightness_100": { + "name": "[%key:component::led_infrared::entity::event::received_command::state_attributes::event_type::state::white_brightness_100%]" + }, + "white_brightness_25": { + "name": "[%key:component::led_infrared::entity::event::received_command::state_attributes::event_type::state::white_brightness_25%]" + }, + "white_brightness_50": { + "name": "[%key:component::led_infrared::entity::event::received_command::state_attributes::event_type::state::white_brightness_50%]" + }, + "white_brightness_75": { + "name": "[%key:component::led_infrared::entity::event::received_command::state_attributes::event_type::state::white_brightness_75%]" + }, + "white_brightness_down": { + "name": "[%key:component::led_infrared::entity::event::received_command::state_attributes::event_type::state::white_brightness_down%]" + }, + "white_brightness_up": { + "name": "[%key:component::led_infrared::entity::event::received_command::state_attributes::event_type::state::white_brightness_up%]" + }, + "white_off": { + "name": "[%key:component::led_infrared::entity::event::received_command::state_attributes::event_type::state::white_off%]" + }, + "white_on": { + "name": "[%key:component::led_infrared::entity::event::received_command::state_attributes::event_type::state::white_on%]" } }, "event": { diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index 4cdcbe5cfdd005..ec5b1aaddfddfc 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -133,6 +133,14 @@ class LyricSensorAccessoryEntityDescription(SensorEntityDescription): value_fn=lambda room, _: room.room_avg_humidity, suitable_fn=lambda _, accessory: accessory.type == "IndoorAirSensor", ), + LyricSensorAccessoryEntityDescription( + key="room_average_temperature", + translation_key="room_average_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda room, _: room.room_avg_temp, + suitable_fn=lambda _, accessory: accessory.type == "IndoorAirSensor", + ), ] diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index b9547c8251475c..b1d822bd896c61 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -61,6 +61,9 @@ "outdoor_temperature": { "name": "Outdoor temperature" }, + "room_average_temperature": { + "name": "Room average temperature" + }, "room_humidity": { "name": "Room humidity" }, diff --git a/homeassistant/components/media_source/helper.py b/homeassistant/components/media_source/helper.py index 92eff623bc3d56..70d0fcdc7a5533 100644 --- a/homeassistant/components/media_source/helper.py +++ b/homeassistant/components/media_source/helper.py @@ -115,7 +115,7 @@ async def async_search_media( async def async_resolve_media( hass: HomeAssistant, media_content_id: str, - target_media_player: str | None | UndefinedType = UNDEFINED, + target_media_player: str | UndefinedType | None = UNDEFINED, ) -> PlayMedia: """Get info to play media.""" if DOMAIN not in hass.config.top_level_components: diff --git a/homeassistant/components/modern_forms/coordinator.py b/homeassistant/components/modern_forms/coordinator.py index a600635c1506ef..09e2c20870db37 100644 --- a/homeassistant/components/modern_forms/coordinator.py +++ b/homeassistant/components/modern_forms/coordinator.py @@ -15,7 +15,7 @@ from .const import DOMAIN -SCAN_INTERVAL = timedelta(seconds=5) +SCAN_INTERVAL = timedelta(seconds=15) _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 4c394a1655ab08..8200b4e8e52b0e 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -1266,7 +1266,7 @@ class PlatformField: required: bool validator: Callable[[Any], Any] | None = None error: str | None = None - default: Any | None | Callable[[dict[str, Any]], Any] | vol.Undefined = ( + default: Any | Callable[[dict[str, Any]], Any] | vol.Undefined | None = ( vol.UNDEFINED ) is_schema_default: bool = False diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index 70c8283744f598..0e3b12111bb384 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -1601,7 +1601,7 @@ def config_schema() -> VolSchemaType: def _set_entity_name(self, config: ConfigType) -> None: """Help setting the entity name if needed.""" - entity_name: str | None | UndefinedType = config.get(CONF_NAME, UNDEFINED) + entity_name: str | UndefinedType | None = config.get(CONF_NAME, UNDEFINED) # Only set _attr_name if it is needed if entity_name is not UNDEFINED: self._attr_name = entity_name diff --git a/homeassistant/components/mysensors/cover.py b/homeassistant/components/mysensors/cover.py index 40ed94427aeb52..2b505d19aba7ae 100644 --- a/homeassistant/components/mysensors/cover.py +++ b/homeassistant/components/mysensors/cover.py @@ -3,7 +3,11 @@ from enum import Enum, unique from typing import Any, override -from homeassistant.components.cover import ATTR_POSITION, CoverEntity +from homeassistant.components.cover import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + CoverEntity, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON, Platform from homeassistant.core import HomeAssistant @@ -138,3 +142,45 @@ async def async_stop_cover(self, **kwargs: Any) -> None: self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_STOP, 1, ack=1 ) + + @property + @override + def current_cover_tilt_position(self) -> int | None: + """Return current position of cover tilt.""" + set_req = self.gateway.const.SetReq + if hasattr(set_req, "V_TILT"): + return self._values.get(set_req.V_TILT) + return None + + @override + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Move the cover tilt to a specific position.""" + set_req = self.gateway.const.SetReq + position = kwargs[ATTR_TILT_POSITION] + self.gateway.set_child_value( + self.node_id, self.child_id, set_req.V_TILT, position, ack=1 + ) + + @override + async def async_open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover tilt.""" + set_req = self.gateway.const.SetReq + self.gateway.set_child_value( + self.node_id, self.child_id, set_req.V_TILT, 100, ack=1 + ) + + @override + async def async_close_cover_tilt(self, **kwargs: Any) -> None: + """Close the cover tilt.""" + set_req = self.gateway.const.SetReq + self.gateway.set_child_value( + self.node_id, self.child_id, set_req.V_TILT, 0, ack=1 + ) + + @override + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: + """Stop the cover tilt.""" + set_req = self.gateway.const.SetReq + self.gateway.set_child_value( + self.node_id, self.child_id, set_req.V_STOP, 1, ack=1 + ) diff --git a/homeassistant/components/mysensors/entity.py b/homeassistant/components/mysensors/entity.py index 2cb55009e20cb8..ce158455520523 100644 --- a/homeassistant/components/mysensors/entity.py +++ b/homeassistant/components/mysensors/entity.py @@ -255,7 +255,9 @@ def _async_update(self) -> None: set_req.V_STOP, ): self._values[value_type] = STATE_ON if int(value) == 1 else STATE_OFF - elif value_type == set_req.V_DIMMER: + elif value_type == set_req.V_DIMMER or ( + hasattr(set_req, "V_TILT") and value_type == set_req.V_TILT + ): self._values[value_type] = int(value) else: self._values[value_type] = value diff --git a/homeassistant/components/neopool/manifest.json b/homeassistant/components/neopool/manifest.json index 9c0658e5031bf6..ba4dd1aa3bc78e 100644 --- a/homeassistant/components/neopool/manifest.json +++ b/homeassistant/components/neopool/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["neopool_modbus"], "quality_scale": "silver", - "requirements": ["neopool-modbus==3.6.0"] + "requirements": ["neopool-modbus==4.5.1"] } diff --git a/homeassistant/components/nfandroidtv/__init__.py b/homeassistant/components/nfandroidtv/__init__.py index 38688c211526a0..366422ef5bc27a 100644 --- a/homeassistant/components/nfandroidtv/__init__.py +++ b/homeassistant/components/nfandroidtv/__init__.py @@ -30,6 +30,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: NFAndroidTVConfigEntry) -> bool: """Set up NFAndroidTV from a config entry.""" + hass.async_create_task( + discovery.async_load_platform( + hass, + Platform.NOTIFY, + DOMAIN, + {CONF_NAME: entry.title, **entry.data}, + hass.data[DATA_HASS_CONFIG], + ) + ) + try: client = await hass.async_add_executor_job(Notifications, entry.data[CONF_HOST]) except ConnectError as e: @@ -42,16 +52,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: NFAndroidTVConfigEntry) entry.runtime_data = client - hass.async_create_task( - discovery.async_load_platform( - hass, - Platform.NOTIFY, - DOMAIN, - {CONF_NAME: entry.title, **entry.data}, - hass.data[DATA_HASS_CONFIG], - ) - ) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index 2529610da6c15a..b053247bc50ad5 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -12,7 +12,7 @@ EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC @@ -77,7 +77,7 @@ async def _connect(ip: str) -> nobo: entry, data={**entry.data, CONF_IP_ADDRESS: new_ip} ) - async def _async_close(event): + async def _async_close(event: Event) -> None: """Close the Nobø Ecohub socket connection when HA stops.""" await hub.stop() diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index aa09b8fba97f8a..83c9a9966e037e 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -106,7 +106,7 @@ class NoboZone(NoboBaseEntity, ClimateEntity): # Need to poll to get preset change when in HVACMode.AUTO _attr_should_poll = True - def __init__(self, zone_id, hub: nobo, override_type) -> None: + def __init__(self, zone_id: str, hub: nobo, override_type: str) -> None: """Initialize the climate device.""" super().__init__(hub) self._id = zone_id diff --git a/homeassistant/components/nobo_hub/config_flow.py b/homeassistant/components/nobo_hub/config_flow.py index ecbd23a7d487b4..51b52f4e4a6e8a 100644 --- a/homeassistant/components/nobo_hub/config_flow.py +++ b/homeassistant/components/nobo_hub/config_flow.py @@ -326,10 +326,12 @@ async def _test_connection(self, serial: str, ip_address: str) -> str: await hub.close() @staticmethod - def _format_hub(ip, serial_prefix): + def _format_hub(ip: str, serial_prefix: str) -> str: return f"{serial_prefix}XXX ({ip})" - def _hubs(self): + def _hubs(self) -> dict[str, str]: + if TYPE_CHECKING: + assert self._discovered_hubs return { ip: self._format_hub(ip, serial_prefix) for ip, serial_prefix in self._discovered_hubs.items() @@ -348,7 +350,7 @@ def async_get_options_flow( class NoboHubConnectError(HomeAssistantError): """Error with connecting to Nobø Ecohub.""" - def __init__(self, msg) -> None: + def __init__(self, msg: str) -> None: """Instantiate error.""" super().__init__() self.msg = msg @@ -357,7 +359,9 @@ def __init__(self, msg) -> None: class OptionsFlowHandler(OptionsFlowWithReload): """Handles options flow for the component.""" - async def async_step_init(self, user_input=None) -> ConfigFlowResult: + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: diff --git a/homeassistant/components/nobo_hub/manifest.json b/homeassistant/components/nobo_hub/manifest.json index 3350742c38d512..8d46aa65ebb0b6 100644 --- a/homeassistant/components/nobo_hub/manifest.json +++ b/homeassistant/components/nobo_hub/manifest.json @@ -15,6 +15,6 @@ "documentation": "https://www.home-assistant.io/integrations/nobo_hub", "integration_type": "hub", "iot_class": "local_push", - "quality_scale": "gold", + "quality_scale": "platinum", "requirements": ["pynobo==1.9.0"] } diff --git a/homeassistant/components/nobo_hub/quality_scale.yaml b/homeassistant/components/nobo_hub/quality_scale.yaml index fb94595ed48f20..a28f19e5524c4d 100644 --- a/homeassistant/components/nobo_hub/quality_scale.yaml +++ b/homeassistant/components/nobo_hub/quality_scale.yaml @@ -82,4 +82,4 @@ rules: inject-websession: status: exempt comment: This integration uses a local TCP socket (via pynobo); no HTTP client is used. - strict-typing: todo + strict-typing: done diff --git a/homeassistant/components/nobo_hub/select.py b/homeassistant/components/nobo_hub/select.py index 85ad51e78e04b9..ef6f7f29f36543 100644 --- a/homeassistant/components/nobo_hub/select.py +++ b/homeassistant/components/nobo_hub/select.py @@ -77,7 +77,7 @@ class NoboGlobalSelector(NoboBaseEntity, SelectEntity): _attr_options = list(_modes.values()) _attr_current_option: str | None = None - def __init__(self, hub: nobo, override_type) -> None: + def __init__(self, hub: nobo, override_type: str) -> None: """Initialize the global override selector.""" super().__init__(hub) self._attr_unique_id = hub.hub_serial diff --git a/homeassistant/components/palazzetti/sensor.py b/homeassistant/components/palazzetti/sensor.py index e4d448184aa3f6..908314b9fd6701 100644 --- a/homeassistant/components/palazzetti/sensor.py +++ b/homeassistant/components/palazzetti/sensor.py @@ -28,7 +28,7 @@ class PropertySensorEntityDescription(SensorEntityDescription): client_property: str property_map: dict[StateType, str] | None = None - presence_flag: None | str = None + presence_flag: str | None = None PROPERTY_SENSOR_DESCRIPTIONS: list[PropertySensorEntityDescription] = [ diff --git a/homeassistant/components/portainer/button.py b/homeassistant/components/portainer/button.py index b2a4dd8b7d73d0..72a193de6bf92c 100644 --- a/homeassistant/components/portainer/button.py +++ b/homeassistant/components/portainer/button.py @@ -42,7 +42,7 @@ class PortainerEndpointButtonDescription(ButtonEntityDescription): press_action: Callable[ [Portainer, int], - Coroutine[Any, Any, None | DockerContainer], + Coroutine[Any, Any, DockerContainer | None], ] @@ -52,7 +52,7 @@ class PortainerContainerButtonDescription(ButtonEntityDescription): press_action: Callable[ [Portainer, int, str], - Coroutine[Any, Any, None | DockerContainer], + Coroutine[Any, Any, DockerContainer | None], ] diff --git a/homeassistant/components/prusalink/coordinator.py b/homeassistant/components/prusalink/coordinator.py index d4de511a41d60b..102cfa2bd4541d 100644 --- a/homeassistant/components/prusalink/coordinator.py +++ b/homeassistant/components/prusalink/coordinator.py @@ -41,9 +41,9 @@ bound=PrinterStatus | LegacyPrinterStatus | JobInfo - | None | PrinterInfo - | VersionInfo, + | VersionInfo + | None, ) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 69a61de9170b17..b1195f43b14f53 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -566,8 +566,8 @@ def async_update_statistics_metadata( statistic_id: str, *, new_statistic_id: str | UndefinedType = UNDEFINED, - new_unit_class: str | None | UndefinedType = UNDEFINED, - new_unit_of_measurement: str | None | UndefinedType = UNDEFINED, + new_unit_class: str | UndefinedType | None = UNDEFINED, + new_unit_of_measurement: str | UndefinedType | None = UNDEFINED, on_done: Callable[[], None] | None = None, ) -> None: """Update statistics metadata for a statistic_id.""" diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 641dc5e2973a2d..8302fb3ca82672 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -979,8 +979,8 @@ def async_update_statistics_metadata( statistic_id: str, *, new_statistic_id: str | UndefinedType = UNDEFINED, - new_unit_class: str | None | UndefinedType = UNDEFINED, - new_unit_of_measurement: str | None | UndefinedType = UNDEFINED, + new_unit_class: str | UndefinedType | None = UNDEFINED, + new_unit_of_measurement: str | UndefinedType | None = UNDEFINED, on_done: Callable[[], None] | None = None, _called_from_ws_api: bool = False, ) -> None: @@ -1028,9 +1028,9 @@ def async_update_statistics_metadata( def update_statistics_metadata( instance: Recorder, statistic_id: str, - new_statistic_id: str | None | UndefinedType, - new_unit_class: str | None | UndefinedType, - new_unit_of_measurement: str | None | UndefinedType, + new_statistic_id: str | UndefinedType | None, + new_unit_class: str | UndefinedType | None, + new_unit_of_measurement: str | UndefinedType | None, ) -> None: """Update statistics metadata for a statistic_id.""" statistics_meta_manager = instance.statistics_meta_manager diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index d383161a553f64..ea4a584584e71b 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -76,9 +76,9 @@ class UpdateStatisticsMetadataTask(RecorderTask): on_done: Callable[[], None] | None statistic_id: str - new_statistic_id: str | None | UndefinedType - new_unit_class: str | None | UndefinedType - new_unit_of_measurement: str | None | UndefinedType + new_statistic_id: str | UndefinedType | None + new_unit_class: str | UndefinedType | None + new_unit_of_measurement: str | UndefinedType | None @override def run(self, instance: Recorder) -> None: diff --git a/homeassistant/components/roborock/icons.json b/homeassistant/components/roborock/icons.json index bc018d23789b84..b436a367e46edf 100644 --- a/homeassistant/components/roborock/icons.json +++ b/homeassistant/components/roborock/icons.json @@ -171,6 +171,9 @@ }, "set_vacuum_goto_position": { "service": "mdi:map-marker" + }, + "set_vacuum_zoned_cleaning": { + "service": "mdi:select-marker" } } } diff --git a/homeassistant/components/roborock/services.py b/homeassistant/components/roborock/services.py index 7c60921557fa23..35eebd2b4006da 100644 --- a/homeassistant/components/roborock/services.py +++ b/homeassistant/components/roborock/services.py @@ -9,6 +9,7 @@ from .const import DOMAIN GET_MAPS_SERVICE_NAME = "get_maps" +SET_VACUUM_ZONED_CLEANING_SERVICE_NAME = "set_vacuum_zoned_cleaning" SET_VACUUM_GOTO_POSITION_SERVICE_NAME = "set_vacuum_goto_position" GET_VACUUM_CURRENT_POSITION_SERVICE_NAME = "get_vacuum_current_position" @@ -51,3 +52,23 @@ def async_setup_services(hass: HomeAssistant) -> None: func="async_set_vacuum_goto_position", supports_response=SupportsResponse.NONE, ) + + service.async_register_platform_entity_service( + hass, + DOMAIN, + SET_VACUUM_ZONED_CLEANING_SERVICE_NAME, + entity_domain=VACUUM_DOMAIN, + schema=cv.make_entity_service_schema( + { + vol.Required("x1"): vol.Coerce(int), + vol.Required("y1"): vol.Coerce(int), + vol.Required("x2"): vol.Coerce(int), + vol.Required("y2"): vol.Coerce(int), + vol.Required("repeats"): vol.All( + vol.Coerce(int), vol.Range(min=0, max=2) + ), + }, + ), + func="async_set_vacuum_zoned_cleaning", + supports_response=SupportsResponse.NONE, + ) diff --git a/homeassistant/components/roborock/services.yaml b/homeassistant/components/roborock/services.yaml index eebda66fac78c6..5e8d1f7e8f107a 100644 --- a/homeassistant/components/roborock/services.yaml +++ b/homeassistant/components/roborock/services.yaml @@ -21,6 +21,44 @@ set_vacuum_goto_position: selector: text: type: number +set_vacuum_zoned_cleaning: + target: + entity: + integration: roborock + domain: vacuum + fields: + x1: + example: 28582 + required: true + selector: + text: + type: number + y1: + example: 21363 + required: true + selector: + text: + type: number + x2: + example: 27425 + required: true + selector: + text: + type: number + y2: + example: 22816 + required: true + selector: + text: + type: number + repeats: + example: 0 + required: true + selector: + number: + min: 0 + max: 2 + step: 1 get_vacuum_current_position: target: entity: diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index b02fc8b2b639c8..764267178ad11c 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -816,6 +816,32 @@ } }, "name": "Go to position" + }, + "set_vacuum_zoned_cleaning": { + "description": "Starts cleaning the specified zone.", + "fields": { + "repeats": { + "description": "The number of times the zone cleaning is repeated. '0' is just cleaning once.", + "name": "Repeats" + }, + "x1": { + "description": "The first x-coordinate of the zone.", + "name": "X1-coordinate" + }, + "x2": { + "description": "The second x-coordinate of the zone.", + "name": "X2-coordinate" + }, + "y1": { + "description": "The first y-coordinate of the zone.", + "name": "Y1-coordinate" + }, + "y2": { + "description": "The second y-coordinate of the zone.", + "name": "Y2-coordinate" + } + }, + "name": "Clean zone" } } } diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 1fae7472c9b1d3..853572e4ff9765 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -284,6 +284,12 @@ async def async_set_vacuum_goto_position(self, x: int, y: int) -> None: """Send vacuum to a specific target point.""" await self.send(RoborockCommand.APP_GOTO_TARGET, [x, y]) + async def async_set_vacuum_zoned_cleaning( + self, x1: int, y1: int, x2: int, y2: int, repeats: int + ) -> None: + """Clean the specified zone.""" + await self.send(RoborockCommand.APP_ZONED_CLEAN, [[x1, y1, x2, y2, repeats]]) + @override async def async_get_segments(self) -> list[Segment]: """Get the segments that can be cleaned.""" @@ -552,6 +558,12 @@ async def async_set_vacuum_goto_position(self, x: int, y: int) -> None: """Set the vacuum to go to a specific position.""" raise ServiceNotSupported(DOMAIN, "set_vacuum_goto_position", self.entity_id) + async def async_set_vacuum_zoned_cleaning( + self, x1: int, y1: int, x2: int, y2: int, repeats: int + ) -> None: + """Clean the specified zone.""" + raise ServiceNotSupported(DOMAIN, "set_vacuum_zoned_cleaning", self.entity_id) + class RoborockQ10Vacuum(RoborockCoordinatedEntityB01Q10, StateVacuumEntity): """Representation of a Roborock Q10 vacuum.""" @@ -769,3 +781,9 @@ async def get_vacuum_current_position(self) -> ServiceResponse: async def async_set_vacuum_goto_position(self, x: int, y: int) -> None: """Set the vacuum to go to a specific position.""" raise ServiceNotSupported(DOMAIN, "set_vacuum_goto_position", self.entity_id) + + async def async_set_vacuum_zoned_cleaning( + self, x1: int, y1: int, x2: int, y2: int, repeats: int + ) -> None: + """Clean the specified zone.""" + raise ServiceNotSupported(DOMAIN, "set_vacuum_zoned_cleaning", self.entity_id) diff --git a/homeassistant/components/ruuvi_gateway/__init__.py b/homeassistant/components/ruuvi_gateway/__init__.py index c5af0db31540f9..f0b09ff586006a 100644 --- a/homeassistant/components/ruuvi_gateway/__init__.py +++ b/homeassistant/components/ruuvi_gateway/__init__.py @@ -1,5 +1,4 @@ """The Ruuvi Gateway integration.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging @@ -13,12 +12,16 @@ _LOGGER = logging.getLogger(DOMAIN) +type RuuviGatewayConfigEntry = ConfigEntry[RuuviGatewayRuntimeData] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry( + hass: HomeAssistant, entry: RuuviGatewayConfigEntry +) -> bool: """Set up Ruuvi Gateway from a config entry.""" coordinator = RuuviGatewayUpdateCoordinator(hass, entry, _LOGGER) scanner, unload_scanner = async_connect_scanner(hass, entry, coordinator) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = RuuviGatewayRuntimeData( + entry.runtime_data = RuuviGatewayRuntimeData( update_coordinator=coordinator, scanner=scanner, ) @@ -26,9 +29,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: RuuviGatewayConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, []): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return True diff --git a/homeassistant/components/ruuvitag_ble/__init__.py b/homeassistant/components/ruuvitag_ble/__init__.py index 65d096ca1b20e5..f0a121c006b23f 100644 --- a/homeassistant/components/ruuvitag_ble/__init__.py +++ b/homeassistant/components/ruuvitag_ble/__init__.py @@ -1,5 +1,4 @@ """The ruuvitag_ble integration.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging @@ -13,27 +12,26 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN - PLATFORMS: list[Platform] = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) +type RuuvitagBLEConfigEntry = ConfigEntry[PassiveBluetoothProcessorCoordinator] + -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: RuuvitagBLEConfigEntry) -> bool: """Set up Ruuvi BLE device from a config entry.""" address = entry.unique_id assert address is not None data = RuuvitagBluetoothDeviceData() - coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ( - PassiveBluetoothProcessorCoordinator( - hass, - _LOGGER, - address=address, - mode=BluetoothScanningMode.ACTIVE, - update_method=data.update, - ) + coordinator = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + address=address, + mode=BluetoothScanningMode.ACTIVE, + update_method=data.update, ) + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload( coordinator.async_start() @@ -41,9 +39,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: RuuvitagBLEConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ruuvitag_ble/sensor.py b/homeassistant/components/ruuvitag_ble/sensor.py index 37d12ffdf1da53..69a5c747cf22e4 100644 --- a/homeassistant/components/ruuvitag_ble/sensor.py +++ b/homeassistant/components/ruuvitag_ble/sensor.py @@ -9,12 +9,10 @@ Units, ) -from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, PassiveBluetoothEntityKey, - PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( @@ -35,7 +33,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info -from .const import DOMAIN +from . import RuuvitagBLEConfigEntry SENSOR_DESCRIPTIONS = { "temperature": SensorEntityDescription( @@ -194,15 +192,11 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: RuuvitagBLEConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Ruuvi BLE sensors.""" - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=home-assistant-use-runtime-data - coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ - entry.entry_id - ] + coordinator = entry.runtime_data processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) entry.async_on_unload( processor.async_add_entities_listener( diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 54991bd5a2f1cf..6e425a395e2d10 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -209,7 +209,7 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _invalid_unit_of_measurement_reported = False _last_reset_reported = False _sensor_option_display_precision: int | None = None - _sensor_option_unit_of_measurement: str | None | UndefinedType = UNDEFINED + _sensor_option_unit_of_measurement: str | UndefinedType | None = UNDEFINED _invalid_suggested_unit_of_measurement_reported = False _get_uptime: Callable[[datetime], datetime] | None = None @@ -910,7 +910,7 @@ def _update_suggested_precision(self) -> None: def _custom_unit_or_undef( self, primary_key: str, secondary_key: str - ) -> str | None | UndefinedType: + ) -> str | UndefinedType | None: """Return a custom unit, or UNDEFINED if not compatible with the native unit.""" assert self.registry_entry if ( diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 5440951454de87..79181b65359b1e 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -359,7 +359,7 @@ def _normalize_states( valid_fstates: list[tuple[float, State]] = [] convert: Callable[[float], float] | None = None - last_unit: str | None | UndefinedType = UNDEFINED + last_unit: str | UndefinedType | None = UNDEFINED valid_units = converter.VALID_UNITS for fstate, state in fstates: diff --git a/homeassistant/components/shopping_list/common.py b/homeassistant/components/shopping_list/common.py index 4305ba84bdedee..cdd7faf423836c 100644 --- a/homeassistant/components/shopping_list/common.py +++ b/homeassistant/components/shopping_list/common.py @@ -98,7 +98,7 @@ async def async_remove_items( async def async_complete( self, name: str, context: Context | None = None ) -> list[dict[str, JsonValueType]]: - """Mark all shopping list items with the given name as complete.""" + """Mark all incomplete shopping list items with the given name as complete.""" complete_items = [ item for item in self.items if item["name"] == name and not item["complete"] ] diff --git a/homeassistant/components/shopping_list/strings.json b/homeassistant/components/shopping_list/strings.json index 06ffc307b1a7d9..6c4fe93234fe0a 100644 --- a/homeassistant/components/shopping_list/strings.json +++ b/homeassistant/components/shopping_list/strings.json @@ -37,7 +37,7 @@ "name": "Complete all shopping list items" }, "complete_item": { - "description": "Marks the first item with matching name as completed in the shopping list.", + "description": "Marks all items with matching name as completed in the shopping list. Skips items that are already complete.", "fields": { "name": { "description": "The name of the item to mark as completed (without removing).", diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index c3d3688bfbccdc..5052bf594997ad 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -1061,17 +1061,17 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): SmartThingsSensorEntityDescription( key="x_coordinate", translation_key="x_coordinate", - value_fn=lambda value: value[0], + value_fn=lambda value: value[0] if value else None, ), SmartThingsSensorEntityDescription( key="y_coordinate", translation_key="y_coordinate", - value_fn=lambda value: value[1], + value_fn=lambda value: value[1] if value else None, ), SmartThingsSensorEntityDescription( key="z_coordinate", translation_key="z_coordinate", - value_fn=lambda value: value[2], + value_fn=lambda value: value[2] if value else None, ), ] }, diff --git a/homeassistant/components/squeezebox/update.py b/homeassistant/components/squeezebox/update.py index affe359637f37e..309a8314a223a4 100644 --- a/homeassistant/components/squeezebox/update.py +++ b/homeassistant/components/squeezebox/update.py @@ -87,7 +87,7 @@ def release_url(self) -> str: @property @override - def release_summary(self) -> None | str: + def release_summary(self) -> str | None: """If install is supported give some info.""" return ( str(self.coordinator.data[UPDATE_RELEASE_SUMMARY]) @@ -117,7 +117,7 @@ def supported_features(self) -> UpdateEntityFeature: @property @override - def release_summary(self) -> None | str: + def release_summary(self) -> str | None: """If install is supported give some info.""" rs = self.coordinator.data[UPDATE_PLUGINS_RELEASE_SUMMARY] return ( diff --git a/homeassistant/components/switchbot/icons.json b/homeassistant/components/switchbot/icons.json index 043772e6505b05..b4bf6e4623db8e 100644 --- a/homeassistant/components/switchbot/icons.json +++ b/homeassistant/components/switchbot/icons.json @@ -59,6 +59,19 @@ } } } + }, + "standing_fan": { + "state_attributes": { + "preset_mode": { + "state": { + "baby": "mdi:baby-face-outline", + "custom_natural": "mdi:leaf-circle-outline", + "natural": "mdi:leaf", + "normal": "mdi:fan", + "sleep": "mdi:power-sleep" + } + } + } } }, "humidifier": { diff --git a/homeassistant/components/tado/helper.py b/homeassistant/components/tado/helper.py index 838222dc768af7..24043f9bd0629a 100644 --- a/homeassistant/components/tado/helper.py +++ b/homeassistant/components/tado/helper.py @@ -37,7 +37,7 @@ def decide_duration( duration: int | None, zone_id: int, overlay_mode: str | None = None, -) -> None | int: +) -> int | None: """Return correct duration based on overlay mode and tado config.""" # If we ended up with a timer but no duration, set a default duration diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index a61b9cb5e60e0f..787a6d63b29ca9 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -175,14 +175,17 @@ def as_dict(self) -> dict[str, Any]: return asdict(self) @classmethod - def from_dict(cls, restored: dict[str, Any]) -> Self: + def from_dict(cls, restored: dict[str, Any]) -> Self | None: """Initialize a stored cover state from a dict.""" - return cls( - current_cover_position=restored["current_cover_position"], - current_cover_tilt_position=restored["current_cover_tilt_position"], - is_opening=restored["is_opening"], - is_closing=restored["is_closing"], - ) + try: + return cls( + current_cover_position=restored["current_cover_position"], + current_cover_tilt_position=restored["current_cover_tilt_position"], + is_opening=restored["is_opening"], + is_closing=restored["is_closing"], + ) + except KeyError: + return None class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity, RestoreEntity): diff --git a/homeassistant/components/template/device_tracker.py b/homeassistant/components/template/device_tracker.py index e0103fdf92caf0..702b9fa24f2fa3 100644 --- a/homeassistant/components/template/device_tracker.py +++ b/homeassistant/components/template/device_tracker.py @@ -191,14 +191,17 @@ def as_dict(self) -> dict[str, Any]: return asdict(self) @classmethod - def from_dict(cls, restored: dict[str, Any]) -> Self: + def from_dict(cls, restored: dict[str, Any]) -> Self | None: """Initialize a stored tracker state from a dict.""" - return cls( - in_zones=restored["in_zones"], - latitude=restored["latitude"], - longitude=restored["longitude"], - location_accuracy=restored["location_accuracy"], - ) + try: + return cls( + in_zones=restored["in_zones"], + latitude=restored["latitude"], + longitude=restored["longitude"], + location_accuracy=restored["location_accuracy"], + ) + except KeyError: + return None class AbstractTemplateTracker(AbstractTemplateEntity, TrackerEntity, RestoreEntity): diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 5a8f221ae98a07..ff87cc830057e0 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -175,28 +175,16 @@ def as_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, restored: dict[str, Any]) -> Self | None: """Initialize a stored fan data from a dict.""" - is_on = restored.get("is_on") - percentage = restored.get("percentage") - preset_mode = restored.get("preset_mode") - oscillating = restored.get("oscillating") - direction = restored.get("direction") - if is_on is not None and not isinstance(is_on, bool): - return None - if percentage is not None and not isinstance(percentage, int): - return None - if preset_mode is not None and not isinstance(preset_mode, str): - return None - if oscillating is not None and not isinstance(oscillating, bool): - return None - if direction is not None and not isinstance(direction, str): + try: + return cls( + is_on=restored["is_on"], + percentage=restored["percentage"], + preset_mode=restored["preset_mode"], + oscillating=restored["oscillating"], + direction=restored["direction"], + ) + except KeyError: return None - return cls( - is_on=is_on, - percentage=percentage, - preset_mode=preset_mode, - oscillating=oscillating, - direction=direction, - ) class AbstractTemplateFan(AbstractTemplateEntity, FanEntity, RestoreEntity): diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 33609933d4dfd1..ff7176171e4022 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -2,8 +2,9 @@ from collections.abc import Callable import contextlib +from dataclasses import dataclass import logging -from typing import TYPE_CHECKING, Any, override +from typing import TYPE_CHECKING, Any, Self, override import voluptuous as vol @@ -41,6 +42,7 @@ AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import color as color_util @@ -281,7 +283,85 @@ def convert(result: Any) -> list[int] | None: return convert -class AbstractTemplateLight(AbstractTemplateEntity, LightEntity): +@dataclass(kw_only=True) +class LightExtraStoredData(ExtraStoredData): + """Object to hold extra stored data.""" + + is_on: bool | None + brightness: int | None + color_mode: ColorMode | None + color_temp_kelvin: int | None + effect_list: list[str] | None + effect: str | None + hs_color: tuple[float, float] | None + max_color_temp_kelvin: int + min_color_temp_kelvin: int + rgb_color: tuple[int, int, int] | None + rgbw_color: tuple[int, int, int, int] | None + rgbww_color: tuple[int, int, int, int, int] | None + supported_color_modes: set[ColorMode] | None + xy_color: tuple[float, float] | None + + @override + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the extra data.""" + return { + "is_on": self.is_on, + "brightness": self.brightness, + "color_mode": self.color_mode.value if self.color_mode else None, + "color_temp_kelvin": self.color_temp_kelvin, + "effect_list": self.effect_list, + "effect": self.effect, + "hs_color": self.hs_color, + "max_color_temp_kelvin": self.max_color_temp_kelvin, + "min_color_temp_kelvin": self.min_color_temp_kelvin, + "rgb_color": self.rgb_color, + "rgbw_color": self.rgbw_color, + "rgbww_color": self.rgbww_color, + "supported_color_modes": ( + [mode.value for mode in self.supported_color_modes] + if self.supported_color_modes + else None + ), + "xy_color": self.xy_color, + } + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self | None: + """Initialize a stored state from a dict.""" + + try: + color_mode: ColorMode | None = None + if _color_mode := restored["color_mode"]: + color_mode = ColorMode(_color_mode) + + supported_color_modes: set[ColorMode] | None = None + if _supported_color_modes := restored["supported_color_modes"]: + supported_color_modes = { + ColorMode(item) for item in _supported_color_modes + } + + return cls( + is_on=restored["is_on"], + brightness=restored["brightness"], + color_mode=color_mode, + color_temp_kelvin=restored["color_temp_kelvin"], + effect_list=restored["effect_list"], + effect=restored["effect"], + hs_color=restored["hs_color"], + max_color_temp_kelvin=restored["max_color_temp_kelvin"], + min_color_temp_kelvin=restored["min_color_temp_kelvin"], + rgb_color=restored["rgb_color"], + rgbw_color=restored["rgbw_color"], + rgbww_color=restored["rgbww_color"], + supported_color_modes=supported_color_modes, + xy_color=restored["xy_color"], + ) + except KeyError, ValueError: + return None + + +class AbstractTemplateLight(AbstractTemplateEntity, LightEntity, RestoreEntity): """Representation of a template lights features.""" _entity_id_format = ENTITY_ID_FORMAT @@ -289,6 +369,8 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity): _attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN _attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN _state_option = CONF_STATE + _restore_state_extra_data = LightExtraStoredData + _restore_state_properties = ("_attr_is_on",) # The super init is not called because TemplateEntity # and TriggerEntity will call @@ -772,6 +854,45 @@ def _update_supports_transition(self, render): if self._supports_transition: self._attr_supported_features |= LightEntityFeature.TRANSITION + @property + @override + def extra_restore_state_data(self) -> LightExtraStoredData: + """Return weather specific state data to be restored.""" + return LightExtraStoredData( + is_on=self._attr_is_on, + brightness=self._attr_brightness, + color_mode=self._attr_color_mode, + color_temp_kelvin=self._attr_color_temp_kelvin, + effect_list=self._attr_effect_list, + effect=self._attr_effect, + hs_color=self._attr_hs_color, + max_color_temp_kelvin=self._attr_max_color_temp_kelvin, + min_color_temp_kelvin=self._attr_min_color_temp_kelvin, + rgb_color=self._attr_rgb_color, + rgbw_color=self._attr_rgbw_color, + rgbww_color=self._attr_rgbww_color, + supported_color_modes=self._attr_supported_color_modes, + xy_color=self._attr_xy_color, + ) + + @override + def restore_extra_data(self, extra_data: LightExtraStoredData) -> None: + """Restore the extra data.""" + self._attr_is_on = extra_data.is_on + self._attr_brightness = extra_data.brightness + self._attr_color_mode = extra_data.color_mode + self._attr_color_temp_kelvin = extra_data.color_temp_kelvin + self._attr_effect_list = extra_data.effect_list + self._attr_effect = extra_data.effect + self._attr_hs_color = extra_data.hs_color + self._attr_max_color_temp_kelvin = extra_data.max_color_temp_kelvin + self._attr_min_color_temp_kelvin = extra_data.min_color_temp_kelvin + self._attr_rgb_color = extra_data.rgb_color + self._attr_rgbw_color = extra_data.rgbw_color + self._attr_rgbww_color = extra_data.rgbww_color + self._attr_supported_color_modes = extra_data.supported_color_modes + self._attr_xy_color = extra_data.xy_color + class StateLightEntity(TemplateEntity, AbstractTemplateLight): """Representation of a templated Light, including dimmable.""" diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index a77f76aef9c00f..9c31be9cba5004 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -1,6 +1,7 @@ """Support for locks which integrates with other components.""" -from typing import TYPE_CHECKING, Any, override +from dataclasses import asdict, dataclass +from typing import TYPE_CHECKING, Any, Self, override import voluptuous as vol @@ -20,6 +21,7 @@ AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import validators as template_validators @@ -122,12 +124,48 @@ def async_create_preview_lock( ) -class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): +@dataclass(kw_only=True) +class LockExtraStoredData(ExtraStoredData): + """Holds extra stored data for template lock entities.""" + + code_format: str | None + is_locked: bool | None + is_locking: bool | None + is_open: bool | None + is_opening: bool | None + is_unlocking: bool | None + is_jammed: bool | None + + @override + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the lock data.""" + return asdict(self) + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self | None: + """Initialize a stored lock state from a dict.""" + try: + return cls( + code_format=restored["code_format"], + is_locked=restored["is_locked"], + is_locking=restored["is_locking"], + is_open=restored["is_open"], + is_opening=restored["is_opening"], + is_unlocking=restored["is_unlocking"], + is_jammed=restored["is_jammed"], + ) + except KeyError: + return None + + +class AbstractTemplateLock(AbstractTemplateEntity, LockEntity, RestoreEntity): """Representation of a template lock features.""" _entity_id_format = ENTITY_ID_FORMAT _optimistic_entity = True _state_option = CONF_STATE + _restore_state_extra_data = LockExtraStoredData + _restore_state_properties = ("_attr_is_locked",) # The super init is not called because TemplateEntity # and TriggerEntity will call @@ -260,6 +298,31 @@ def _raise_template_error_if_available(self): }, ) + @property + @override + def extra_restore_state_data(self) -> LockExtraStoredData: + """Return lock specific state data to be restored.""" + return LockExtraStoredData( + code_format=self._attr_code_format, + is_locked=self._attr_is_locked, + is_locking=self._attr_is_locking, + is_open=self._attr_is_open, + is_opening=self._attr_is_opening, + is_unlocking=self._attr_is_unlocking, + is_jammed=self._attr_is_jammed, + ) + + @override + def restore_extra_data(self, extra_data: LockExtraStoredData) -> None: + """Restore the extra data.""" + self._attr_code_format = extra_data.code_format + self._attr_is_locked = extra_data.is_locked + self._attr_is_locking = extra_data.is_locking + self._attr_is_open = extra_data.is_open + self._attr_is_opening = extra_data.is_opening + self._attr_is_unlocking = extra_data.is_unlocking + self._attr_is_jammed = extra_data.is_jammed + class StateLockEntity(TemplateEntity, AbstractTemplateLock): """Representation of a template lock.""" diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 54b7ad435edf31..05ab4cc57e6170 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -1,7 +1,8 @@ """Support for selects which integrates with other components.""" +from dataclasses import asdict, dataclass import logging -from typing import TYPE_CHECKING, Any, override +from typing import TYPE_CHECKING, Any, Self, override import voluptuous as vol @@ -18,6 +19,7 @@ AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator, validators as template_validators @@ -106,12 +108,38 @@ def async_create_preview_select( ) -class AbstractTemplateSelect(AbstractTemplateEntity, SelectEntity): +@dataclass(kw_only=True) +class SelectExtraStoredData(ExtraStoredData): + """Holds extra stored data for template select entities.""" + + current_option: str | None + options: list[str] + + @override + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the select data.""" + return asdict(self) + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self | None: + """Initialize a stored select state from a dict.""" + try: + return cls( + current_option=restored["current_option"], + options=restored["options"], + ) + except KeyError: + return None + + +class AbstractTemplateSelect(AbstractTemplateEntity, SelectEntity, RestoreEntity): """Representation of a template select features.""" _entity_id_format = ENTITY_ID_FORMAT _optimistic_entity = True _state_option = CONF_STATE + _restore_state_extra_data = SelectExtraStoredData + _restore_state_properties = ("_attr_current_option",) # The super init is not called because TemplateEntity # and TriggerEntity will call @@ -124,7 +152,7 @@ def __init__(self, name: str, config: dict[str, Any]) -> None: # pylint: disabl self.setup_state_template( "_attr_current_option", - cv.string, + template_validators.string(self, CONF_STATE), ) self.setup_template( CONF_OPTIONS, @@ -150,6 +178,21 @@ async def async_select_option(self, option: str) -> None: context=self._context, ) + @property + @override + def extra_restore_state_data(self) -> SelectExtraStoredData: + """Return select specific state data to be restored.""" + return SelectExtraStoredData( + current_option=self._attr_current_option, + options=self._attr_options or [], + ) + + @override + def restore_extra_data(self, extra_data: SelectExtraStoredData) -> None: + """Restore the extra data.""" + self._attr_current_option = extra_data.current_option + self._attr_options = extra_data.options + class TemplateSelect(TemplateEntity, AbstractTemplateSelect): """Representation of a template select.""" diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index f0412fb903737f..514e66635aded0 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -1,8 +1,9 @@ """Support for Template vacuums.""" from collections.abc import Callable +from dataclasses import dataclass import logging -from typing import TYPE_CHECKING, Any, override +from typing import TYPE_CHECKING, Any, Self, override import voluptuous as vol @@ -28,6 +29,7 @@ AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator, validators as template_validators @@ -214,12 +216,45 @@ def parse(result: Any) -> list[Segment] | None: return parse -class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): +@dataclass(kw_only=True) +class VacuumExtraStoredData(ExtraStoredData): + """Holds extra stored data for template vacuum entities.""" + + activity: VacuumActivity | None + fan_speed: str | None + + @override + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the vacuum data.""" + return { + "activity": self.activity.value if self.activity else None, + "fan_speed": self.fan_speed, + } + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self | None: + """Initialize a stored vacuum state from a dict.""" + try: + activity: VacuumActivity | None = None + if _activity := restored["activity"]: + activity = VacuumActivity(_activity) + + return cls( + activity=activity, + fan_speed=restored["fan_speed"], + ) + except KeyError, ValueError: + return None + + +class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity, RestoreEntity): """Representation of a template vacuum features.""" _entity_id_format = ENTITY_ID_FORMAT _optimistic_entity = True _state_option = CONF_STATE + _restore_state_extra_data = VacuumExtraStoredData + _restore_state_properties = ("_attr_activity",) # The super init is not called because TemplateEntity # and TriggerEntity will call @@ -369,6 +404,21 @@ async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: script, run_variables={"fan_speed": fan_speed}, context=self._context ) + @property + @override + def extra_restore_state_data(self) -> VacuumExtraStoredData: + """Return vacuum specific state data to be restored.""" + return VacuumExtraStoredData( + activity=self._attr_activity, + fan_speed=self._attr_fan_speed, + ) + + @override + def restore_extra_data(self, extra_data: VacuumExtraStoredData) -> None: + """Restore the extra data.""" + self._attr_activity = extra_data.activity + self._attr_fan_speed = extra_data.fan_speed + class TemplateStateVacuumEntity(TemplateEntity, AbstractTemplateVacuum): """A template vacuum component.""" diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 68405a4b608b80..5e49f4d0f8276f 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -87,7 +87,7 @@ async def async_disconnect(self) -> None: try: async with asyncio.timeout(DISCONNECT_TIMEOUT): await self._client.rt_disconnect() - except Exception: # noqa: BLE001 + except Exception: _LOGGER.warning( "Error disconnecting the Tibber realtime connection", exc_info=True ) diff --git a/homeassistant/components/todoist/__init__.py b/homeassistant/components/todoist/__init__.py index 56793e52398bdd..08264c4f2189bb 100644 --- a/homeassistant/components/todoist/__init__.py +++ b/homeassistant/components/todoist/__init__.py @@ -7,8 +7,12 @@ from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType +from .const import DOMAIN from .coordinator import TodoistConfigEntry, TodoistCoordinator +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -17,6 +21,14 @@ PLATFORMS: list[Platform] = [Platform.CALENDAR, Platform.TODO] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the integration.""" + async_setup_services(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: TodoistConfigEntry) -> bool: """Set up todoist from a config entry.""" diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index a721870f0adad5..b5ce72c38194a5 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -1,9 +1,9 @@ """Support for Todoist task management (https://todoist.com).""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from datetime import date, datetime, timedelta import logging from typing import Any, override -import uuid from todoist_api_python.api_async import TodoistAPIAsync from todoist_api_python.models import Label, Project, Task @@ -15,10 +15,8 @@ CalendarEvent, ) from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import Event, HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import ServiceValidationError +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -30,30 +28,18 @@ from .const import ( ALL_DAY, ALL_TASKS, - ASSIGNEE, COMPLETED, CONF_EXTRA_PROJECTS, CONF_PROJECT_DUE_DATE, CONF_PROJECT_LABEL_WHITELIST, CONF_PROJECT_WHITELIST, - CONTENT, DESCRIPTION, DOMAIN, - DUE_DATE, - DUE_DATE_LANG, - DUE_DATE_STRING, - DUE_DATE_VALID_LANGS, DUE_TODAY, END, LABELS, OVERDUE, PRIORITY, - PROJECT_NAME, - REMINDER_DATE, - REMINDER_DATE_LANG, - REMINDER_DATE_STRING, - SECTION_NAME, - SERVICE_NEW_TASK, START, SUMMARY, ) @@ -63,25 +49,6 @@ _LOGGER = logging.getLogger(__name__) -NEW_TASK_SERVICE_SCHEMA = vol.Schema( - { - vol.Required(CONTENT): cv.string, - vol.Optional(DESCRIPTION): cv.string, - vol.Optional(PROJECT_NAME, default="inbox"): vol.All(cv.string, vol.Lower), - vol.Optional(SECTION_NAME): vol.All(cv.string, vol.Lower), - vol.Optional(LABELS): cv.ensure_list_csv, - vol.Optional(ASSIGNEE): cv.string, - vol.Optional(PRIORITY): vol.All(vol.Coerce(int), vol.Range(min=1, max=4)), - vol.Exclusive(DUE_DATE_STRING, "due_date"): cv.string, - vol.Optional(DUE_DATE_LANG): vol.All(cv.string, vol.In(DUE_DATE_VALID_LANGS)), - vol.Exclusive(DUE_DATE, "due_date"): cv.string, - vol.Exclusive(REMINDER_DATE_STRING, "reminder_date"): cv.string, - vol.Optional(REMINDER_DATE_LANG): vol.All( - cv.string, vol.In(DUE_DATE_VALID_LANGS) - ), - vol.Exclusive(REMINDER_DATE, "reminder_date"): cv.string, - } -) PLATFORM_SCHEMA = CALENDAR_PLATFORM_SCHEMA.extend( { @@ -127,7 +94,6 @@ async def async_setup_entry( entities.append(TodoistProjectEntity(coordinator, project_data, labels)) async_add_entities(entities) - async_register_services(hass, coordinator) async def async_setup_platform( @@ -146,6 +112,10 @@ async def async_setup_platform( coordinator = TodoistCoordinator(hass, _LOGGER, None, SCAN_INTERVAL, api, token) await coordinator.async_refresh() + # The YAML platform has no config entry, so expose the coordinator for the + # new_task service to reach it. + hass.data.setdefault(DOMAIN, []).append(coordinator) + async def _shutdown_coordinator(_: Event) -> None: await coordinator.async_shutdown() @@ -205,157 +175,6 @@ async def _shutdown_coordinator(_: Event) -> None: async_add_entities(project_devices, update_before_add=True) - async_register_services(hass, coordinator) - - -def async_register_services( # noqa: C901 - hass: HomeAssistant, coordinator: TodoistCoordinator -) -> None: - """Register services.""" - - if hass.services.has_service(DOMAIN, SERVICE_NEW_TASK): - return - - session = async_get_clientsession(hass) - - async def handle_new_task(call: ServiceCall) -> None: - """Call when a user creates a new Todoist Task from Home Assistant.""" - project_name = call.data[PROJECT_NAME] - projects = await coordinator.async_get_projects() - project_id: str | None = None - for project in projects: - if project_name == project.name.lower(): - project_id = project.id - break - if project_id is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="project_invalid", - translation_placeholders={ - "project": project_name, - }, - ) - - # Optional section within project - section_id: str | None = None - if SECTION_NAME in call.data: - section_name = call.data[SECTION_NAME] - sections = await coordinator.async_get_sections(project_id) - for section in sections: - if section_name == section.name.lower(): - section_id = section.id - break - if section_id is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="section_invalid", - translation_placeholders={ - "section": section_name, - "project": project_name, - }, - ) - - # Create the task - content = call.data[CONTENT] - data: dict[str, Any] = {"project_id": project_id} - - if description := call.data.get(DESCRIPTION): - data["description"] = description - - if section_id is not None: - data["section_id"] = section_id - - if task_labels := call.data.get(LABELS): - data["labels"] = task_labels - - if ASSIGNEE in call.data: - collaborators_result = await coordinator.api.get_collaborators(project_id) - all_collaborators = await flatten_async_pages(collaborators_result) - collaborator_id_lookup = { - collab.name.lower(): collab.id for collab in all_collaborators - } - task_assignee = call.data[ASSIGNEE].lower() - if task_assignee in collaborator_id_lookup: - data["assignee_id"] = collaborator_id_lookup[task_assignee] - else: - raise ValueError( - f"User is not part of the shared project. user: {task_assignee}" - ) - - if PRIORITY in call.data: - data["priority"] = call.data[PRIORITY] - - if DUE_DATE_STRING in call.data: - data["due_string"] = call.data[DUE_DATE_STRING] - - if DUE_DATE_LANG in call.data: - data["due_lang"] = call.data[DUE_DATE_LANG] - - if DUE_DATE in call.data: - due_date = dt_util.parse_datetime(call.data[DUE_DATE]) - if due_date is None: - due = dt_util.parse_date(call.data[DUE_DATE]) - if due is None: - raise ValueError(f"Invalid due_date: {call.data[DUE_DATE]}") - due_date = datetime(due.year, due.month, due.day) - # Pass the datetime object directly - the library handles formatting - data["due_datetime"] = dt_util.as_utc(due_date) - - api_task = await coordinator.api.add_task(content, **data) - - # The REST API doesn't support reminders, so we use the Sync API directly - # to maintain functional parity with the component. - # https://developer.todoist.com/api/v1/#tag/Sync/Reminders/Add-a-reminder - _reminder_due: dict = {} - if REMINDER_DATE_STRING in call.data: - _reminder_due["string"] = call.data[REMINDER_DATE_STRING] - - if REMINDER_DATE_LANG in call.data: - _reminder_due["lang"] = call.data[REMINDER_DATE_LANG] - - if REMINDER_DATE in call.data: - reminder_date = dt_util.parse_datetime(call.data[REMINDER_DATE]) - if reminder_date is None: - reminder = dt_util.parse_date(call.data[REMINDER_DATE]) - if reminder is None: - raise ValueError( - f"Invalid reminder_date: {call.data[REMINDER_DATE]}" - ) - reminder_date = datetime(reminder.year, reminder.month, reminder.day) - # Format it in the manner Todoist expects (UTC with Z suffix) - reminder_date = dt_util.as_utc(reminder_date) - date_format = "%Y-%m-%dT%H:%M:%S.000000Z" - _reminder_due["date"] = datetime.strftime(reminder_date, date_format) - - if _reminder_due: - sync_url = "https://api.todoist.com/api/v1/sync" - reminder_data = { - "commands": [ - { - "type": "reminder_add", - "temp_id": str(uuid.uuid1()), - "uuid": str(uuid.uuid1()), - "args": { - "item_id": api_task.id, - "type": "absolute", - "due": _reminder_due, - }, - } - ] - } - headers = { - "Authorization": f"Bearer {coordinator.token}", - "Content-Type": "application/json", - } - await session.post(sync_url, headers=headers, json=reminder_data) - - _LOGGER.debug("Created Todoist task: %s", call.data[CONTENT]) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, SERVICE_NEW_TASK, handle_new_task, schema=NEW_TASK_SERVICE_SCHEMA - ) - class TodoistProjectEntity(CoordinatorEntity[TodoistCoordinator], CalendarEntity): """A device for getting the next Task from a Todoist Project.""" diff --git a/homeassistant/components/todoist/services.py b/homeassistant/components/todoist/services.py new file mode 100644 index 00000000000000..46a8585311de7a --- /dev/null +++ b/homeassistant/components/todoist/services.py @@ -0,0 +1,214 @@ +"""Services for Todoist.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern + +from datetime import datetime +import logging +from typing import Any +import uuid + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv, service +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util import dt as dt_util + +from .const import ( + ASSIGNEE, + CONTENT, + DESCRIPTION, + DOMAIN, + DUE_DATE, + DUE_DATE_LANG, + DUE_DATE_STRING, + DUE_DATE_VALID_LANGS, + LABELS, + PRIORITY, + PROJECT_NAME, + REMINDER_DATE, + REMINDER_DATE_LANG, + REMINDER_DATE_STRING, + SECTION_NAME, + SERVICE_NEW_TASK, +) +from .coordinator import TodoistConfigEntry, TodoistCoordinator, flatten_async_pages + +_LOGGER = logging.getLogger(__name__) + +NEW_TASK_SERVICE_SCHEMA = vol.Schema( + { + vol.Required(CONTENT): cv.string, + vol.Optional(DESCRIPTION): cv.string, + vol.Optional(PROJECT_NAME, default="inbox"): vol.All(cv.string, vol.Lower), + vol.Optional(SECTION_NAME): vol.All(cv.string, vol.Lower), + vol.Optional(LABELS): cv.ensure_list_csv, + vol.Optional(ASSIGNEE): cv.string, + vol.Optional(PRIORITY): vol.All(vol.Coerce(int), vol.Range(min=1, max=4)), + vol.Exclusive(DUE_DATE_STRING, "due_date"): cv.string, + vol.Optional(DUE_DATE_LANG): vol.All(cv.string, vol.In(DUE_DATE_VALID_LANGS)), + vol.Exclusive(DUE_DATE, "due_date"): cv.string, + vol.Exclusive(REMINDER_DATE_STRING, "reminder_date"): cv.string, + vol.Optional(REMINDER_DATE_LANG): vol.All( + cv.string, vol.In(DUE_DATE_VALID_LANGS) + ), + vol.Exclusive(REMINDER_DATE, "reminder_date"): cv.string, + } +) + + +def _async_get_coordinator(hass: HomeAssistant) -> TodoistCoordinator: + """Return a coordinator to service the request. + + Coordinators created by the legacy YAML calendar platform have no config + entry, so they are stored in hass.data and take precedence. Otherwise fall + back to the single loaded config entry. + """ + if coordinators := hass.data.get(DOMAIN): + return coordinators[0] + entry: TodoistConfigEntry = service.async_get_config_entry(hass, DOMAIN, None) + return entry.runtime_data + + +async def handle_new_task(call: ServiceCall) -> None: + """Call when a user creates a new Todoist Task from Home Assistant.""" + coordinator = _async_get_coordinator(call.hass) + project_name = call.data[PROJECT_NAME] + projects = await coordinator.async_get_projects() + project_id: str | None = None + for project in projects: + if project_name == project.name.lower(): + project_id = project.id + break + if project_id is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="project_invalid", + translation_placeholders={ + "project": project_name, + }, + ) + + # Optional section within project + section_id: str | None = None + if SECTION_NAME in call.data: + section_name = call.data[SECTION_NAME] + sections = await coordinator.async_get_sections(project_id) + for section in sections: + if section_name == section.name.lower(): + section_id = section.id + break + if section_id is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="section_invalid", + translation_placeholders={ + "section": section_name, + "project": project_name, + }, + ) + + # Create the task + content = call.data[CONTENT] + data: dict[str, Any] = {"project_id": project_id} + + if description := call.data.get(DESCRIPTION): + data["description"] = description + + if section_id is not None: + data["section_id"] = section_id + + if task_labels := call.data.get(LABELS): + data["labels"] = task_labels + + if ASSIGNEE in call.data: + collaborators_result = await coordinator.api.get_collaborators(project_id) + all_collaborators = await flatten_async_pages(collaborators_result) + collaborator_id_lookup = { + collab.name.lower(): collab.id for collab in all_collaborators + } + task_assignee = call.data[ASSIGNEE].lower() + if task_assignee in collaborator_id_lookup: + data["assignee_id"] = collaborator_id_lookup[task_assignee] + else: + raise ValueError( + f"User is not part of the shared project. user: {task_assignee}" + ) + + if PRIORITY in call.data: + data["priority"] = call.data[PRIORITY] + + if DUE_DATE_STRING in call.data: + data["due_string"] = call.data[DUE_DATE_STRING] + + if DUE_DATE_LANG in call.data: + data["due_lang"] = call.data[DUE_DATE_LANG] + + if DUE_DATE in call.data: + due_date = dt_util.parse_datetime(call.data[DUE_DATE]) + if due_date is None: + due = dt_util.parse_date(call.data[DUE_DATE]) + if due is None: + raise ValueError(f"Invalid due_date: {call.data[DUE_DATE]}") + due_date = datetime(due.year, due.month, due.day) + # Pass the datetime object directly - the library handles formatting + data["due_datetime"] = dt_util.as_utc(due_date) + + api_task = await coordinator.api.add_task(content, **data) + + # The REST API doesn't support reminders, so we use the Sync API directly + # to maintain functional parity with the component. + # https://developer.todoist.com/api/v1/#tag/Sync/Reminders/Add-a-reminder + _reminder_due: dict = {} + if REMINDER_DATE_STRING in call.data: + _reminder_due["string"] = call.data[REMINDER_DATE_STRING] + + if REMINDER_DATE_LANG in call.data: + _reminder_due["lang"] = call.data[REMINDER_DATE_LANG] + + if REMINDER_DATE in call.data: + reminder_date = dt_util.parse_datetime(call.data[REMINDER_DATE]) + if reminder_date is None: + reminder = dt_util.parse_date(call.data[REMINDER_DATE]) + if reminder is None: + raise ValueError(f"Invalid reminder_date: {call.data[REMINDER_DATE]}") + reminder_date = datetime(reminder.year, reminder.month, reminder.day) + # Format it in the manner Todoist expects (UTC with Z suffix) + reminder_date = dt_util.as_utc(reminder_date) + date_format = "%Y-%m-%dT%H:%M:%S.000000Z" + _reminder_due["date"] = datetime.strftime(reminder_date, date_format) + + if _reminder_due: + sync_url = "https://api.todoist.com/api/v1/sync" + reminder_data = { + "commands": [ + { + "type": "reminder_add", + "temp_id": str(uuid.uuid1()), + "uuid": str(uuid.uuid1()), + "args": { + "item_id": api_task.id, + "type": "absolute", + "due": _reminder_due, + }, + } + ] + } + headers = { + "Authorization": f"Bearer {coordinator.token}", + "Content-Type": "application/json", + } + await async_get_clientsession(call.hass).post( + sync_url, headers=headers, json=reminder_data + ) + + _LOGGER.debug("Created Todoist task: %s", call.data[CONTENT]) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register services.""" + + hass.services.async_register( + DOMAIN, SERVICE_NEW_TASK, handle_new_task, schema=NEW_TASK_SERVICE_SCHEMA + ) diff --git a/homeassistant/components/togrill/number.py b/homeassistant/components/togrill/number.py index 3e80ba7b3ce464..0162c93d164b91 100644 --- a/homeassistant/components/togrill/number.py +++ b/homeassistant/components/togrill/number.py @@ -69,7 +69,7 @@ def _get_description( def _get_temperatures( coordinator: ToGrillCoordinator, alarm_type: AlarmType - ) -> tuple[None | float, None | float]: + ) -> tuple[float | None, float | None]: if not (packet := coordinator.get_packet(PacketA8Notify, probe_number)): return None, None diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 2bfe23b62ee1ec..4b56a3699359d2 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -380,7 +380,7 @@ def _description_for_feature[_D: EntityDescription]( # HA logic is to name entities based on the following logic: # _attr_name > translation.name > description.name # > device_class (if base platform supports). - name: str | None | UndefinedType = UNDEFINED + name: str | UndefinedType | None = UNDEFINED # The state feature gets the device name or the child device # name if it's a child device diff --git a/homeassistant/components/tuya/diagnostics.py b/homeassistant/components/tuya/diagnostics.py index 425b6a10dca466..7de6e05d5164ff 100644 --- a/homeassistant/components/tuya/diagnostics.py +++ b/homeassistant/components/tuya/diagnostics.py @@ -58,11 +58,11 @@ def _async_get_diagnostics( if device: tuya_device_id = next(iter(device.identifiers))[1] - data |= _async_device_as_dict(hass, manager.device_map[tuya_device_id]) + data |= _async_device_as_dict(hass, entry, manager.device_map[tuya_device_id]) else: data.update( devices=[ - _async_device_as_dict(hass, device) + _async_device_as_dict(hass, entry, device) for device in manager.device_map.values() ] ) @@ -72,7 +72,7 @@ def _async_get_diagnostics( @callback def _async_device_as_dict( - hass: HomeAssistant, device: CustomerDevice + hass: HomeAssistant, entry: TuyaConfigEntry, device: CustomerDevice ) -> dict[str, Any]: """Represent a Tuya device as a dictionary.""" @@ -87,7 +87,9 @@ def _async_device_as_dict( # Gather information how this Tuya device is represented in Home Assistant device_registry = dr.async_get(hass) entity_registry = er.async_get(hass) - hass_device = device_registry.async_get_device(identifiers={(DOMAIN, device.id)}) + hass_device = device_registry.async_get_device_by_identifier( + (DOMAIN, device.id), entry.entry_id + ) if hass_device: data["home_assistant"] = { "name": hass_device.name, diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 9b66204c64ea64..7e63426442375d 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -2,17 +2,15 @@ from collections.abc import Sequence import dataclasses +import operator from typing import cast, override from uiprotect.data import ( NVR, - Camera, - Event, ModelType, MountType, ProtectAdoptableDeviceModel, Sensor, - SmartDetectObjectType, ) from uiprotect.data.nvr import UOSDisk from uiprotect.data.public_devices import ( @@ -296,6 +294,127 @@ class ProtectBinaryEventEntityDescription( ufp_value="is_person_tracking_enabled", ufp_perm=PermRequired.NO_WRITE, ), + # Sustained state via the public devices WS (uiprotect pushes a camera + # update on each detection transition). + ProtectBinaryEntityDescription( + key="motion", + device_class=BinarySensorDeviceClass.MOTION, + ufp_public_value="is_motion_detected", + ufp_event_driven=True, + ), + ProtectBinaryEntityDescription( + key="smart_obj_any", + translation_key="object_detected", + ufp_required_field="feature_flags.has_smart_detect", + ufp_public_value="is_smart_currently_detected", + ufp_event_driven=True, + entity_registry_enabled_default=False, + ), + ProtectBinaryEntityDescription( + key="smart_obj_person", + translation_key="person_detected", + ufp_required_field="can_detect_person", + ufp_public_value="is_person_currently_detected", + ufp_event_driven=True, + ufp_public_enabled_fn=operator.attrgetter("is_person_detection_on"), + ), + ProtectBinaryEntityDescription( + key="smart_obj_vehicle", + translation_key="vehicle_detected", + ufp_required_field="can_detect_vehicle", + ufp_public_value="is_vehicle_currently_detected", + ufp_event_driven=True, + ufp_public_enabled_fn=operator.attrgetter("is_vehicle_detection_on"), + ), + ProtectBinaryEntityDescription( + key="smart_obj_animal", + translation_key="animal_detected", + ufp_required_field="can_detect_animal", + ufp_public_value="is_animal_currently_detected", + ufp_event_driven=True, + ufp_public_enabled_fn=operator.attrgetter("is_animal_detection_on"), + ), + ProtectBinaryEntityDescription( + key="smart_audio_any", + translation_key="audio_object_detected", + ufp_required_field="feature_flags.smart_detect_audio_types", + ufp_public_value="is_audio_currently_detected", + ufp_event_driven=True, + entity_registry_enabled_default=False, + ), + ProtectBinaryEntityDescription( + key="smart_audio_smoke", + translation_key="smoke_alarm_detected", + ufp_required_field="can_detect_smoke", + ufp_public_value="is_smoke_currently_detected", + ufp_event_driven=True, + ufp_public_enabled_fn=operator.attrgetter("is_smoke_detection_on"), + ), + ProtectBinaryEntityDescription( + key="smart_audio_cmonx", + translation_key="co_alarm_detected", + device_class=BinarySensorDeviceClass.CO, + ufp_required_field="can_detect_co", + ufp_public_value="is_cmonx_currently_detected", + ufp_event_driven=True, + ufp_public_enabled_fn=operator.attrgetter("is_co_detection_on"), + ), + ProtectBinaryEntityDescription( + key="smart_audio_siren", + translation_key="siren_detected", + ufp_required_field="can_detect_siren", + ufp_public_value="is_siren_currently_detected", + ufp_event_driven=True, + ufp_public_enabled_fn=operator.attrgetter("is_siren_detection_on"), + ), + ProtectBinaryEntityDescription( + key="smart_audio_baby_cry", + translation_key="baby_cry_detected", + ufp_required_field="can_detect_baby_cry", + ufp_public_value="is_baby_cry_currently_detected", + ufp_event_driven=True, + ufp_public_enabled_fn=operator.attrgetter("is_baby_cry_detection_on"), + ), + ProtectBinaryEntityDescription( + key="smart_audio_speak", + translation_key="speaking_detected", + ufp_required_field="can_detect_speaking", + ufp_public_value="is_speaking_currently_detected", + ufp_event_driven=True, + ufp_public_enabled_fn=operator.attrgetter("is_speaking_detection_on"), + ), + ProtectBinaryEntityDescription( + key="smart_audio_bark", + translation_key="barking_detected", + ufp_required_field="can_detect_bark", + ufp_public_value="is_bark_currently_detected", + ufp_event_driven=True, + ufp_public_enabled_fn=operator.attrgetter("is_bark_detection_on"), + ), + ProtectBinaryEntityDescription( + key="smart_audio_car_alarm", + translation_key="car_alarm_detected", + ufp_required_field="can_detect_car_alarm", + ufp_public_value="is_car_alarm_currently_detected", + ufp_event_driven=True, + ufp_public_enabled_fn=operator.attrgetter("is_car_alarm_detection_on"), + ), + ProtectBinaryEntityDescription( + key="smart_audio_car_horn", + translation_key="car_horn_detected", + ufp_required_field="can_detect_car_horn", + ufp_public_value="is_car_horn_currently_detected", + ufp_event_driven=True, + ufp_public_enabled_fn=operator.attrgetter("is_car_horn_detection_on"), + ), + ProtectBinaryEntityDescription( + key="smart_audio_glass_break", + translation_key="glass_break_detected", + ufp_required_field="can_detect_glass_break", + ufp_public_value="is_glass_break_currently_detected", + ufp_event_driven=True, + ufp_public_enabled_fn=operator.attrgetter("is_glass_break_detection_on"), + ), ) LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( @@ -418,6 +537,8 @@ class ProtectBinaryEventEntityDescription( ), ) +# Doorbell ring is momentary (no sustained public state), so it stays on the +# private event path. EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ProtectBinaryEventEntityDescription( key="doorbell", @@ -426,125 +547,6 @@ class ProtectBinaryEventEntityDescription( ufp_required_field="feature_flags.is_doorbell", ufp_event_obj="last_ring_event", ), - ProtectBinaryEventEntityDescription( - key="motion", - device_class=BinarySensorDeviceClass.MOTION, - ufp_enabled="is_motion_detection_on", - ufp_event_obj="last_motion_event", - ), - ProtectBinaryEventEntityDescription( - key="smart_obj_any", - translation_key="object_detected", - ufp_required_field="feature_flags.has_smart_detect", - ufp_event_obj="last_smart_detect_event", - entity_registry_enabled_default=False, - ), - ProtectBinaryEventEntityDescription( - key="smart_obj_person", - translation_key="person_detected", - ufp_obj_type=SmartDetectObjectType.PERSON, - ufp_required_field="can_detect_person", - ufp_enabled="is_person_detection_on", - ufp_event_obj="last_person_detect_event", - ), - ProtectBinaryEventEntityDescription( - key="smart_obj_vehicle", - translation_key="vehicle_detected", - ufp_obj_type=SmartDetectObjectType.VEHICLE, - ufp_required_field="can_detect_vehicle", - ufp_enabled="is_vehicle_detection_on", - ufp_event_obj="last_vehicle_detect_event", - ), - ProtectBinaryEventEntityDescription( - key="smart_obj_animal", - translation_key="animal_detected", - ufp_obj_type=SmartDetectObjectType.ANIMAL, - ufp_required_field="can_detect_animal", - ufp_enabled="is_animal_detection_on", - ufp_event_obj="last_animal_detect_event", - ), - # Package detection is a momentary smart-detect event, not a sustained state: - # it is the package event entity (event.py), not a binary sensor. - ProtectBinaryEventEntityDescription( - key="smart_audio_any", - translation_key="audio_object_detected", - ufp_required_field="feature_flags.has_smart_detect", - ufp_event_obj="last_smart_audio_detect_event", - entity_registry_enabled_default=False, - ), - ProtectBinaryEventEntityDescription( - key="smart_audio_smoke", - translation_key="smoke_alarm_detected", - ufp_obj_type=SmartDetectObjectType.SMOKE, - ufp_required_field="can_detect_smoke", - ufp_enabled="is_smoke_detection_on", - ufp_event_obj="last_smoke_detect_event", - ), - ProtectBinaryEventEntityDescription( - key="smart_audio_cmonx", - translation_key="co_alarm_detected", - device_class=BinarySensorDeviceClass.CO, - ufp_required_field="can_detect_co", - ufp_enabled="is_co_detection_on", - ufp_event_obj="last_cmonx_detect_event", - ufp_obj_type=SmartDetectObjectType.CMONX, - ), - ProtectBinaryEventEntityDescription( - key="smart_audio_siren", - translation_key="siren_detected", - ufp_obj_type=SmartDetectObjectType.SIREN, - ufp_required_field="can_detect_siren", - ufp_enabled="is_siren_detection_on", - ufp_event_obj="last_siren_detect_event", - ), - ProtectBinaryEventEntityDescription( - key="smart_audio_baby_cry", - translation_key="baby_cry_detected", - ufp_obj_type=SmartDetectObjectType.BABY_CRY, - ufp_required_field="can_detect_baby_cry", - ufp_enabled="is_baby_cry_detection_on", - ufp_event_obj="last_baby_cry_detect_event", - ), - ProtectBinaryEventEntityDescription( - key="smart_audio_speak", - translation_key="speaking_detected", - ufp_obj_type=SmartDetectObjectType.SPEAK, - ufp_required_field="can_detect_speaking", - ufp_enabled="is_speaking_detection_on", - ufp_event_obj="last_speaking_detect_event", - ), - ProtectBinaryEventEntityDescription( - key="smart_audio_bark", - translation_key="barking_detected", - ufp_obj_type=SmartDetectObjectType.BARK, - ufp_required_field="can_detect_bark", - ufp_enabled="is_bark_detection_on", - ufp_event_obj="last_bark_detect_event", - ), - ProtectBinaryEventEntityDescription( - key="smart_audio_car_alarm", - translation_key="car_alarm_detected", - ufp_obj_type=SmartDetectObjectType.BURGLAR, - ufp_required_field="can_detect_car_alarm", - ufp_enabled="is_car_alarm_detection_on", - ufp_event_obj="last_car_alarm_detect_event", - ), - ProtectBinaryEventEntityDescription( - key="smart_audio_car_horn", - translation_key="car_horn_detected", - ufp_obj_type=SmartDetectObjectType.CAR_HORN, - ufp_required_field="can_detect_car_horn", - ufp_enabled="is_car_horn_detection_on", - ufp_event_obj="last_car_horn_detect_event", - ), - ProtectBinaryEventEntityDescription( - key="smart_audio_glass_break", - translation_key="glass_break_detected", - ufp_obj_type=SmartDetectObjectType.GLASS_BREAK, - ufp_required_field="can_detect_glass_break", - ufp_enabled="is_glass_break_detection_on", - ufp_event_obj="last_glass_break_detect_event", - ), ) VIEWER_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( @@ -665,31 +667,6 @@ def _set_event_done(self) -> None: self._attr_is_on = False self._attr_extra_state_attributes = {} - @callback - def _find_active_event_with_object_type( - self, device: ProtectDeviceType - ) -> Event | None: - """Find an active event containing this sensor's object type. - - Fallback for issue #152133: last_smart_detect_event_ids may not update - immediately when a new detection type is added to an ongoing event. - """ - obj_type = self.entity_description.ufp_obj_type - if obj_type is None or not isinstance(device, Camera): - return None - - # Check known active event IDs from camera first (fast path) - for event_id in device.last_smart_detect_event_ids.values(): - if ( - event_id - and (event := self.data.api.bootstrap.events.get(event_id)) - and event.end is None - and obj_type in event.smart_detect_types - ): - return event - - return None - @callback @override def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: @@ -700,22 +677,11 @@ def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: super()._async_update_device_from_protect(device) event = description.get_event_obj(device) - if event is None: - # Fallback for #152133: check active events directly - event = self._find_active_event_with_object_type(device) - if event: self._event = event self._event_end = event.end - if not ( - event - and ( - description.ufp_obj_type is None - or description.has_matching_smart(event) - ) - and not self._event_already_ended(prev_event, prev_event_end) - ): + if not (event and not self._event_already_ended(prev_event, prev_event_end)): self._set_event_done() return diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index c735bf00963d39..45bde637256f61 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -186,6 +186,15 @@ def _async_camera_entities( or (camera is not None and camera.is_third_party_camera) ): ir.async_delete_issue(hass, DOMAIN, issue_id) + elif streams is None: + # None means the best-effort read failed, not that streams are absent. + _LOGGER.warning( + ( + "Could not read RTSPS streams for camera %s;" + " live streaming stays disabled until streams can be read again" + ), + public.display_name, + ) else: _create_rtsp_repair(hass, entry, public) return entities diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index 717f960ca2f927..d71e75493c1a4d 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -13,6 +13,7 @@ ATTR_EVENT_SCORE = "event_score" ATTR_EVENT_ID = "event_id" +ATTR_SMART_DETECT_TYPES = "smart_detect_types" ATTR_WIDTH = "width" ATTR_HEIGHT = "height" ATTR_FPS = "fps" diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 7c68fd4b28090e..182f50c8e505a2 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -98,6 +98,7 @@ def __init__( self.auth_retries = 0 self.last_update_success = False self.last_public_update_success = False + self.last_events_update_success = False self.api = protect self.adopt_signal = _async_dispatch_id(entry, DISPATCH_ADOPT) self.add_signal = _async_dispatch_id(entry, DISPATCH_ADD) @@ -212,6 +213,7 @@ def async_setup(self) -> None: """Subscribe and do the refresh.""" self.last_update_success = True self.last_public_update_success = True + self.last_events_update_success = True self._async_update_change(True, force_update=True) api = self.api self._unsubs = [ @@ -227,6 +229,7 @@ def async_setup(self) -> None: self._async_process_public_devices_ws_message ), api.subscribe_devices_websocket_state(self._async_public_ws_state_changed), + api.subscribe_events_websocket_state(self._async_events_ws_state_changed), ] @callback @@ -310,14 +313,14 @@ def _async_process_public_event( ) -> None: """Dispatch a public events websocket event to its subscribers. - Only the start of an event is dispatched, routed to the subscribers that - registered for this device and event type; an entity that cares about a - sub-type (e.g. a smart-detect object type) filters further itself. - Subscriptions are keyed by ``device_id`` (the stable cross-API join key, - shared by the private and public bootstraps), so the event routes - directly without a bootstrap lookup. + Each non-eviction change is dispatched — a detection type may surface at + the event start, on a later update, or only as it ends — routed to the + subscribers registered for this device and event type; entities fire each + ``(event, type)`` once. Subscriptions are keyed by ``device_id`` (the + stable cross-API join key, shared by the private and public bootstraps), + so the event routes directly without a bootstrap lookup. """ - if change is not EventChange.STARTED: + if change is EventChange.REMOVED: return if not ( subscriptions := self._public_event_subscriptions.get( @@ -373,6 +376,21 @@ async def _async_resignal_after_public_resync(self) -> None: for public in list(self.api.public_bootstrap.cameras.values()): async_dispatcher_send(self._hass, self.channels_signal, public) + @callback + def _async_events_ws_state_changed(self, state: WebsocketState) -> None: + """Handle a change in the public events websocket state. + + Entities whose values are derived from the events stream (the + detection booleans and the public event entities) include this in + their availability, since the devices websocket alone cannot tell + whether detections still flow. + """ + success = state is WebsocketState.CONNECTED + if success == self.last_events_update_success: + return + self.last_events_update_success = success + self._async_process_public_updates() + @callback def _async_process_public_updates(self) -> None: """Re-signal public-API entities after a public websocket state change.""" @@ -470,14 +488,12 @@ async def _async_adopt_ptz_camera(self, camera: Camera) -> None: @callback def _async_remove_device(self, device: ProtectAdoptableDeviceModel) -> None: registry = dr.async_get(self._hass) - device_entry = registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, device.mac)} + device_entry = registry.async_get_device_by_connection( + (dr.CONNECTION_NETWORK_MAC, device.mac), self._entry.entry_id ) if device_entry: _LOGGER.debug("Device removed: %s", device.id) - registry.async_update_device( - device_entry.id, remove_config_entry_id=self._entry.entry_id - ) + registry.async_remove_device(device_entry.id) @callback def _async_update_device( diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 06b07cf51bf134..9508de8a3b1d1d 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -31,6 +31,7 @@ from .const import ( ATTR_EVENT_ID, ATTR_EVENT_SCORE, + ATTR_SMART_DETECT_TYPES, DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN, @@ -232,6 +233,9 @@ class BaseProtectEntity(Entity): # (set ``ufp_public_value``); ``None`` until primed/refreshed. _ufp_public_obj: PublicDeviceModel | None = None _ufp_uses_public: bool = False + # Values derived from the public events websocket (detection booleans, + # public event entities) additionally require that websocket to be healthy. + _ufp_requires_events_ws: bool = False def __init__( self, @@ -281,12 +285,19 @@ def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: # Migrated entities are fully public: availability tracks the public # websocket health and the public object's state (CONNECTED only; # CONNECTING/DISCONNECTED/UNKNOWN and a missing object read as - # unavailable), independent of the private connection. An optional - # ``ufp_public_enabled_fn`` gate then mirrors ``ufp_enabled`` against - # the public object (e.g. a sensor feature toggled off). + # unavailable), independent of the private connection. Values fed by + # the events websocket also require it to be healthy — the devices + # websocket keeps the device state fresh, but only the events stream + # carries the detections. An optional ``ufp_public_enabled_fn`` gate + # then mirrors ``ufp_enabled`` against the public object (e.g. a + # sensor feature toggled off). public_obj = self._ufp_public_obj if ( self.data.last_public_update_success + and ( + not self._ufp_requires_events_ws + or self.data.last_events_update_success + ) and public_obj is not None and public_obj.state is DeviceState.CONNECTED ): @@ -355,11 +366,18 @@ async def async_added_to_hass(self) -> None: ) # Not every entity carries an entity_description (e.g. cameras), so getattr. description = getattr(self, "entity_description", None) - if isinstance(description, ProtectEntityDescription) and ( - description.ufp_public_value is not None - or description.ufp_public_value_fn is not None - ): - self._ufp_uses_public = True + if isinstance(description, ProtectEntityDescription): + if ( + description.ufp_public_value is not None + or description.ufp_public_value_fn is not None + ): + self._ufp_uses_public = True + if description.ufp_event_driven: + self._ufp_requires_events_ws = True + # ``_ufp_uses_public`` may also be declared as a class attribute by + # entities driven by the public API without a migrated value (the + # public event entities). + if self._ufp_uses_public: self._ufp_public_obj = self.data.async_get_public_device(self.device) self.async_on_remove( self.data.async_subscribe_public( @@ -429,7 +447,9 @@ class EventEntityMixin(ProtectDeviceEntity): """Adds motion event attributes to sensor.""" entity_description: ProtectEventMixin - _unrecorded_attributes = frozenset({ATTR_EVENT_ID, ATTR_EVENT_SCORE}) + _unrecorded_attributes = frozenset( + {ATTR_EVENT_ID, ATTR_EVENT_SCORE, ATTR_SMART_DETECT_TYPES} + ) _event: Event | None = None _event_end: datetime | None = None @@ -484,6 +504,9 @@ class ProtectEntityDescription(EntityDescription, Generic[T]): # noqa: UP046 ufp_public_value: str | None = None # Callable variant of ``ufp_public_value`` for public values needing a transform. ufp_public_value_fn: Callable[[PublicDeviceModel], Any] | None = None + # True when the public value is derived from the events websocket (the + # detection booleans); availability then also tracks that websocket. + ufp_event_driven: bool = False ufp_enabled: str | None = None # Public counterpart of ``ufp_enabled``; a callable because public enablement # is often compound (e.g. mount type plus a settings flag). diff --git a/homeassistant/components/unifiprotect/event.py b/homeassistant/components/unifiprotect/event.py index 4875c4b9697f7d..338779649e5843 100644 --- a/homeassistant/components/unifiprotect/event.py +++ b/homeassistant/components/unifiprotect/event.py @@ -1,6 +1,7 @@ """Platform providing event entities for UniFi Protect.""" import dataclasses +import re from typing import Any, override from uiprotect import ProtectEvent @@ -20,6 +21,7 @@ from . import Bootstrap from .const import ( ATTR_EVENT_ID, + ATTR_SMART_DETECT_TYPES, EVENT_TYPE_FINGERPRINT_IDENTIFIED, EVENT_TYPE_FINGERPRINT_NOT_IDENTIFIED, EVENT_TYPE_NFC_SCANNED, @@ -42,6 +44,10 @@ PARALLEL_UPDATES = 0 +# Per-entity cap on tracked event ids for fire dedup (far above realistic +# concurrent/recent events per camera per category). +_MAX_TRACKED_EVENTS = 16 + # Select best thumbnail # Prefer thumbnails with LPR data, sorted by confidence @@ -75,7 +81,69 @@ class ProtectEventEntityDescription(ProtectEventMixin, EventEntityDescription): entity_class: type[ProtectDeviceEntity] -class ProtectDeviceRingEventEntity(EventEntityMixin, ProtectDeviceEntity, EventEntity): +# Protect emits overlapping ``smartDetectZone``, ``smartDetectLine``, and +# ``smartDetectLoiterZone`` frames for the same underlying detection, and a +# line-crossing or loitering detection can arrive as a standalone event of its +# own type — all three carry the same ``smartDetectTypes`` payload per the +# public API schema, so smart-detect entities subscribe to all of them. +_SMART_DETECT_EVENT_TYPES = ( + EventType.SMART_DETECT, + EventType.SMART_DETECT_LINE, + EventType.SMART_DETECT_LOITER, +) + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class ProtectDetectionEventEntityDescription(ProtectEventEntityDescription): + """Describes a category detection event entity driven by the public events WS.""" + + ufp_public_event_types: tuple[EventType, ...] + + +class ProtectDevicePublicEventEntity( + EventEntityMixin, ProtectDeviceEntity, EventEntity +): + """Base for entities driven by the public events WS. + + A detection type can surface at the event start, on a later update, or only + as the event ends, and every non-eviction change is dispatched — so firing is + deduped per ``(event id, event type)``. + + Availability follows the public API (device present and connected) plus the + events websocket, which is the only channel these entities fire from. + """ + + _ufp_uses_public = True + _ufp_requires_events_ws = True + + entity_description: ProtectEventEntityDescription + # A camera can run two overlapping events of the same category whose + # dispatches interleave, so dedup tracks fired types per recent event id + # (bounded), not just the current one. + _fired: dict[str, frozenset[str]] | None = None + + @callback + def _fire_once( + self, event: ProtectEvent, event_type: str, event_data: dict[str, Any] + ) -> None: + """Fire ``event_type`` once per event, ignoring repeat dispatches.""" + fired = self._fired + if fired is None: + fired = self._fired = {} + # Pop-and-reinsert so any dispatch refreshes this event id's recency; a + # long-running event that keeps updating is then not evicted below. + types = fired.pop(event.id, frozenset()) + if event_type in types: + fired[event.id] = types + return + fired[event.id] = types | {event_type} + if len(fired) > _MAX_TRACKED_EVENTS: + del fired[next(iter(fired))] # evict the least-recently-seen event id + self._trigger_event(event_type, event_data) + self.async_write_ha_state() + + +class ProtectDeviceRingEventEntity(ProtectDevicePublicEventEntity): """A UniFi Protect doorbell ring event entity driven by the public events WS.""" entity_description: ProtectEventEntityDescription @@ -92,8 +160,7 @@ async def async_added_to_hass(self) -> None: @callback def _async_ring_event(self, event: ProtectEvent) -> None: - self._trigger_event(DoorbellEventType.RING, {ATTR_EVENT_ID: event.id}) - self.async_write_ha_state() + self._fire_once(event, DoorbellEventType.RING, {ATTR_EVENT_ID: event.id}) class ProtectDeviceNFCEventEntity(EventEntityMixin, ProtectDeviceEntity, EventEntity): @@ -360,9 +427,7 @@ def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: self._async_set_thumbnail_timer() -class ProtectDeviceSmartDetectEventEntity( - EventEntityMixin, ProtectDeviceEntity, EventEntity -): +class ProtectDeviceSmartDetectEventEntity(ProtectDevicePublicEventEntity): """A UniFi Protect smart-detect event entity driven by the public events WS. Used for object types that Protect models as discrete, point-in-time @@ -380,19 +445,102 @@ class ProtectDeviceSmartDetectEventEntity( async def async_added_to_hass(self) -> None: """Subscribe to public smart-detect events for this camera.""" await super().async_added_to_hass() - self.async_on_remove( - self.data.async_subscribe_public_event( - self.device.id, EventType.SMART_DETECT, self._async_smart_detect_event + for event_type in _SMART_DETECT_EVENT_TYPES: + self.async_on_remove( + self.data.async_subscribe_public_event( + self.device.id, event_type, self._async_smart_detect_event + ) ) - ) @callback def _async_smart_detect_event(self, event: ProtectEvent) -> None: description = self.entity_description event_types = description.event_types if event_types and description.ufp_obj_type in event.smart_detect_types: - self._trigger_event(event_types[0], {ATTR_EVENT_ID: event.id}) - self.async_write_ha_state() + self._fire_once(event, event_types[0], {ATTR_EVENT_ID: event.id}) + + +_CAMEL_BOUNDARY = re.compile(r"(? str: + """Stable snake_case event type for a detection (HA translation-key rules).""" + return ( + _EVENT_TYPE_OVERRIDES.get(detected) + or _CAMEL_BOUNDARY.sub("_", detected.value).lower() + ) + + +_SMART_OBJECT_EVENT_TYPES = [ + _event_type(t) for t in SmartDetectObjectType if t.audio_type is None +] +_SMART_AUDIO_EVENT_TYPES = [ + _event_type(t) for t in SmartDetectObjectType if t.audio_type is not None +] + + +class ProtectDeviceDetectionEventEntity(ProtectDevicePublicEventEntity): + """A camera smart-detect category event entity (object or audio), public WS. + + Fires a momentary event for each detected type the entity surfaces. The + ``event_types`` are derived from the uiprotect enum, so a new detection type + is surfaced automatically without code changes (only a state label is added). + The subscribed category comes from ``ufp_public_event_types``; the motion + variant overrides the firing. + """ + + entity_description: ProtectDetectionEventEntityDescription + + @override + async def async_added_to_hass(self) -> None: + """Subscribe to the category's public detection events.""" + await super().async_added_to_hass() + for event_type in self.entity_description.ufp_public_event_types: + self.async_on_remove( + self.data.async_subscribe_public_event( + self.device.id, + event_type, + self._async_detection_event, + ) + ) + + @callback + def _async_detection_event(self, event: ProtectEvent) -> None: + allowed = self.entity_description.event_types or () + # One fire per detected type so each stays independently automatable + # (incl. types with no binary sensor); carries the co-detected set known + # at fire time (types can still arrive on a later update). + detected = [_event_type(t) for t in event.smart_detect_types] + for event_type in detected: + if event_type in allowed: + self._fire_once( + event, + event_type, + {ATTR_EVENT_ID: event.id, ATTR_SMART_DETECT_TYPES: detected}, + ) + + +class ProtectDeviceMotionEventEntity(ProtectDeviceDetectionEventEntity): + """A camera motion-detection event entity (public events WS).""" + + @callback + @override + def _async_detection_event(self, event: ProtectEvent) -> None: + self._fire_once(event, EventType.MOTION.value, {ATTR_EVENT_ID: event.id}) EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = ( @@ -439,6 +587,30 @@ def _async_smart_detect_event(self, event: ProtectEvent) -> None: event_types=[EVENT_TYPE_PACKAGE_DETECTED], entity_class=ProtectDeviceSmartDetectEventEntity, ), + ProtectDetectionEventEntityDescription( + key="motion_detection", + translation_key="motion_detection", + device_class=EventDeviceClass.MOTION, + event_types=[EventType.MOTION.value], + ufp_public_event_types=(EventType.MOTION,), + entity_class=ProtectDeviceMotionEventEntity, + ), + ProtectDetectionEventEntityDescription( + key="smart_detection", + translation_key="smart_detection", + ufp_required_field="feature_flags.has_smart_detect", + event_types=_SMART_OBJECT_EVENT_TYPES, + ufp_public_event_types=_SMART_DETECT_EVENT_TYPES, + entity_class=ProtectDeviceDetectionEventEntity, + ), + ProtectDetectionEventEntityDescription( + key="sound_detection", + translation_key="sound_detection", + ufp_required_field="feature_flags.smart_detect_audio_types", + event_types=_SMART_AUDIO_EVENT_TYPES, + ufp_public_event_types=(EventType.SMART_AUDIO_DETECT,), + entity_class=ProtectDeviceDetectionEventEntity, + ), ) diff --git a/homeassistant/components/unifiprotect/icons.json b/homeassistant/components/unifiprotect/icons.json index 9817415f0a08b5..d027fca002c22d 100644 --- a/homeassistant/components/unifiprotect/icons.json +++ b/homeassistant/components/unifiprotect/icons.json @@ -181,12 +181,21 @@ "fingerprint": { "default": "mdi:fingerprint" }, + "motion_detection": { + "default": "mdi:motion-sensor" + }, "nfc": { "default": "mdi:nfc" }, "package": { "default": "mdi:package-variant-closed" }, + "smart_detection": { + "default": "mdi:cctv" + }, + "sound_detection": { + "default": "mdi:waveform" + }, "vehicle": { "default": "mdi:car" } diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index e5e42b5bb65ef1..e77ca44b1294d7 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -61,18 +61,9 @@ class ProtectLight(ProtectDeviceEntity, LightEntity): _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} _state_attrs = ("_attr_available", "_attr_is_on", "_attr_brightness") - - @override - async def async_added_to_hass(self) -> None: - """Read state from the public API (primed before the first update).""" - self._ufp_uses_public = True - self._ufp_public_obj = self.data.async_get_public_device(self.device) - self.async_on_remove( - self.data.async_subscribe_public( - self.device.mac, self._async_public_updated - ) - ) - await super().async_added_to_hass() + # State comes from the public API; the base class primes the object and + # subscribes to the public devices websocket on this flag. + _ufp_uses_public = True @callback @override diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index eb7b65358f7341..9792cdaac2f047 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -289,6 +289,16 @@ } } }, + "motion_detection": { + "name": "Motion detection", + "state_attributes": { + "event_type": { + "state": { + "motion": "Motion" + } + } + } + }, "nfc": { "name": "NFC", "state_attributes": { @@ -309,6 +319,41 @@ } } }, + "smart_detection": { + "name": "Smart detection", + "state_attributes": { + "event_type": { + "state": { + "animal": "Animal", + "car": "Car", + "face": "Face", + "license_plate": "License plate", + "package": "Package", + "person": "Person", + "pet": "Pet", + "vehicle": "Vehicle" + } + } + } + }, + "sound_detection": { + "name": "Sound detection", + "state_attributes": { + "event_type": { + "state": { + "baby_cry": "Baby cry", + "bark": "Barking", + "car_alarm": "Car alarm", + "car_horn": "Car horn", + "co": "CO alarm", + "glass_break": "Glass break", + "siren": "Siren", + "smoke": "Smoke alarm", + "speaking": "Speaking" + } + } + } + }, "vehicle": { "name": "Vehicle", "state_attributes": { diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index dd52d40aae22b3..1706ce0a0e04b3 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -1200,6 +1200,38 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + ViCareSensorEntityDescription( + key="exhaust_humidity", + translation_key="exhaust_humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=UnitOfRatio.PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_getter=lambda api: api.getExhaustHumidity(), + ), + ViCareSensorEntityDescription( + key="exhaust_temperature", + translation_key="exhaust_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getExhaustTemperature(), + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + ViCareSensorEntityDescription( + key="extract_humidity", + translation_key="extract_humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=UnitOfRatio.PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_getter=lambda api: api.getExtractHumidity(), + ), + ViCareSensorEntityDescription( + key="extract_temperature", + translation_key="extract_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getExtractTemperature(), + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), ViCareSensorEntityDescription( key="filter_hours", translation_key="filter_hours", diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index a971d8d5d6a772..14d6ff881c56b1 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -365,6 +365,18 @@ "evaporator_overheat_temperature": { "name": "Evaporator overheat temperature" }, + "exhaust_humidity": { + "name": "Exhaust humidity" + }, + "exhaust_temperature": { + "name": "Exhaust temperature" + }, + "extract_humidity": { + "name": "Extract humidity" + }, + "extract_temperature": { + "name": "Extract temperature" + }, "filter_hours": { "name": "Filter hours" }, diff --git a/homeassistant/components/victron_gx/hub.py b/homeassistant/components/victron_gx/hub.py index bd780cb5ffec46..ebc81ae652859a 100644 --- a/homeassistant/components/victron_gx/hub.py +++ b/homeassistant/components/victron_gx/hub.py @@ -103,7 +103,7 @@ async def stop(self) -> None: _LOGGER.info("Stopping hub") try: await self._hub.disconnect() - except Exception as err: # noqa: BLE001 + except Exception as err: _LOGGER.warning( "Ignoring error while disconnecting from hub %s during shutdown", self.host, diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index b306f44bd9986d..5e0389d030d130 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -189,9 +189,7 @@ async def _internal_update_data(self) -> SleepSummary | None: if not response: return None - return sorted( - response, key=lambda sleep_summary: sleep_summary.end_date, reverse=True - )[0] + return max(response, key=lambda sleep_summary: sleep_summary.end_date) class WithingsBedPresenceDataUpdateCoordinator(WithingsDataUpdateCoordinator[None]): diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index d58be813923b0e..009c3d9a9eaa99 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -34,7 +34,7 @@ class YouLessSensorEntityDescription(SensorEntityDescription): """Describes a YouLess sensor entity.""" device_group: str - value_func: Callable[[YoulessAPI], float | None | str] + value_func: Callable[[YoulessAPI], float | str | None] SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index dd68e36551fe25..b9d78f64bd5915 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2541,7 +2541,7 @@ def async_update_entry( pref_disable_new_entities: bool | UndefinedType = UNDEFINED, pref_disable_polling: bool | UndefinedType = UNDEFINED, title: str | UndefinedType = UNDEFINED, - unique_id: str | None | UndefinedType = UNDEFINED, + unique_id: str | UndefinedType | None = UNDEFINED, version: int | UndefinedType = UNDEFINED, ) -> bool: """Update a config entry. @@ -2580,7 +2580,7 @@ def _async_update_entry( pref_disable_polling: bool | UndefinedType = UNDEFINED, subentries: dict[str, ConfigSubentry] | UndefinedType = UNDEFINED, title: str | UndefinedType = UNDEFINED, - unique_id: str | None | UndefinedType = UNDEFINED, + unique_id: str | UndefinedType | None = UNDEFINED, version: int | UndefinedType = UNDEFINED, ) -> bool: """Update a config entry. @@ -2707,7 +2707,7 @@ def async_update_subentry( *, data: Mapping[str, Any] | UndefinedType = UNDEFINED, title: str | UndefinedType = UNDEFINED, - unique_id: str | None | UndefinedType = UNDEFINED, + unique_id: str | UndefinedType | None = UNDEFINED, ) -> bool: """Update a config subentry. @@ -3461,7 +3461,7 @@ def __async_update( self, entry: ConfigEntry, *, - unique_id: str | None | UndefinedType, + unique_id: str | UndefinedType | None, title: str | UndefinedType, data: Mapping[str, Any] | UndefinedType, data_updates: Mapping[str, Any] | UndefinedType, @@ -3490,7 +3490,7 @@ def async_update_and_abort( self, entry: ConfigEntry, *, - unique_id: str | None | UndefinedType = UNDEFINED, + unique_id: str | UndefinedType | None = UNDEFINED, title: str | UndefinedType = UNDEFINED, data: Mapping[str, Any] | UndefinedType = UNDEFINED, data_updates: Mapping[str, Any] | UndefinedType = UNDEFINED, @@ -3532,7 +3532,7 @@ def async_update_reload_and_abort( self, entry: ConfigEntry, *, - unique_id: str | None | UndefinedType = UNDEFINED, + unique_id: str | UndefinedType | None = UNDEFINED, title: str | UndefinedType = UNDEFINED, data: Mapping[str, Any] | UndefinedType = UNDEFINED, data_updates: Mapping[str, Any] | UndefinedType = UNDEFINED, @@ -3771,7 +3771,7 @@ def _async_update( entry: ConfigEntry, subentry: ConfigSubentry, *, - unique_id: str | None | UndefinedType = UNDEFINED, + unique_id: str | UndefinedType | None = UNDEFINED, title: str | UndefinedType = UNDEFINED, data: Mapping[str, Any] | UndefinedType = UNDEFINED, data_updates: Mapping[str, Any] | UndefinedType = UNDEFINED, @@ -3800,7 +3800,7 @@ def async_update_and_abort( entry: ConfigEntry, subentry: ConfigSubentry, *, - unique_id: str | None | UndefinedType = UNDEFINED, + unique_id: str | UndefinedType | None = UNDEFINED, title: str | UndefinedType = UNDEFINED, data: Mapping[str, Any] | UndefinedType = UNDEFINED, data_updates: Mapping[str, Any] | UndefinedType = UNDEFINED, @@ -3829,7 +3829,7 @@ def async_update_reload_and_abort( entry: ConfigEntry, subentry: ConfigSubentry, *, - unique_id: str | None | UndefinedType = UNDEFINED, + unique_id: str | UndefinedType | None = UNDEFINED, title: str | UndefinedType = UNDEFINED, data: Mapping[str, Any] | UndefinedType = UNDEFINED, data_updates: Mapping[str, Any] | UndefinedType = UNDEFINED, diff --git a/homeassistant/const.py b/homeassistant/const.py index 346f9a37f58b12..1a1018e176943e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -22,7 +22,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 -MINOR_VERSION: Final = 8 +MINOR_VERSION: Final = 9 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 711c98c76f3a21..1b08bb74e47a6e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3350,7 +3350,7 @@ "name": "IOmeter", "integration_type": "device", "config_flow": true, - "iot_class": "local_polling" + "iot_class": "local_push" }, "ios": { "name": "Home Assistant iOS", diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 8ab868a35b5efd..1835fb6af80f28 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -349,13 +349,13 @@ def async_update( area_id: str, *, aliases: set[str] | UndefinedType = UNDEFINED, - floor_id: str | None | UndefinedType = UNDEFINED, - humidity_entity_id: str | None | UndefinedType = UNDEFINED, - icon: str | None | UndefinedType = UNDEFINED, + floor_id: str | UndefinedType | None = UNDEFINED, + humidity_entity_id: str | UndefinedType | None = UNDEFINED, + icon: str | UndefinedType | None = UNDEFINED, labels: set[str] | UndefinedType = UNDEFINED, name: str | UndefinedType = UNDEFINED, - picture: str | None | UndefinedType = UNDEFINED, - temperature_entity_id: str | None | UndefinedType = UNDEFINED, + picture: str | UndefinedType | None = UNDEFINED, + temperature_entity_id: str | UndefinedType | None = UNDEFINED, ) -> AreaEntry: """Update name of area.""" updated = self._async_update( @@ -385,13 +385,13 @@ def _async_update( area_id: str, *, aliases: set[str] | UndefinedType = UNDEFINED, - floor_id: str | None | UndefinedType = UNDEFINED, - humidity_entity_id: str | None | UndefinedType = UNDEFINED, - icon: str | None | UndefinedType = UNDEFINED, + floor_id: str | UndefinedType | None = UNDEFINED, + humidity_entity_id: str | UndefinedType | None = UNDEFINED, + icon: str | UndefinedType | None = UNDEFINED, labels: set[str] | UndefinedType = UNDEFINED, name: str | UndefinedType = UNDEFINED, - picture: str | None | UndefinedType = UNDEFINED, - temperature_entity_id: str | None | UndefinedType = UNDEFINED, + picture: str | UndefinedType | None = UNDEFINED, + temperature_entity_id: str | UndefinedType | None = UNDEFINED, ) -> AreaEntry: """Update name of area.""" old = self.areas[area_id] diff --git a/homeassistant/helpers/automation.py b/homeassistant/helpers/automation.py index 80c3da754ccab7..4b1199e48a9ce4 100644 --- a/homeassistant/helpers/automation.py +++ b/homeassistant/helpers/automation.py @@ -32,7 +32,7 @@ class DomainSpec: Used by triggers and conditions. """ - device_class: str | None | AnyDeviceClassType = ANY_DEVICE_CLASS + device_class: str | AnyDeviceClassType | None = ANY_DEVICE_CLASS value_source: str | None = None """Attribute name to extract the value from, or None for state.state.""" @@ -143,7 +143,7 @@ class ThresholdConfig: numerical: bool entity: str | None number: float | None - unit: str | None | UndefinedType + unit: str | UndefinedType | None @classmethod def from_config(cls, config: dict[str, Any] | None) -> Self | None: @@ -153,7 +153,7 @@ def from_config(cls, config: dict[str, Any] | None) -> Self | None: entity: str | None = None number: float | None = None - unit: str | None | UndefinedType = UNDEFINED + unit: str | UndefinedType | None = UNDEFINED numerical = "number" in config if numerical: number = config["number"] diff --git a/homeassistant/helpers/category_registry.py b/homeassistant/helpers/category_registry.py index 59e381ec256c69..9b00e5c1f0d1f6 100644 --- a/homeassistant/helpers/category_registry.py +++ b/homeassistant/helpers/category_registry.py @@ -171,7 +171,7 @@ def async_update( *, scope: str, category_id: str, - icon: str | None | UndefinedType = UNDEFINED, + icon: str | UndefinedType | None = UNDEFINED, name: str | UndefinedType = UNDEFINED, ) -> CategoryEntry: """Update name or icon of the category.""" diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 4896cf59b49817..9f843d61e758c9 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -960,7 +960,7 @@ class EntityNumericalConditionBase(EntityConditionBase): """Condition for numerical state comparisons with above/below thresholds.""" _schema = NUMERICAL_CONDITION_SCHEMA - _valid_unit: str | None | UndefinedType = UNDEFINED + _valid_unit: str | UndefinedType | None = UNDEFINED def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: """Initialize the numerical condition.""" @@ -1051,7 +1051,7 @@ def is_valid_state(self, entity_state: State) -> bool: def make_entity_numerical_condition( domain_specs: Mapping[str, DomainSpec] | str, - valid_unit: str | None | UndefinedType = UNDEFINED, + valid_unit: str | UndefinedType | None = UNDEFINED, *, primary_entities_only: bool = True, ) -> type[EntityNumericalConditionBase]: @@ -1727,6 +1727,7 @@ def state( req_state = [req_state] is_state = False + state_value: Any = None for req_state_value in req_state: state_value = req_state_value if ( diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index ecdf7abb834492..65b01ff671630f 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -291,20 +291,20 @@ def _determine_device_info_type( class _ValidatedDeviceInfoFields(TypedDict): """Device info fields validated on create and update.""" - configuration_url: str | URL | None | UndefinedType - hw_version: str | None | UndefinedType - manufacturer: str | None | UndefinedType - model: str | None | UndefinedType - model_id: str | None | UndefinedType - serial_number: str | None | UndefinedType - sw_version: str | None | UndefinedType + configuration_url: str | URL | UndefinedType | None + hw_version: str | UndefinedType | None + manufacturer: str | UndefinedType | None + model: str | UndefinedType | None + model_id: str | UndefinedType | None + serial_number: str | UndefinedType | None + sw_version: str | UndefinedType | None _cached_parse_url = lru_cache(maxsize=512)(URL) """Parse a URL and cache the result.""" -def _validate_str(name: str, value: Any) -> str | None | UndefinedType: +def _validate_str(name: str, value: Any) -> str | UndefinedType | None: """Validate that a device registry string field has correct type.""" if ( value is UNDEFINED @@ -1149,7 +1149,7 @@ def get_entry( identifiers: set[tuple[str, str]] | None = None, connections: set[tuple[str, str]] | None = None, *, - config_entry_id: str | None | UndefinedType = UNDEFINED, + config_entry_id: str | UndefinedType | None = UNDEFINED, ) -> _EntryTypeT | None: """Get the first entry matching identifiers or connections. @@ -1741,32 +1741,32 @@ def async_get_or_create( self, *, config_entry_id: str, - config_subentry_id: str | None | UndefinedType = UNDEFINED, - configuration_url: str | URL | None | UndefinedType = UNDEFINED, - connections: set[tuple[str, str]] | None | UndefinedType = UNDEFINED, + config_subentry_id: str | UndefinedType | None = UNDEFINED, + configuration_url: str | URL | UndefinedType | None = UNDEFINED, + connections: set[tuple[str, str]] | UndefinedType | None = UNDEFINED, created_at: str | datetime | UndefinedType = UNDEFINED, # will be ignored - default_manufacturer: str | None | UndefinedType = UNDEFINED, - default_model: str | None | UndefinedType = UNDEFINED, - default_name: str | None | UndefinedType = UNDEFINED, + default_manufacturer: str | UndefinedType | None = UNDEFINED, + default_model: str | UndefinedType | None = UNDEFINED, + default_name: str | UndefinedType | None = UNDEFINED, # To disable a device if it gets created, does not affect existing devices - disabled_by: DeviceEntryDisabler | None | UndefinedType = UNDEFINED, - entry_type: DeviceEntryType | None | UndefinedType = UNDEFINED, - hw_version: str | None | UndefinedType = UNDEFINED, - identifiers: set[tuple[str, str]] | None | UndefinedType = UNDEFINED, - manufacturer: str | None | UndefinedType = UNDEFINED, - model: str | None | UndefinedType = UNDEFINED, - model_id: str | None | UndefinedType = UNDEFINED, + disabled_by: DeviceEntryDisabler | UndefinedType | None = UNDEFINED, + entry_type: DeviceEntryType | UndefinedType | None = UNDEFINED, + hw_version: str | UndefinedType | None = UNDEFINED, + identifiers: set[tuple[str, str]] | UndefinedType | None = UNDEFINED, + manufacturer: str | UndefinedType | None = UNDEFINED, + model: str | UndefinedType | None = UNDEFINED, + model_id: str | UndefinedType | None = UNDEFINED, modified_at: str | datetime | UndefinedType = UNDEFINED, # will be ignored - name: str | None | UndefinedType = UNDEFINED, - serial_number: str | None | UndefinedType = UNDEFINED, - suggested_area: str | None | UndefinedType = UNDEFINED, - sw_version: str | None | UndefinedType = UNDEFINED, + name: str | UndefinedType | None = UNDEFINED, + serial_number: str | UndefinedType | None = UNDEFINED, + suggested_area: str | UndefinedType | None = UNDEFINED, + sw_version: str | UndefinedType | None = UNDEFINED, translation_key: str | None = None, translation_placeholders: Mapping[str, str] | None = None, # via_device is deprecated and will be removed in HA Core 2027.8, use # via_device_id instead - via_device: tuple[str, str] | None | UndefinedType = UNDEFINED, - via_device_id: str | None | UndefinedType = UNDEFINED, + via_device: tuple[str, str] | UndefinedType | None = UNDEFINED, + via_device_id: str | UndefinedType | None = UNDEFINED, ) -> DeviceEntry: """Get device. Create if it doesn't exist.""" default_manufacturer = _validate_str( @@ -2040,37 +2040,37 @@ def _async_update_device( # noqa: C901 device_id: str, *, add_config_entry_id: str | UndefinedType = UNDEFINED, - add_config_subentry_id: str | None | UndefinedType = UNDEFINED, + add_config_subentry_id: str | UndefinedType | None = UNDEFINED, # Only set when stripping colliding keys from a stale device: its retained # keys can still be duplicated in other stale devices and must not validate. allow_collisions: bool = False, - area_id: str | None | UndefinedType = UNDEFINED, - configuration_url: str | URL | None | UndefinedType = UNDEFINED, - disabled_by: DeviceEntryDisabler | None | UndefinedType = UNDEFINED, - entry_type: DeviceEntryType | None | UndefinedType = UNDEFINED, - hw_version: str | None | UndefinedType = UNDEFINED, + area_id: str | UndefinedType | None = UNDEFINED, + configuration_url: str | URL | UndefinedType | None = UNDEFINED, + disabled_by: DeviceEntryDisabler | UndefinedType | None = UNDEFINED, + entry_type: DeviceEntryType | UndefinedType | None = UNDEFINED, + hw_version: str | UndefinedType | None = UNDEFINED, is_new: bool = False, labels: set[str] | UndefinedType = UNDEFINED, - manufacturer: str | None | UndefinedType = UNDEFINED, + manufacturer: str | UndefinedType | None = UNDEFINED, merge_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED, merge_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED, - model: str | None | UndefinedType = UNDEFINED, - model_id: str | None | UndefinedType = UNDEFINED, - name_by_user: str | None | UndefinedType = UNDEFINED, - name: str | None | UndefinedType = UNDEFINED, + model: str | UndefinedType | None = UNDEFINED, + model_id: str | UndefinedType | None = UNDEFINED, + name_by_user: str | UndefinedType | None = UNDEFINED, + name: str | UndefinedType | None = UNDEFINED, # has_composite_identifiers can be removed in HA Core 2027.8 has_composite_identifiers: bool | UndefinedType = UNDEFINED, new_config_entry_id: str | UndefinedType = UNDEFINED, - new_config_subentry_id: str | None | UndefinedType = UNDEFINED, + new_config_subentry_id: str | UndefinedType | None = UNDEFINED, new_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED, new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED, remove_config_entry_id: str | UndefinedType = UNDEFINED, - remove_config_subentry_id: str | None | UndefinedType = UNDEFINED, - serial_number: str | None | UndefinedType = UNDEFINED, + remove_config_subentry_id: str | UndefinedType | None = UNDEFINED, + serial_number: str | UndefinedType | None = UNDEFINED, # Can be removed when suggested_area is removed from DeviceEntry - suggested_area: str | None | UndefinedType = UNDEFINED, - sw_version: str | None | UndefinedType = UNDEFINED, - via_device_id: str | None | UndefinedType = UNDEFINED, + suggested_area: str | UndefinedType | None = UNDEFINED, + sw_version: str | UndefinedType | None = UNDEFINED, + via_device_id: str | UndefinedType | None = UNDEFINED, ) -> DeviceEntry | None: """Private update device attributes. @@ -2173,8 +2173,8 @@ def _async_update_device( # noqa: C901 # is one, otherwise it removes the device, since it has no other config entry. # - new_config_entry_id / new_config_subentry_id move the device immediately. target_config_entry_id: str | UndefinedType = UNDEFINED - target_config_subentry_id: str | None | UndefinedType = UNDEFINED - pending_move: _PendingMove | None | UndefinedType = UNDEFINED + target_config_subentry_id: str | UndefinedType | None = UNDEFINED + pending_move: _PendingMove | UndefinedType | None = UNDEFINED if new_config_entry_id is not UNDEFINED: target_config_entry_id = new_config_entry_id target_config_subentry_id = ( @@ -2488,31 +2488,31 @@ def async_update_device( device_id: str, *, add_config_entry_id: str | UndefinedType = UNDEFINED, - add_config_subentry_id: str | None | UndefinedType = UNDEFINED, - area_id: str | None | UndefinedType = UNDEFINED, - configuration_url: str | URL | None | UndefinedType = UNDEFINED, - disabled_by: DeviceEntryDisabler | None | UndefinedType = UNDEFINED, - entry_type: DeviceEntryType | None | UndefinedType = UNDEFINED, - hw_version: str | None | UndefinedType = UNDEFINED, + add_config_subentry_id: str | UndefinedType | None = UNDEFINED, + area_id: str | UndefinedType | None = UNDEFINED, + configuration_url: str | URL | UndefinedType | None = UNDEFINED, + disabled_by: DeviceEntryDisabler | UndefinedType | None = UNDEFINED, + entry_type: DeviceEntryType | UndefinedType | None = UNDEFINED, + hw_version: str | UndefinedType | None = UNDEFINED, labels: set[str] | UndefinedType = UNDEFINED, - manufacturer: str | None | UndefinedType = UNDEFINED, + manufacturer: str | UndefinedType | None = UNDEFINED, merge_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED, merge_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED, - model: str | None | UndefinedType = UNDEFINED, - model_id: str | None | UndefinedType = UNDEFINED, - name_by_user: str | None | UndefinedType = UNDEFINED, - name: str | None | UndefinedType = UNDEFINED, + model: str | UndefinedType | None = UNDEFINED, + model_id: str | UndefinedType | None = UNDEFINED, + name_by_user: str | UndefinedType | None = UNDEFINED, + name: str | UndefinedType | None = UNDEFINED, new_config_entry_id: str | UndefinedType = UNDEFINED, - new_config_subentry_id: str | None | UndefinedType = UNDEFINED, + new_config_subentry_id: str | UndefinedType | None = UNDEFINED, new_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED, new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED, remove_config_entry_id: str | UndefinedType = UNDEFINED, - remove_config_subentry_id: str | None | UndefinedType = UNDEFINED, - serial_number: str | None | UndefinedType = UNDEFINED, + remove_config_subentry_id: str | UndefinedType | None = UNDEFINED, + serial_number: str | UndefinedType | None = UNDEFINED, # suggested_area is deprecated and will be removed in 2026.9 - suggested_area: str | None | UndefinedType = UNDEFINED, - sw_version: str | None | UndefinedType = UNDEFINED, - via_device_id: str | None | UndefinedType = UNDEFINED, + suggested_area: str | UndefinedType | None = UNDEFINED, + sw_version: str | UndefinedType | None = UNDEFINED, + via_device_id: str | UndefinedType | None = UNDEFINED, ) -> DeviceEntry | None: """Update device attributes. diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py index 85bc0723f2cda7..e3f5e715654185 100644 --- a/homeassistant/helpers/dispatcher.py +++ b/homeassistant/helpers/dispatcher.py @@ -27,7 +27,7 @@ SignalType[*_Ts] | str, dict[ Callable[[*_Ts], Any] | Callable[..., Any], - HassJob[..., None | Coroutine[Any, Any, None]] | None, + HassJob[..., Coroutine[Any, Any, None] | None] | None, ], ] diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 553f99c9699367..4ffd4504f8ed4a 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -171,7 +171,7 @@ def get_device_class(hass: HomeAssistant, entity_id: str) -> str | None: def get_device_class_or_undefined( hass: HomeAssistant, entity_id: str -) -> str | None | UndefinedType: +) -> str | UndefinedType | None: """Get the device class of an entity or UNDEFINED if not found.""" try: return get_device_class(hass, entity_id) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 3b15d34b02e91c..7adbb90ad2fdd2 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -210,7 +210,7 @@ async def _async_get_translations( return await translation.async_get_translations( self.hass, language, category, {integration} ) - except Exception as err: # noqa: BLE001 + except Exception as err: _LOGGER.debug( "Could not load translations for %s", integration, diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index a48a1102c3542a..11401aa7e5ec7a 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -499,7 +499,7 @@ def _async_get_full_entity_name( has_entity_name: bool, name: str | None, original_name: str | None, - original_name_unprefixed: str | None | UndefinedType = UNDEFINED, + original_name_unprefixed: str | UndefinedType | None = UNDEFINED, overridden_name: str | None = None, parts: Sequence[EntityNamePart], unprefix_name: bool = False, @@ -582,10 +582,10 @@ def _async_get_full_entity_name( def async_get_full_entity_name( hass: HomeAssistant, entry: RegistryEntry, - original_name: str | None | UndefinedType = UNDEFINED, + original_name: str | UndefinedType | None = UNDEFINED, ) -> str: """Get full entity name for an entry.""" - original_name_unprefixed: str | None | UndefinedType = UNDEFINED + original_name_unprefixed: str | UndefinedType | None = UNDEFINED if original_name is UNDEFINED or original_name == entry.original_name: original_name = entry.original_name original_name_unprefixed = entry.original_name_unprefixed @@ -1114,12 +1114,12 @@ def _validate_item( domain: str, platform: str, *, - config_entry_id: str | None | UndefinedType = None, - config_subentry_id: str | None | UndefinedType = None, - device_id: str | None | UndefinedType = None, - disabled_by: RegistryEntryDisabler | None | UndefinedType = None, - entity_category: EntityCategory | None | UndefinedType = None, - hidden_by: RegistryEntryHider | None | UndefinedType = None, + config_entry_id: str | UndefinedType | None = None, + config_subentry_id: str | UndefinedType | None = None, + device_id: str | UndefinedType | None = None, + disabled_by: RegistryEntryDisabler | UndefinedType | None = None, + entity_category: EntityCategory | UndefinedType | None = None, + hidden_by: RegistryEntryHider | UndefinedType | None = None, old_config_subentry_id: str | None = None, report_non_string_unique_id: bool = True, unique_id: str | Hashable | UndefinedType | Any, @@ -1420,8 +1420,8 @@ def async_get_or_create( *, # Used for entity ID generation, if entity gets created. # `suggested_object_id` has priority over `object_id_base`. - object_id_base: str | None | UndefinedType = UNDEFINED, - suggested_object_id: str | None | UndefinedType = UNDEFINED, + object_id_base: str | UndefinedType | None = UNDEFINED, + suggested_object_id: str | UndefinedType | None = UNDEFINED, # To disable or hide an entity if it gets created, does not affect # existing entities disabled_by: RegistryEntryDisabler | None = None, @@ -1429,25 +1429,25 @@ def async_get_or_create( # Function to generate initial entity options if it gets created get_initial_options: Callable[[], EntityOptionsType | None] | None = None, # Data that we want entry to have - capabilities: Mapping[str, Any] | None | UndefinedType = UNDEFINED, - config_entry: ConfigEntry | None | UndefinedType = UNDEFINED, - config_subentry_id: str | None | UndefinedType = UNDEFINED, - device_id: str | None | UndefinedType = UNDEFINED, + capabilities: Mapping[str, Any] | UndefinedType | None = UNDEFINED, + config_entry: ConfigEntry | UndefinedType | None = UNDEFINED, + config_subentry_id: str | UndefinedType | None = UNDEFINED, + device_id: str | UndefinedType | None = UNDEFINED, entity_category: EntityCategory | UndefinedType | None = UNDEFINED, has_entity_name: bool | UndefinedType = UNDEFINED, - original_device_class: str | None | UndefinedType = UNDEFINED, - original_icon: str | None | UndefinedType = UNDEFINED, - original_name: str | None | UndefinedType = UNDEFINED, - supported_features: int | None | UndefinedType = UNDEFINED, - translation_key: str | None | UndefinedType = UNDEFINED, - unit_of_measurement: str | None | UndefinedType = UNDEFINED, + original_device_class: str | UndefinedType | None = UNDEFINED, + original_icon: str | UndefinedType | None = UNDEFINED, + original_name: str | UndefinedType | None = UNDEFINED, + supported_features: int | UndefinedType | None = UNDEFINED, + translation_key: str | UndefinedType | None = UNDEFINED, + unit_of_measurement: str | UndefinedType | None = UNDEFINED, ) -> RegistryEntry: """Get entity. Create if it doesn't exist. domain: entity component domain (e.g. light, sensor) platform: integration domain (e.g. hue, zwave) """ - config_entry_id: str | None | UndefinedType = UNDEFINED + config_entry_id: str | UndefinedType | None = UNDEFINED if not config_entry: config_entry_id = None elif config_entry is not UNDEFINED: @@ -1756,7 +1756,7 @@ def async_device_modified( # An empty name_unprefixed means the entity name equals # the device name (e.g. a main sensor); a non-empty one # is appended as a suffix. - name: str | None | UndefinedType = UNDEFINED + name: str | UndefinedType | None = UNDEFINED if ( by_user and entity.name is None @@ -1802,8 +1802,8 @@ def async_device_modified( @callback def _ignore_composite_device_id( - self, platform: str, device_id: str | None | UndefinedType - ) -> str | None | UndefinedType: + self, platform: str, device_id: str | UndefinedType | None + ) -> str | UndefinedType | None: """Ignore a request to link an entity to a composite device id. A pre-migration composite device was split into one device per config @@ -1838,33 +1838,33 @@ def _async_update_entity( entity_id: str, *, aliases: list[AliasEntry] | UndefinedType = UNDEFINED, - area_id: str | None | UndefinedType = UNDEFINED, + area_id: str | UndefinedType | None = UNDEFINED, categories: dict[str, str] | UndefinedType = UNDEFINED, - capabilities: Mapping[str, Any] | None | UndefinedType = UNDEFINED, - config_entry_id: str | None | UndefinedType = UNDEFINED, - config_subentry_id: str | None | UndefinedType = UNDEFINED, - device_class: str | None | UndefinedType = UNDEFINED, - device_id: str | None | UndefinedType = UNDEFINED, - disabled_by: RegistryEntryDisabler | None | UndefinedType = UNDEFINED, - entity_category: EntityCategory | None | UndefinedType = UNDEFINED, - hidden_by: RegistryEntryHider | None | UndefinedType = UNDEFINED, - icon: str | None | UndefinedType = UNDEFINED, + capabilities: Mapping[str, Any] | UndefinedType | None = UNDEFINED, + config_entry_id: str | UndefinedType | None = UNDEFINED, + config_subentry_id: str | UndefinedType | None = UNDEFINED, + device_class: str | UndefinedType | None = UNDEFINED, + device_id: str | UndefinedType | None = UNDEFINED, + disabled_by: RegistryEntryDisabler | UndefinedType | None = UNDEFINED, + entity_category: EntityCategory | UndefinedType | None = UNDEFINED, + hidden_by: RegistryEntryHider | UndefinedType | None = UNDEFINED, + icon: str | UndefinedType | None = UNDEFINED, has_entity_name: bool | UndefinedType = UNDEFINED, labels: set[str] | UndefinedType = UNDEFINED, - name: str | None | UndefinedType = UNDEFINED, + name: str | UndefinedType | None = UNDEFINED, new_entity_id: str | UndefinedType = UNDEFINED, new_unique_id: str | UndefinedType = UNDEFINED, - object_id_base: str | None | UndefinedType = UNDEFINED, + object_id_base: str | UndefinedType | None = UNDEFINED, options: EntityOptionsType | UndefinedType = UNDEFINED, - original_device_class: str | None | UndefinedType = UNDEFINED, - original_icon: str | None | UndefinedType = UNDEFINED, - original_name: str | None | UndefinedType = UNDEFINED, - original_name_unprefixed: str | None | UndefinedType = UNDEFINED, - platform: str | None | UndefinedType = UNDEFINED, - suggested_object_id: str | None | UndefinedType = UNDEFINED, + original_device_class: str | UndefinedType | None = UNDEFINED, + original_icon: str | UndefinedType | None = UNDEFINED, + original_name: str | UndefinedType | None = UNDEFINED, + original_name_unprefixed: str | UndefinedType | None = UNDEFINED, + platform: str | UndefinedType | None = UNDEFINED, + suggested_object_id: str | UndefinedType | None = UNDEFINED, supported_features: int | UndefinedType = UNDEFINED, - translation_key: str | None | UndefinedType = UNDEFINED, - unit_of_measurement: str | None | UndefinedType = UNDEFINED, + translation_key: str | UndefinedType | None = UNDEFINED, + unit_of_measurement: str | UndefinedType | None = UNDEFINED, ) -> RegistryEntry: """Private facing update properties method.""" old = self.entities[entity_id] @@ -2015,28 +2015,28 @@ def async_update_entity( entity_id: str, *, aliases: list[AliasEntry] | UndefinedType = UNDEFINED, - area_id: str | None | UndefinedType = UNDEFINED, + area_id: str | UndefinedType | None = UNDEFINED, categories: dict[str, str] | UndefinedType = UNDEFINED, - capabilities: Mapping[str, Any] | None | UndefinedType = UNDEFINED, - config_entry_id: str | None | UndefinedType = UNDEFINED, - config_subentry_id: str | None | UndefinedType = UNDEFINED, - device_class: str | None | UndefinedType = UNDEFINED, - device_id: str | None | UndefinedType = UNDEFINED, - disabled_by: RegistryEntryDisabler | None | UndefinedType = UNDEFINED, - entity_category: EntityCategory | None | UndefinedType = UNDEFINED, - hidden_by: RegistryEntryHider | None | UndefinedType = UNDEFINED, - icon: str | None | UndefinedType = UNDEFINED, + capabilities: Mapping[str, Any] | UndefinedType | None = UNDEFINED, + config_entry_id: str | UndefinedType | None = UNDEFINED, + config_subentry_id: str | UndefinedType | None = UNDEFINED, + device_class: str | UndefinedType | None = UNDEFINED, + device_id: str | UndefinedType | None = UNDEFINED, + disabled_by: RegistryEntryDisabler | UndefinedType | None = UNDEFINED, + entity_category: EntityCategory | UndefinedType | None = UNDEFINED, + hidden_by: RegistryEntryHider | UndefinedType | None = UNDEFINED, + icon: str | UndefinedType | None = UNDEFINED, has_entity_name: bool | UndefinedType = UNDEFINED, labels: set[str] | UndefinedType = UNDEFINED, - name: str | None | UndefinedType = UNDEFINED, + name: str | UndefinedType | None = UNDEFINED, new_entity_id: str | UndefinedType = UNDEFINED, new_unique_id: str | UndefinedType = UNDEFINED, - original_device_class: str | None | UndefinedType = UNDEFINED, - original_icon: str | None | UndefinedType = UNDEFINED, - original_name: str | None | UndefinedType = UNDEFINED, + original_device_class: str | UndefinedType | None = UNDEFINED, + original_icon: str | UndefinedType | None = UNDEFINED, + original_name: str | UndefinedType | None = UNDEFINED, supported_features: int | UndefinedType = UNDEFINED, - translation_key: str | None | UndefinedType = UNDEFINED, - unit_of_measurement: str | None | UndefinedType = UNDEFINED, + translation_key: str | UndefinedType | None = UNDEFINED, + unit_of_measurement: str | UndefinedType | None = UNDEFINED, ) -> RegistryEntry: """Update properties of an entity.""" return self._async_update_entity( @@ -2075,7 +2075,7 @@ def async_update_entity_platform( new_config_entry_id: str | UndefinedType = UNDEFINED, new_config_subentry_id: str | UndefinedType = UNDEFINED, new_unique_id: str | UndefinedType = UNDEFINED, - new_device_id: str | None | UndefinedType = UNDEFINED, + new_device_id: str | UndefinedType | None = UNDEFINED, ) -> RegistryEntry: """Update entity platform. @@ -2124,7 +2124,7 @@ def async_update_entity_options( def async_update_settings( self, *, - entity_id_parts: list[EntityNamePart] | None | UndefinedType = UNDEFINED, + entity_id_parts: list[EntityNamePart] | UndefinedType | None = UNDEFINED, ) -> EntityRegistrySettings: """Update entity registry settings.""" self.hass.verify_event_loop_thread("entity_registry.async_update_settings") diff --git a/homeassistant/helpers/floor_registry.py b/homeassistant/helpers/floor_registry.py index 530fa13f999e25..aa54f89770539d 100644 --- a/homeassistant/helpers/floor_registry.py +++ b/homeassistant/helpers/floor_registry.py @@ -249,7 +249,7 @@ def async_update( floor_id: str, *, aliases: set[str] | UndefinedType = UNDEFINED, - icon: str | None | UndefinedType = UNDEFINED, + icon: str | UndefinedType | None = UNDEFINED, level: int | UndefinedType = UNDEFINED, name: str | UndefinedType = UNDEFINED, ) -> FloorEntry: diff --git a/homeassistant/helpers/label_registry.py b/homeassistant/helpers/label_registry.py index 1f9fde5762a79c..9ce6ce074233cd 100644 --- a/homeassistant/helpers/label_registry.py +++ b/homeassistant/helpers/label_registry.py @@ -184,9 +184,9 @@ def async_update( self, label_id: str, *, - color: str | None | UndefinedType = UNDEFINED, - description: str | None | UndefinedType = UNDEFINED, - icon: str | None | UndefinedType = UNDEFINED, + color: str | UndefinedType | None = UNDEFINED, + description: str | UndefinedType | None = UNDEFINED, + icon: str | UndefinedType | None = UNDEFINED, name: str | UndefinedType = UNDEFINED, ) -> LabelEntry: """Update name of label.""" diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index 8d85f254d8c5f4..0d424b4d8cead7 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -79,8 +79,8 @@ class SchemaFlowFormStep(SchemaFlowStep): suggested_values: ( Callable[[SchemaCommonFlowHandler], Coroutine[Any, Any, dict[str, Any]]] - | None | UndefinedType + | None ) = UNDEFINED """Optional property to populate suggested values. diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 224d6688f03366..1da929f2291fc8 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -427,7 +427,7 @@ def __init__( self, message: str, response: Any, - conversation_response: str | None | UndefinedType = UNDEFINED, + conversation_response: str | UndefinedType | None = UNDEFINED, ) -> None: """Initialize a halt exception.""" super().__init__(message) @@ -457,7 +457,7 @@ def __init__( self._started = False self._stop = hass.loop.create_future() self._stopped = asyncio.Event() - self._conversation_response: str | None | UndefinedType = UNDEFINED + self._conversation_response: str | UndefinedType | None = UNDEFINED def _changed(self) -> None: if not self._stop.done(): @@ -1494,7 +1494,7 @@ class _IfData(TypedDict): class ScriptRunResult: """Container with the result of a script run.""" - conversation_response: str | None | UndefinedType + conversation_response: str | UndefinedType | None service_response: ServiceResponse variables: Mapping[str, Any] diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index f07a0a8bf5d7dd..f4549792bb9b53 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -817,7 +817,7 @@ class StatelessEntityTriggerBase(EntityTriggerBase): class EntityNumericalStateTriggerBase(EntityTriggerBase): """Base class for numerical state and state attribute triggers.""" - _valid_unit: str | None | UndefinedType = UNDEFINED + _valid_unit: str | UndefinedType | None = UNDEFINED _threshold_type: NumericThresholdType def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: @@ -1282,7 +1282,7 @@ class CustomTrigger(EntityOriginStateTriggerBase): def make_entity_numerical_state_changed_trigger( domain_specs: Mapping[str, DomainSpec], - valid_unit: str | None | UndefinedType = UNDEFINED, + valid_unit: str | UndefinedType | None = UNDEFINED, *, primary_entities_only: bool = True, ) -> type[EntityNumericalStateChangedTriggerBase]: @@ -1300,7 +1300,7 @@ class CustomTrigger(EntityNumericalStateChangedTriggerBase): def make_entity_numerical_state_crossed_threshold_trigger( domain_specs: Mapping[str, DomainSpec], - valid_unit: str | None | UndefinedType = UNDEFINED, + valid_unit: str | UndefinedType | None = UNDEFINED, *, primary_entities_only: bool = True, ) -> type[EntityNumericalStateCrossedThresholdTriggerBase]: diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 4efe8fd385d3b1..e8b47af35f6232 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -75,7 +75,7 @@ def __init__( hass: HomeAssistant, logger: logging.Logger, *, - config_entry: config_entries.ConfigEntry | None | UndefinedType = UNDEFINED, + config_entry: config_entries.ConfigEntry | UndefinedType | None = UNDEFINED, name: str, update_interval: timedelta | None = None, update_method: Callable[[], Awaitable[_DataT]] | None = None, diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 0d828c57430241..dd9f03d42663c1 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -1524,7 +1524,7 @@ async def _resolve_integrations_dependencies( integrations: Iterable[Integration], *, cache: _ResolveDependenciesCacheProtocol, - possible_after_dependencies: set[str] | None | UndefinedType = UNDEFINED, + possible_after_dependencies: set[str] | UndefinedType | None = UNDEFINED, ignore_exceptions: bool, ) -> dict[str, set[str]]: """Resolve all dependencies for integrations. @@ -1567,7 +1567,7 @@ async def _resolve_integration_dependencies( itg: Integration, *, cache: _ResolveDependenciesCacheProtocol, - possible_after_dependencies: set[str] | None | UndefinedType = UNDEFINED, + possible_after_dependencies: set[str] | UndefinedType | None = UNDEFINED, ignore_exceptions: bool = False, ) -> set[str]: """Recursively resolve all dependencies. diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8d4ab68af2a048..e15b34a2453ce3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==6.26.5 hass-nabucasa==2.2.0 hassil==3.10.0 home-assistant-bluetooth==2.0.0 -home-assistant-frontend==20260624.6 +home-assistant-frontend==20260729.0 home-assistant-intents==2026.6.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/homeassistant/strings.json b/homeassistant/strings.json index 93901eea6e9147..f7aa5aaecefba8 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -22,7 +22,7 @@ "already_configured_device": "Device is already configured", "already_configured_location": "Location is already configured", "already_configured_service": "Service is already configured", - "already_in_progress": "Configuration flow is already in progress", + "already_in_progress": "This is already being configured. Finish the existing configuration before starting again.", "cloud_not_connected": "Not connected to Home Assistant Cloud.", "no_compatible_radio_frequency_transmitters": "No radio frequency transmitter supports {frequency} {modulation} transmissions. Please add a compatible transmitter first.", "no_devices_found": "No devices found on the network", diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py index 8500f7efbffee0..1a910b3bbe8a74 100644 --- a/homeassistant/util/async_.py +++ b/homeassistant/util/async_.py @@ -63,7 +63,7 @@ def run_callback() -> None: """Run callback and store result.""" try: future.set_result(callback(*args)) - except Exception as exc: # noqa: BLE001 + except Exception as exc: if future.set_running_or_notify_cancel(): future.set_exception(exc) else: diff --git a/mypy.ini b/mypy.ini index e9ddf4e4135d06..ec2fbda48ccc07 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1707,6 +1707,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.energieleser.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.energy.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3837,6 +3847,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.nobo_hub.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.nordpool.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pylint/plugins/pylint_home_assistant/checkers/super_call.py b/pylint/plugins/pylint_home_assistant/checkers/super_call.py index e767b5d23eec82..fa6bbf9b98a748 100644 --- a/pylint/plugins/pylint_home_assistant/checkers/super_call.py +++ b/pylint/plugins/pylint_home_assistant/checkers/super_call.py @@ -7,6 +7,7 @@ METHODS = { "async_added_to_hass", + "async_will_remove_from_hass", } diff --git a/pylint/plugins/pylint_home_assistant/checkers/type_hints/validators.py b/pylint/plugins/pylint_home_assistant/checkers/type_hints/validators.py index b6e0f266704257..1cebe0ca622a03 100644 --- a/pylint/plugins/pylint_home_assistant/checkers/type_hints/validators.py +++ b/pylint/plugins/pylint_home_assistant/checkers/type_hints/validators.py @@ -34,7 +34,7 @@ def is_valid_type( - expected_type: list[str] | str | None | object, + expected_type: list[str] | str | object | None, node: nodes.NodeNG, in_return: bool = False, ) -> bool: diff --git a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py index bee392ebe525d3..3a9b6e4e5b0c71 100644 --- a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py +++ b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py @@ -5,7 +5,7 @@ from typing import Final -FRONTEND_VERSION: Final[str] = "20260624.6" +FRONTEND_VERSION: Final[str] = "20260729.0" MDI_ICONS: Final[set[str]] = { "ab-testing", diff --git a/pyproject.toml b/pyproject.toml index 6611d71a452930..43f8fc309b9139 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.8.0.dev0" +version = "2026.9.0.dev0" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." @@ -645,7 +645,7 @@ exclude_lines = [ ] [tool.ruff] -required-version = ">=0.15.22" +required-version = ">=0.16.0" [tool.ruff.lint] select = [ @@ -736,6 +736,7 @@ ignore = [ "PLR0912", # Too many branches ({branches} > {max_branches}) "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) "PLR0915", # Too many statements ({statements} > {max_statements}) + "PLR0917", # Too many positional arguments defined for a function ({p_args} > {max_args}) "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable "PLW0108", # Unnecessary lambda wrapping a function call; can often be replaced by the function itself "PLW1641", # __eq__ without __hash__ @@ -779,6 +780,10 @@ ignore = [ "PLE0605", "FURB116", + + # Disabled to implement in follow up PRs after ruff 0.16 bump + "ISC004", + "LOG004", ] [tool.ruff.lint.flake8-import-conventions.extend-aliases] diff --git a/requirements_all.txt b/requirements_all.txt index 4244f77e062095..60890787e29fd0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1278,7 +1278,7 @@ hole==0.9.2 holidays==0.101 # homeassistant.components.frontend -home-assistant-frontend==20260624.6 +home-assistant-frontend==20260729.0 # homeassistant.components.conversation home-assistant-intents==2026.6.24 @@ -1655,7 +1655,7 @@ nad-receiver==0.3.0 ndms2-client==0.1.2 # homeassistant.components.neopool -neopool-modbus==3.6.0 +neopool-modbus==4.5.1 # homeassistant.components.ness_alarm nessclient==1.3.1 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 4245dbfb7ce2ee..2dd634ffe661ae 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,6 +1,6 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.4.3 -ruff==0.15.22 +ruff==0.16.0 yamllint==1.38.0 zizmor==1.25.2 diff --git a/tests/components/acmeda/test_init.py b/tests/components/acmeda/test_init.py new file mode 100644 index 00000000000000..61cbb9cdbed48e --- /dev/null +++ b/tests/components/acmeda/test_init.py @@ -0,0 +1,71 @@ +"""Tests for the Acmeda integration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import aiopulse +import pytest + +from homeassistant.components.acmeda.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_roller() -> MagicMock: + """Return a mocked Acmeda roller.""" + roller = MagicMock() + roller.id = 1234567890123 + roller.name = "Roller" + roller.battery = 50 + roller.type = 1 + roller.closed_percent = 50 + return roller + + +@pytest.fixture +def mock_hub(mock_roller: MagicMock) -> Generator[MagicMock]: + """Mock the aiopulse Hub client.""" + with patch("homeassistant.components.acmeda.hub.aiopulse.Hub") as hub_class: + hub = hub_class.return_value + hub.id = "hub-id" + hub.host = "127.0.0.1" + hub.rollers = {mock_roller.id: mock_roller} + hub.run = AsyncMock() + hub.stop = AsyncMock() + yield hub + + +async def test_update_devices_renames_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + mock_hub: MagicMock, + mock_roller: MagicMock, +) -> None: + """Test a roller rename is propagated to the device registry.""" + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # The integration subscribes a callback which the hub invokes once it has + # fetched roller updates; grab it and simulate the hub reporting an update. + notify_update = mock_hub.callback_subscribe.call_args[0][0] + await notify_update(aiopulse.UpdateType.rollers) + await hass.async_block_till_done() + + device = device_registry.async_get_device_by_identifier( + (DOMAIN, str(mock_roller.id)), mock_config_entry.entry_id + ) + assert device is not None + assert device.name == "Roller" + + mock_roller.name = "Living room blind" + await notify_update(aiopulse.UpdateType.rollers) + await hass.async_block_till_done() + + device = device_registry.async_get_device_by_identifier( + (DOMAIN, str(mock_roller.id)), mock_config_entry.entry_id + ) + assert device.name == "Living room blind" diff --git a/tests/components/alarm_control_panel/test_init.py b/tests/components/alarm_control_panel/test_init.py index 2435e177c2aed9..71c4db6466245e 100644 --- a/tests/components/alarm_control_panel/test_init.py +++ b/tests/components/alarm_control_panel/test_init.py @@ -31,7 +31,7 @@ async def help_test_async_alarm_control_panel_service( hass: HomeAssistant, entity_id: str, service: str, - code: str | None | UndefinedType = UNDEFINED, + code: str | UndefinedType | None = UNDEFINED, ) -> None: """Help to lock a test lock.""" data: dict[str, Any] = {"entity_id": entity_id} diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index 40e7a50878b83e..f97e0ac14a1590 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -690,8 +690,8 @@ async def test_calendar_initial_color_none( ], ) async def test_calendar_initial_color_precedence( - description_color: str | None | object, - attr_color: str | None | object, + description_color: str | object | None, + attr_color: str | object | None, expected_color: str | None, ) -> None: """Test that _attr_initial_color takes precedence over entity_description.""" @@ -703,8 +703,8 @@ class TestCalendarEntity(CalendarEntity): def __init__( self, - description_color: str | None | object, - attr_color: str | None | object, + description_color: str | object | None, + attr_color: str | object | None, ) -> None: """Initialize entity.""" self._attr_name = "Test" diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 060a5cf77b3acb..6b7618a6f4184c 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -8,16 +8,9 @@ from aiohttp import hdrs import pytest from syrupy.assertion import SnapshotAssertion -from webrtc_models import RTCIceCandidateInit from homeassistant.components import camera -from homeassistant.components.camera import ( - Camera, - CameraWebRTCProvider, - WebRTCAnswer, - WebRTCSendMessage, - async_register_webrtc_provider, -) +from homeassistant.components.camera import Camera, async_register_webrtc_provider from homeassistant.components.camera.const import ( DOMAIN, PREF_ORIENTATION, @@ -31,14 +24,14 @@ EVENT_HOMEASSISTANT_STARTED, STATE_UNAVAILABLE, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.core_config import async_process_ha_core_config from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from .common import EMPTY_8_6_JPEG, STREAM_SOURCE, mock_turbo_jpeg +from .common import EMPTY_8_6_JPEG, STREAM_SOURCE, SomeTestProvider, mock_turbo_jpeg from tests.common import async_fire_time_changed from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -53,6 +46,21 @@ async def image_mock_url_fixture(hass: HomeAssistant) -> None: await hass.async_block_till_done() +@pytest.fixture +async def register_provider_and_get_camera( + hass: HomeAssistant, +) -> tuple[Camera, Callable[[], None]]: + """Fixture for mock camera.""" + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # Register test provider + unsub = await _register_test_webrtc_provider(hass) + camera_obj = get_camera_from_entity_id(hass, "camera.demo_camera") + assert camera_obj.webrtc_provider is not None + return camera_obj, unsub + + @pytest.mark.usefixtures("image_mock_url") async def test_get_image_from_camera(hass: HomeAssistant) -> None: """Grab an image from camera entity.""" @@ -856,34 +864,6 @@ async def test_entity_picture_url_changes_on_token_update(hass: HomeAssistant) - async def _register_test_webrtc_provider(hass: HomeAssistant) -> Callable[[], None]: - class SomeTestProvider(CameraWebRTCProvider): - """Test provider.""" - - @property - def domain(self) -> str: - """Return domain.""" - return "test" - - @callback - def async_is_supported(self, stream_source: str) -> bool: - """Determine if the provider supports the stream source.""" - return True - - async def async_handle_async_webrtc_offer( - self, - camera: Camera, - offer_sdp: str, - session_id: str, - send_message: WebRTCSendMessage, - ) -> None: - """Handle the WebRTC offer and return the answer.""" - send_message(WebRTCAnswer("answer")) - - async def async_on_webrtc_candidate( - self, session_id: str, candidate: RTCIceCandidateInit - ) -> None: - """Handle the WebRTC candidate.""" - provider = SomeTestProvider() unsub = async_register_webrtc_provider(hass, provider) await hass.async_block_till_done() @@ -960,7 +940,7 @@ async def test_webrtc_provider_not_added_for_native_webrtc( """Test that a WebRTC provider is not added for native WebRTC.""" camera_obj = get_camera_from_entity_id(hass, "camera.async") assert camera_obj - assert camera_obj._webrtc_provider is None + assert camera_obj.webrtc_provider is None assert camera_obj._supports_native_async_webrtc is True @@ -1014,21 +994,18 @@ async def test_camera_capabilities_changing_native_support( @pytest.mark.usefixtures("mock_camera", "mock_stream_source") async def test_snapshot_service_webrtc_provider( hass: HomeAssistant, + register_provider_and_get_camera: tuple[Camera, Callable[[], None]], ) -> None: """Test snapshot service with the webrtc provider.""" - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - unsub = await _register_test_webrtc_provider(hass) - camera_obj = get_camera_from_entity_id(hass, "camera.demo_camera") - assert camera_obj._webrtc_provider + camera_obj, unsub = register_provider_and_get_camera with ( patch.object(camera_obj, "use_stream_for_stills", return_value=True), patch("homeassistant.components.camera.open"), patch.object( - camera_obj._webrtc_provider, + camera_obj.webrtc_provider, "async_get_image", - wraps=camera_obj._webrtc_provider.async_get_image, + wraps=camera_obj.webrtc_provider.async_get_image, ) as webrtc_get_image_mock, patch.object(camera_obj, "stream", AsyncMock()) as stream_mock, patch( @@ -1073,7 +1050,7 @@ async def test_snapshot_service_webrtc_provider( # Deregister provider unsub() await hass.async_block_till_done() - assert camera_obj._webrtc_provider is None + assert camera_obj.webrtc_provider is None webrtc_get_image_mock.reset_mock() stream_mock.reset_mock() @@ -1088,3 +1065,171 @@ async def test_snapshot_service_webrtc_provider( ) stream_mock.async_get_image.assert_called_once() webrtc_get_image_mock.assert_not_called() + + +@pytest.mark.usefixtures("mock_camera", "mock_stream_source") +async def test_provider_change_register_unregister_called( + register_provider_and_get_camera: tuple[Camera, Callable[[], None]], +) -> None: + """Test that register and unregister are called when provider support changes.""" + camera_obj, _ = register_provider_and_get_camera + provider = camera_obj.webrtc_provider + assert isinstance(provider, SomeTestProvider) + + with ( + patch.object( + provider, "async_unregister_camera", AsyncMock() + ) as mock_unregister, + patch.object(provider, "async_register_camera", AsyncMock()) as mock_register, + ): + # Make provider unsupported + provider._is_supported = False + await camera_obj.async_refresh_providers() + assert camera_obj.webrtc_provider is None + + # Verify unregister was called + mock_unregister.assert_called_once_with(camera_obj) + mock_register.assert_not_called() + + # Make provider supported again + mock_unregister.reset_mock() + provider._is_supported = True + await camera_obj.async_refresh_providers() + assert camera_obj.webrtc_provider is provider + + # Verify register was called + mock_register.assert_called_once_with(camera_obj) + mock_unregister.assert_not_called() + + +@pytest.mark.usefixtures("mock_camera", "mock_stream_source") +@pytest.mark.parametrize( + "side_effect", + [HomeAssistantError("boom"), ValueError("boom")], + ids=["home_assistant_error", "unexpected_error"], +) +async def test_provider_register_error_does_not_propagate( + hass: HomeAssistant, + side_effect: Exception, +) -> None: + """Test a failing register callback does not prevent provider assignment.""" + provider = SomeTestProvider() + with patch.object( + provider, "async_register_camera", AsyncMock(side_effect=side_effect) + ) as mock_register: + async_register_webrtc_provider(hass, provider) + await hass.async_block_till_done() + + camera_obj = get_camera_from_entity_id(hass, "camera.demo_camera") + mock_register.assert_any_call(camera_obj) + assert camera_obj.webrtc_provider is provider + + +@pytest.mark.usefixtures("mock_camera", "mock_stream_source") +@pytest.mark.parametrize( + "side_effect", + [HomeAssistantError("boom"), ValueError("boom")], + ids=["home_assistant_error", "unexpected_error"], +) +async def test_provider_unregister_error_does_not_propagate( + register_provider_and_get_camera: tuple[Camera, Callable[[], None]], + side_effect: Exception, +) -> None: + """Test a failing unregister callback does not break camera removal.""" + camera_obj, _ = register_provider_and_get_camera + + with patch.object( + camera_obj.webrtc_provider, + "async_unregister_camera", + AsyncMock(side_effect=side_effect), + ) as mock_unregister: + await camera_obj.async_remove() + + mock_unregister.assert_called_once_with(camera_obj) + assert camera_obj.webrtc_provider is None + + +@pytest.mark.usefixtures("mock_camera", "mock_stream_source") +async def test_camera_prefs_update_calls_provider_callback( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + register_provider_and_get_camera: tuple[Camera, Callable[[], None]], +) -> None: + """Test that async_on_camera_prefs_update is called when prefs are updated.""" + camera_obj, _ = register_provider_and_get_camera + # Patch the callback method + with patch.object( + camera_obj.webrtc_provider, + "async_on_camera_prefs_update", + AsyncMock(), + ) as mock_prefs_update: + # Update camera preferences through WebSocket + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "camera/update_prefs", + "entity_id": "camera.demo_camera", + "preload_stream": True, + } + ) + msg = await client.receive_json() + + # Assert preference was updated + assert msg["success"] + assert msg["result"][PREF_PRELOAD_STREAM] is True + + # Verify callback was called + mock_prefs_update.assert_called_once_with(camera_obj) + + # Update another preference + mock_prefs_update.reset_mock() + await client.send_json_auto_id( + { + "type": "camera/update_prefs", + "entity_id": "camera.demo_camera", + "preload_stream": False, + } + ) + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"][PREF_PRELOAD_STREAM] is False + + # Verify callback was called again + mock_prefs_update.assert_called_once_with(camera_obj) + + +@pytest.mark.usefixtures("mock_camera", "mock_stream_source") +@pytest.mark.parametrize( + "side_effect", + [HomeAssistantError("boom"), ValueError("boom")], + ids=["home_assistant_error", "unexpected_error"], +) +async def test_camera_prefs_update_provider_callback_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + register_provider_and_get_camera: tuple[Camera, Callable[[], None]], + side_effect: Exception, +) -> None: + """Test prefs update succeeds even if the provider callback raises.""" + camera_obj, _ = register_provider_and_get_camera + + with patch.object( + camera_obj.webrtc_provider, + "async_on_camera_prefs_update", + AsyncMock(side_effect=side_effect), + ) as mock_prefs_update: + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "camera/update_prefs", + "entity_id": "camera.demo_camera", + "preload_stream": True, + } + ) + msg = await client.receive_json() + + # The preferences are persisted despite the provider callback failing + assert msg["success"] + assert msg["result"][PREF_PRELOAD_STREAM] is True + mock_prefs_update.assert_called_once_with(camera_obj) diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index 6c389c791f0dab..d9bb9e96687841 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -721,13 +721,38 @@ async def async_on_webrtc_candidate( ) -> None: """Handle the WebRTC candidate.""" + camera = Mock() provider = OnlyRequiredInterfaceProvider() # Call all interface methods assert provider.async_is_supported("stream_source") is True await provider.async_handle_async_webrtc_offer( - Mock(), "offer_sdp", "session_id", Mock() + camera, "offer_sdp", "session_id", Mock() ) await provider.async_on_webrtc_candidate( "session_id", RTCIceCandidateInit("candidate") ) provider.async_close_session("session_id") + # Call optional methods to verify they do not raise exceptions + await provider.async_register_camera(camera) + await provider.async_unregister_camera(camera) + await provider.async_on_camera_prefs_update(camera) + + +@pytest.mark.usefixtures("mock_camera", "mock_stream_source") +async def test_camera_unregisters_from_webrtc_provider_on_removal( + hass: HomeAssistant, + register_test_provider: SomeTestProvider, +) -> None: + """Test camera unregisters from WebRTC provider when removed from hass.""" + camera = get_camera_from_entity_id(hass, "camera.demo_camera") + + # Verify the provider is registered + assert camera.webrtc_provider is not None + assert camera.webrtc_provider == register_test_provider + + with patch.object( + register_test_provider, "async_unregister_camera", autospec=True + ) as mock_unregister: + await camera.async_remove() + mock_unregister.assert_called_once_with(camera) + assert camera.webrtc_provider is None diff --git a/tests/components/common.py b/tests/components/common.py index 98087ae1e18df5..fdfe3094520891 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -253,9 +253,9 @@ def _parametrize_condition_states( *, condition: str, condition_options: dict[str, Any] | None = None, - target_states: list[str | None | tuple[str | None, dict]], - other_states: list[str | None | tuple[str | None, dict]], - extra_excluded_states: list[str | None | tuple[str | None, dict]] | None = None, + target_states: list[str | tuple[str | None, dict] | None], + other_states: list[str | tuple[str | None, dict] | None], + extra_excluded_states: list[str | tuple[str | None, dict] | None] | None = None, required_filter_attributes: dict | None, condition_true_if_invalid: bool, excluded_entities_from_other_domain: bool, @@ -277,7 +277,7 @@ def _parametrize_condition_states( ) def state_with_attributes( - state: str | None | tuple[str | None, dict], + state: str | tuple[str | None, dict] | None, condition_true: bool, condition_true_first_entity: bool, ) -> ConditionStateDescription: @@ -363,9 +363,9 @@ def parametrize_condition_states_any( *, condition: str, condition_options: dict[str, Any] | None = None, - target_states: list[str | None | tuple[str | None, dict]], - other_states: list[str | None | tuple[str | None, dict]], - extra_excluded_states: list[str | None | tuple[str | None, dict]] | None = None, + target_states: list[str | tuple[str | None, dict] | None], + other_states: list[str | tuple[str | None, dict] | None], + extra_excluded_states: list[str | tuple[str | None, dict] | None] | None = None, required_filter_attributes: dict | None = None, excluded_entities_from_other_domain: bool = False, ) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: @@ -422,9 +422,9 @@ def parametrize_condition_states_all( *, condition: str, condition_options: dict[str, Any] | None = None, - target_states: list[str | None | tuple[str | None, dict]], - other_states: list[str | None | tuple[str | None, dict]], - extra_excluded_states: list[str | None | tuple[str | None, dict]] | None = None, + target_states: list[str | tuple[str | None, dict] | None], + other_states: list[str | tuple[str | None, dict] | None], + extra_excluded_states: list[str | tuple[str | None, dict] | None] | None = None, required_filter_attributes: dict | None = None, excluded_entities_from_other_domain: bool = False, ) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: @@ -484,10 +484,10 @@ def parametrize_trigger_states( *, trigger: str, trigger_options: dict[str, Any] | None = None, - target_states: list[str | None | tuple[str | None, dict]], - other_states: list[str | None | tuple[str | None, dict]], - extra_excluded_states: list[str | None | tuple[str | None, dict]] | None = None, - extra_invalid_states: list[str | None | tuple[str | None, dict]] | None = None, + target_states: list[str | tuple[str | None, dict] | None], + other_states: list[str | tuple[str | None, dict] | None], + extra_excluded_states: list[str | tuple[str | None, dict] | None] | None = None, + extra_invalid_states: list[str | tuple[str | None, dict] | None] | None = None, required_filter_attributes: dict | None = None, trigger_from_none: bool = True, retrigger_on_target_state: bool = False, @@ -555,7 +555,7 @@ def parametrize_trigger_states( trigger_options = trigger_options or {} def _included_state_desc( - state: str | None | tuple[str | None, dict], + state: str | tuple[str | None, dict] | None, ) -> StateDescription: """Build a state for entities meant to match the trigger's target. @@ -570,7 +570,7 @@ def _included_state_desc( } def _excluded_state_desc( - state: str | None | tuple[str | None, dict], + state: str | tuple[str | None, dict] | None, ) -> StateDescription: """Build a state for entities outside the trigger's target. @@ -589,10 +589,10 @@ def _excluded_state_desc( } def state_with_attributes( - state: str | None | tuple[str | None, dict], + state: str | tuple[str | None, dict] | None, count: int, *, - others_state: str | None | tuple[str | None, dict] | UndefinedType = UNDEFINED, + others_state: str | tuple[str | None, dict] | UndefinedType | None = UNDEFINED, ) -> TriggerStateDescription: """Return TriggerStateDescription dict.""" included = _included_state_desc(state) @@ -819,7 +819,7 @@ def state_with_attributes( def _add_threshold_unit( - options: dict[str, Any], threshold_unit: str | None | UndefinedType + options: dict[str, Any], threshold_unit: str | UndefinedType | None ) -> dict[str, Any]: """Add unit to trigger thresholds if threshold_unit is provided.""" if threshold_unit is UNDEFINED: @@ -838,7 +838,7 @@ def parametrize_numerical_attribute_changed_trigger_states( state: str, attribute: str, *, - threshold_unit: str | None | UndefinedType = UNDEFINED, + threshold_unit: str | UndefinedType | None = UNDEFINED, trigger_options: dict[str, Any] | None = None, required_filter_attributes: dict | None = None, unit_attributes: dict | None = None, @@ -984,7 +984,7 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states( state: str, attribute: str, *, - threshold_unit: str | None | UndefinedType = UNDEFINED, + threshold_unit: str | UndefinedType | None = UNDEFINED, trigger_options: dict[str, Any] | None = None, required_filter_attributes: dict | None = None, unit_attributes: dict | None = None, @@ -1155,7 +1155,7 @@ def parametrize_numerical_state_value_changed_trigger_states( trigger: str, *, device_class: str, - threshold_unit: str | None | UndefinedType = UNDEFINED, + threshold_unit: str | UndefinedType | None = UNDEFINED, trigger_options: dict[str, Any] | None = None, unit_attributes: dict | None = None, ) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]: @@ -1236,7 +1236,7 @@ def parametrize_numerical_state_value_crossed_threshold_trigger_states( trigger: str, *, device_class: str, - threshold_unit: str | None | UndefinedType = UNDEFINED, + threshold_unit: str | UndefinedType | None = UNDEFINED, trigger_options: dict[str, Any] | None = None, unit_attributes: dict | None = None, ) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]: @@ -1889,7 +1889,7 @@ def parametrize_numerical_condition_above_below_any( *, device_class: str, condition_options: dict[str, Any] | None = None, - threshold_unit: str | None | UndefinedType = UNDEFINED, + threshold_unit: str | UndefinedType | None = UNDEFINED, unit_attributes: dict | None = None, ) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: """Parametrize threshold cases for state-value numerical conditions. @@ -2012,7 +2012,7 @@ def parametrize_numerical_condition_above_below_all( *, device_class: str, condition_options: dict[str, Any] | None = None, - threshold_unit: str | None | UndefinedType = UNDEFINED, + threshold_unit: str | UndefinedType | None = UNDEFINED, unit_attributes: dict | None = None, ) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: """Parametrize threshold cases for state-value numerical conditions. @@ -2132,7 +2132,7 @@ def parametrize_numerical_attribute_condition_above_below_any( *, condition_options: dict[str, Any] | None = None, required_filter_attributes: dict | None = None, - threshold_unit: str | None | UndefinedType = UNDEFINED, + threshold_unit: str | UndefinedType | None = UNDEFINED, unit_attributes: dict | None = None, attribute_required: bool = False, attribute_value_scale: float = 1.0, @@ -2280,7 +2280,7 @@ def parametrize_numerical_attribute_condition_above_below_all( *, condition_options: dict[str, Any] | None = None, required_filter_attributes: dict | None = None, - threshold_unit: str | None | UndefinedType = UNDEFINED, + threshold_unit: str | UndefinedType | None = UNDEFINED, unit_attributes: dict | None = None, attribute_required: bool = False, attribute_value_scale: float = 1.0, diff --git a/tests/components/daikin/test_config_flow.py b/tests/components/daikin/test_config_flow.py index 906875de617a36..5afe55a7b9d26d 100644 --- a/tests/components/daikin/test_config_flow.py +++ b/tests/components/daikin/test_config_flow.py @@ -96,7 +96,8 @@ async def test_abort_if_already_setup(hass: HomeAssistant, mock_daikin) -> None: (TimeoutError, "cannot_connect"), (ClientError, "cannot_connect"), (web_exceptions.HTTPForbidden, "invalid_auth"), - (DaikinException, "unknown"), + (DaikinException("Empty values."), "cannot_connect"), + (DaikinException, "cannot_connect"), (Exception, "unknown"), ], ) diff --git a/tests/components/daikin/test_zone_climate.py b/tests/components/daikin/test_zone_climate.py index 5508a26d1c1741..2ca160f8a76894 100644 --- a/tests/components/daikin/test_zone_climate.py +++ b/tests/components/daikin/test_zone_climate.py @@ -13,14 +13,22 @@ DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, SERVICE_SET_TEMPERATURE, + ClimateEntityFeature, HVACAction, HVACMode, ) from homeassistant.components.daikin.const import DOMAIN, KEY_MAC +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, CONF_HOST, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant @@ -63,6 +71,17 @@ def _zone_entity_id( ) +def _zone_switch_entity_id( + entity_registry: er.EntityRegistry, zone_device: ZoneDevice, zone_id: int +) -> str | None: + """Return the entity id for a zone switch unique id.""" + return entity_registry.async_get_entity_id( + SWITCH_DOMAIN, + DOMAIN, + f"{zone_device.mac}-zone{zone_id}", + ) + + async def _async_set_zone_temperature( hass: HomeAssistant, entity_id: str, temperature: float ) -> None: @@ -145,12 +164,16 @@ async def test_zone_climate_sets_temperature_for_active_mode( """Setting temperature updates the active mode zone value.""" configure_zone_device( zone_device, - zones=[["Living", "1", 22], ["Office", "1", 21]], + zones=[["Living", "0", 22], ["Office", "1", 21]], mode=mode, ) await _async_setup_daikin(hass, zone_device) entity_id = _zone_entity_id(entity_registry, zone_device, 0) assert entity_id is not None + state = hass.states.get(entity_id) + assert state is not None + assert state.state == HVACMode.OFF + assert state.attributes[ATTR_TEMPERATURE] == 22.0 await _async_set_zone_temperature(hass, entity_id, 23) @@ -302,15 +325,25 @@ async def test_zone_climate_set_temperature_requires_heat_or_cool( assert err.value.translation_key == "zone_hvac_mode_unsupported" +@pytest.mark.parametrize( + ("zone_state", "expected_state", "expected_action"), + [ + pytest.param("1", HVACMode.COOL, HVACAction.COOLING, id="zone-on"), + pytest.param("0", HVACMode.OFF, HVACAction.OFF, id="zone-off"), + ], +) async def test_zone_climate_properties( hass: HomeAssistant, entity_registry: er.EntityRegistry, zone_device: ZoneDevice, + zone_state: str, + expected_state: HVACMode, + expected_action: HVACAction, ) -> None: """Zone climate exposes expected state attributes.""" configure_zone_device( zone_device, - zones=[["Living", "1", 22]], + zones=[["Living", zone_state, 22]], target_temperature=24, mode="cool", heating_values="20", @@ -322,25 +355,173 @@ async def test_zone_climate_properties( state = hass.states.get(entity_id) assert state is not None - assert state.state == HVACMode.COOL - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING + assert state.state == expected_state + assert state.attributes[ATTR_HVAC_ACTION] == expected_action assert state.attributes[ATTR_TEMPERATURE] == 18.0 assert state.attributes[ATTR_MIN_TEMP] == 22.0 assert state.attributes[ATTR_MAX_TEMP] == 26.0 assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.COOL] + assert state.attributes[ATTR_SUPPORTED_FEATURES] == ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) assert state.attributes["zone_id"] == 0 +@pytest.mark.parametrize( + ( + "service", + "initial_zone_state", + "expected_zone_state", + "expected_climate_state", + "expected_switch_state", + "main_mode", + ), + [ + pytest.param( + SERVICE_TURN_ON, + "0", + "1", + HVACMode.COOL, + STATE_ON, + "cool", + id="climate-turn-on", + ), + pytest.param( + SERVICE_TURN_OFF, + "1", + "0", + HVACMode.OFF, + STATE_OFF, + "cool", + id="climate-turn-off", + ), + pytest.param( + SERVICE_TOGGLE, + "1", + "0", + HVACMode.OFF, + STATE_OFF, + "cool", + id="climate-toggle-off", + ), + pytest.param( + SERVICE_TOGGLE, + "1", + "0", + HVACMode.OFF, + STATE_OFF, + "off", + id="climate-toggle-zone-on-main-off", + ), + ], +) +async def test_zone_climate_power_controls( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, + service: str, + initial_zone_state: str, + expected_zone_state: str, + expected_climate_state: HVACMode, + expected_switch_state: str, + main_mode: str, +) -> None: + """Zone climate and switch power controls stay synchronized.""" + configure_zone_device( + zone_device, + zones=[["Living", initial_zone_state, 22]], + mode=main_mode, + ) + + async def set_zone(zone_id: int, key: str, value: str) -> None: + assert key == "zone_onoff" + zone_device.zones[zone_id][1] = value + + zone_device.set_zone.side_effect = set_zone + await _async_setup_daikin(hass, zone_device) + climate_entity_id = _zone_entity_id(entity_registry, zone_device, 0) + switch_entity_id = _zone_switch_entity_id(entity_registry, zone_device, 0) + assert climate_entity_id is not None + assert switch_entity_id is not None + + await hass.services.async_call( + CLIMATE_DOMAIN, + service, + {ATTR_ENTITY_ID: climate_entity_id}, + blocking=True, + ) + + zone_device.set_zone.assert_awaited_once_with(0, "zone_onoff", expected_zone_state) + zone_device.set.assert_not_awaited() + climate_state = hass.states.get(climate_entity_id) + switch_state = hass.states.get(switch_entity_id) + assert climate_state is not None + assert switch_state is not None + assert climate_state.state == expected_climate_state + assert switch_state.state == expected_switch_state + + +async def test_zone_switch_updates_zone_climate( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """The existing zone switch updates the zone climate state.""" + configure_zone_device( + zone_device, + zones=[["Living", "1", 22]], + mode="cool", + ) + + async def set_zone(zone_id: int, key: str, value: str) -> None: + assert key == "zone_onoff" + zone_device.zones[zone_id][1] = value + + zone_device.set_zone.side_effect = set_zone + await _async_setup_daikin(hass, zone_device) + climate_entity_id = _zone_entity_id(entity_registry, zone_device, 0) + switch_entity_id = _zone_switch_entity_id(entity_registry, zone_device, 0) + assert climate_entity_id is not None + assert switch_entity_id is not None + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: switch_entity_id}, + blocking=True, + ) + + zone_device.set_zone.assert_awaited_once_with(0, "zone_onoff", "0") + zone_device.set.assert_not_awaited() + climate_state = hass.states.get(climate_entity_id) + switch_state = hass.states.get(switch_entity_id) + assert climate_state is not None + assert switch_state is not None + assert climate_state.state == HVACMode.OFF + assert switch_state.state == STATE_OFF + + +@pytest.mark.parametrize( + ("mode", "expected_state"), + [ + pytest.param("auto", HVACMode.HEAT_COOL, id="auto"), + pytest.param("off", HVACMode.OFF, id="off"), + ], +) async def test_zone_climate_target_temperature_inactive_mode( hass: HomeAssistant, entity_registry: er.EntityRegistry, zone_device: ZoneDevice, + mode: str, + expected_state: HVACMode, ) -> None: """In non-heating/cooling modes, zone target temperature is None.""" configure_zone_device( zone_device, zones=[["Living", "1", 22]], - mode="auto", + mode=mode, heating_values="bad", cooling_values="19", ) @@ -350,7 +531,7 @@ async def test_zone_climate_target_temperature_inactive_mode( state = hass.states.get(entity_id) assert state is not None - assert state.state == HVACMode.HEAT_COOL + assert state.state == expected_state assert state.attributes[ATTR_TEMPERATURE] is None diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index fe820e8b6d3829..4341b58f1dcbc1 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -323,6 +323,47 @@ async def test_registered_devices( ) +async def test_registered_devices_multiple_config_entries_same_mac( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test registered-device rediscovery covers every config entry sharing a MAC.""" + integration_matchers = dhcp.async_index_integration_matchers( + [ + {"domain": "mock-domain-a", "registered_devices": True}, + {"domain": "mock-domain-b", "registered_devices": True}, + ] + ) + + packet = Ether(RAW_DHCP_RENEWAL) + + # Two config entries each own a device for the same MAC; both must be rediscovered. + config_entry_a = MockConfigEntry(domain="mock-domain-a", data={}) + config_entry_a.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry_a.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "50147903852c")}, + name="name", + ) + config_entry_b = MockConfigEntry(domain="mock-domain-b", data={}) + config_entry_b.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry_b.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "50147903852c")}, + name="name", + ) + + async_handle_dhcp_packet = await _async_get_handle_dhcp_packet( + hass, integration_matchers + ) + with patch.object(hass.config_entries.flow, "async_init") as mock_init: + await async_handle_dhcp_packet(packet) + + assert {call[1][0] for call in mock_init.mock_calls} == { + "mock-domain-a", + "mock-domain-b", + } + + async def test_dhcp_match_hostname(hass: HomeAssistant) -> None: """Test matching based on hostname only.""" integration_matchers = dhcp.async_index_integration_matchers( diff --git a/tests/components/doorbird/conftest.py b/tests/components/doorbird/conftest.py index bcdcb49b72841a..897f4ac529ceea 100644 --- a/tests/components/doorbird/conftest.py +++ b/tests/components/doorbird/conftest.py @@ -38,17 +38,22 @@ def doorbird_info() -> dict[str, Any]: return load_json_value_fixture("info.json", "doorbird")["BHA"]["VERSION"][0] -@pytest.fixture(scope="package") +@pytest.fixture def doorbird_schedule() -> list[DoorBirdScheduleEntry]: - """Return a loaded DoorBird schedule fixture.""" + """Return a freshly parsed DoorBird schedule fixture. + + Function-scoped because the integration mutates schedule entries in place + via `_configure_unconfigured_favorites` — sharing one instance across tests + would let earlier tests poison later ones. + """ return DoorBirdScheduleEntry.parse_all( load_json_value_fixture("schedule.json", "doorbird") ) -@pytest.fixture(scope="package") +@pytest.fixture def doorbird_schedule_wrong_param() -> list[DoorBirdScheduleEntry]: - """Return a loaded DoorBird schedule fixture with an incorrect param.""" + """Return a freshly parsed DoorBird schedule fixture with an incorrect param.""" return DoorBirdScheduleEntry.parse_all( load_json_value_fixture("schedule_wrong_param.json", "doorbird") ) diff --git a/tests/components/doorbird/test_image.py b/tests/components/doorbird/test_image.py new file mode 100644 index 00000000000000..74014672c45b5d --- /dev/null +++ b/tests/components/doorbird/test_image.py @@ -0,0 +1,104 @@ +"""Test DoorBird image entities.""" + +from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import mock_webhook_call +from .conftest import DoorbirdMockerType + +from tests.typing import ClientSessionGenerator + +# A body whose first 4 bytes are a recognized JPEG magic number, so +# infer_image_type accepts it. The trailing bytes are arbitrary padding. +VALID_JPEG = b"\xff\xd8\xff\xe0junk" + + +async def test_image_entities_registered( + hass: HomeAssistant, + doorbird_mocker: DoorbirdMockerType, + entity_registry: er.EntityRegistry, +) -> None: + """Both last_motion and last_ring image entities are registered.""" + await doorbird_mocker() + last_motion = hass.states.get("image.mydoorbird_last_motion") + last_ring = hass.states.get("image.mydoorbird_last_ring") + assert last_motion is not None + assert last_ring is not None + # No event has fired yet, so image_last_updated is None → state is unknown. + assert last_motion.state == STATE_UNKNOWN + assert last_ring.state == STATE_UNKNOWN + assert ( + entity_registry.async_get("image.mydoorbird_last_motion").unique_id + == "1234ABCD_last_motion" + ) + assert ( + entity_registry.async_get("image.mydoorbird_last_ring").unique_id + == "1234ABCD_last_ring" + ) + + +async def test_image_updates_on_event( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + doorbird_mocker: DoorbirdMockerType, +) -> None: + """Receiving a doorbird event bumps image_last_updated on the matching image.""" + doorbird_entry = await doorbird_mocker() + client = await hass_client() + + assert hass.states.get("image.mydoorbird_last_ring").state == STATE_UNKNOWN + assert hass.states.get("image.mydoorbird_last_motion").state == STATE_UNKNOWN + + await mock_webhook_call(doorbird_entry.entry, client, "mydoorbird_doorbell") + await hass.async_block_till_done() + + # Ring event only updates the ring image. + ring_state = hass.states.get("image.mydoorbird_last_ring").state + motion_state = hass.states.get("image.mydoorbird_last_motion").state + assert ring_state != STATE_UNKNOWN + assert motion_state == STATE_UNKNOWN + + await mock_webhook_call(doorbird_entry.entry, client, "mydoorbird_motion") + await hass.async_block_till_done() + + assert hass.states.get("image.mydoorbird_last_motion").state != STATE_UNKNOWN + + +async def test_image_entity_fetches_bytes( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + doorbird_mocker: DoorbirdMockerType, +) -> None: + """The image proxy returns bytes fetched from the device.""" + doorbird_entry = await doorbird_mocker() + doorbird_entry.api.get_image.return_value = VALID_JPEG + client = await hass_client() + + state = hass.states.get("image.mydoorbird_last_ring") + access_token = state.attributes["access_token"] + resp = await client.get( + f"/api/{IMAGE_DOMAIN}_proxy/image.mydoorbird_last_ring?token={access_token}" + ) + assert resp.status == 200 + assert await resp.read() == VALID_JPEG + assert doorbird_entry.api.get_image.called + + +async def test_image_rejects_non_image_body( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + doorbird_mocker: DoorbirdMockerType, +) -> None: + """A body that is not a recognized image is rejected instead of cached.""" + doorbird_entry = await doorbird_mocker() + doorbird_entry.api.get_image.return_value = b"error" + client = await hass_client() + + state = hass.states.get("image.mydoorbird_last_ring") + access_token = state.attributes["access_token"] + resp = await client.get( + f"/api/{IMAGE_DOMAIN}_proxy/image.mydoorbird_last_ring?token={access_token}" + ) + assert resp.status == 500 diff --git a/tests/components/ecovacs/fixtures/devices/9eamof/device.json b/tests/components/ecovacs/fixtures/devices/9eamof/device.json new file mode 100644 index 00000000000000..d4bd01dd047b5f --- /dev/null +++ b/tests/components/ecovacs/fixtures/devices/9eamof/device.json @@ -0,0 +1,29 @@ +{ + "did": "8516fbb1-17f1-4194-0000002", + "name": "E1234567890000000004", + "class": "9eamof", + "resource": "NHl6", + "company": "eco-ng", + "bindTs": 1734792100200, + "service": { + "jmq": "jmq-ngiot-eu.dc.ww.ecouser.net", + "mqs": "api-ngiot.dc-eu.ww.ecouser.net" + }, + "deviceName": "DEEBOT T80 OMNI", + "icon": "https: //portal-ww.ecouser.net/api/pim/file/get/0000002", + "ota": true, + "UILogicId": "t8_ww_h_t80", + "materialNo": "110-2304-0001", + "pid": "0000002", + "product_category": "DEEBOT", + "model": "T80_OMNI_INT", + "updateInfo": { + "needUpdate": false, + "changeLog": "" + }, + "nick": "T80 OMNI", + "homeId": "1234567890abcdef12345678", + "homeSort": 2, + "status": 1, + "otaUpgrade": {} +} diff --git a/tests/components/ecovacs/snapshots/test_button.ambr b/tests/components/ecovacs/snapshots/test_button.ambr index 0242d12b69641a..5eae800a85bb9e 100644 --- a/tests/components/ecovacs/snapshots/test_button.ambr +++ b/tests/components/ecovacs/snapshots/test_button.ambr @@ -99,6 +99,456 @@ 'state': '2024-01-01T00:00:00+00:00', }) # --- +# name: test_buttons[9eamof][button.t80_omni_empty_dustbin:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.t80_omni_empty_dustbin', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Empty dustbin', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Empty dustbin', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'station_action_empty_dustbin', + 'unique_id': '8516fbb1-17f1-4194-0000002_station_action_empty_dustbin', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[9eamof][button.t80_omni_empty_dustbin:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'T80 OMNI Empty dustbin', + }), + 'context': , + 'entity_id': 'button.t80_omni_empty_dustbin', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[9eamof][button.t80_omni_relocate:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.t80_omni_relocate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Relocate', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Relocate', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relocate', + 'unique_id': '8516fbb1-17f1-4194-0000002_relocate', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[9eamof][button.t80_omni_relocate:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'T80 OMNI Relocate', + }), + 'context': , + 'entity_id': 'button.t80_omni_relocate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[9eamof][button.t80_omni_reset_cleaning_solution_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.t80_omni_reset_cleaning_solution_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reset cleaning solution lifespan', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset cleaning solution lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_lifespan_cleaning_solution', + 'unique_id': '8516fbb1-17f1-4194-0000002_reset_lifespan_cleaning_solution', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[9eamof][button.t80_omni_reset_cleaning_solution_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'T80 OMNI Reset cleaning solution lifespan', + }), + 'context': , + 'entity_id': 'button.t80_omni_reset_cleaning_solution_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[9eamof][button.t80_omni_reset_filter_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.t80_omni_reset_filter_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reset filter lifespan', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset filter lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_lifespan_filter', + 'unique_id': '8516fbb1-17f1-4194-0000002_reset_lifespan_filter', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[9eamof][button.t80_omni_reset_filter_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'T80 OMNI Reset filter lifespan', + }), + 'context': , + 'entity_id': 'button.t80_omni_reset_filter_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[9eamof][button.t80_omni_reset_hand_filter_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.t80_omni_reset_hand_filter_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reset hand filter lifespan', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset hand filter lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_lifespan_hand_filter', + 'unique_id': '8516fbb1-17f1-4194-0000002_reset_lifespan_hand_filter', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[9eamof][button.t80_omni_reset_hand_filter_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'T80 OMNI Reset hand filter lifespan', + }), + 'context': , + 'entity_id': 'button.t80_omni_reset_hand_filter_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[9eamof][button.t80_omni_reset_main_brush_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.t80_omni_reset_main_brush_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reset main brush lifespan', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset main brush lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_lifespan_brush', + 'unique_id': '8516fbb1-17f1-4194-0000002_reset_lifespan_brush', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[9eamof][button.t80_omni_reset_main_brush_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'T80 OMNI Reset main brush lifespan', + }), + 'context': , + 'entity_id': 'button.t80_omni_reset_main_brush_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[9eamof][button.t80_omni_reset_sewage_box_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.t80_omni_reset_sewage_box_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reset sewage box lifespan', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset sewage box lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_lifespan_sewage_box', + 'unique_id': '8516fbb1-17f1-4194-0000002_reset_lifespan_sewage_box', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[9eamof][button.t80_omni_reset_sewage_box_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'T80 OMNI Reset sewage box lifespan', + }), + 'context': , + 'entity_id': 'button.t80_omni_reset_sewage_box_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[9eamof][button.t80_omni_reset_side_brush_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.t80_omni_reset_side_brush_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reset side brush lifespan', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset side brush lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_lifespan_side_brush', + 'unique_id': '8516fbb1-17f1-4194-0000002_reset_lifespan_side_brush', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[9eamof][button.t80_omni_reset_side_brush_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'T80 OMNI Reset side brush lifespan', + }), + 'context': , + 'entity_id': 'button.t80_omni_reset_side_brush_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[9eamof][button.t80_omni_reset_unit_care_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.t80_omni_reset_unit_care_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reset unit care lifespan', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset unit care lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_lifespan_unit_care', + 'unique_id': '8516fbb1-17f1-4194-0000002_reset_lifespan_unit_care', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[9eamof][button.t80_omni_reset_unit_care_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'T80 OMNI Reset unit care lifespan', + }), + 'context': , + 'entity_id': 'button.t80_omni_reset_unit_care_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- # name: test_buttons[qhe2o2][button.dusty_empty_dustbin:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/ecovacs/snapshots/test_sensor.ambr b/tests/components/ecovacs/snapshots/test_sensor.ambr index f260fec4f34662..a015e9f68c9c32 100644 --- a/tests/components/ecovacs/snapshots/test_sensor.ambr +++ b/tests/components/ecovacs/snapshots/test_sensor.ambr @@ -856,6 +856,968 @@ 'state': 'Testnetwork', }) # --- +# name: test_sensors[9eamof][sensor.t80_omni_area_cleaned:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.t80_omni_area_cleaned', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Area cleaned', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Area cleaned', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'stats_area', + 'unique_id': '8516fbb1-17f1-4194-0000002_stats_area', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_area_cleaned:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'area', + : 'T80 OMNI Area cleaned', + : , + }), + 'context': , + 'entity_id': 'sensor.t80_omni_area_cleaned', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_battery:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.t80_omni_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '8516fbb1-17f1-4194-0000002_battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_battery:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'battery', + : 'T80 OMNI Battery', + : '%', + }), + 'context': , + 'entity_id': 'sensor.t80_omni_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_cleaning_duration:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.t80_omni_cleaning_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cleaning duration', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cleaning duration', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'stats_time', + 'unique_id': '8516fbb1-17f1-4194-0000002_stats_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_cleaning_duration:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'T80 OMNI Cleaning duration', + : , + }), + 'context': , + 'entity_id': 'sensor.t80_omni_cleaning_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_cleaning_solution_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.t80_omni_cleaning_solution_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cleaning solution lifespan', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cleaning solution lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifespan_cleaning_solution', + 'unique_id': '8516fbb1-17f1-4194-0000002_lifespan_cleaning_solution', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_cleaning_solution_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'T80 OMNI Cleaning solution lifespan', + : '%', + }), + 'context': , + 'entity_id': 'sensor.t80_omni_cleaning_solution_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_error:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.t80_omni_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Error', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Error', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'error', + 'unique_id': '8516fbb1-17f1-4194-0000002_error', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_error:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'description': 'NoError: Robot is operational', + : 'T80 OMNI Error', + }), + 'context': , + 'entity_id': 'sensor.t80_omni_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_filter_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.t80_omni_filter_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Filter lifespan', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifespan_filter', + 'unique_id': '8516fbb1-17f1-4194-0000002_lifespan_filter', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_filter_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'T80 OMNI Filter lifespan', + : '%', + }), + 'context': , + 'entity_id': 'sensor.t80_omni_filter_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '56', + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_hand_filter_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.t80_omni_hand_filter_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hand filter lifespan', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hand filter lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifespan_hand_filter', + 'unique_id': '8516fbb1-17f1-4194-0000002_lifespan_hand_filter', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_hand_filter_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'T80 OMNI Hand filter lifespan', + : '%', + }), + 'context': , + 'entity_id': 'sensor.t80_omni_hand_filter_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_ip_address:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.t80_omni_ip_address', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'IP address', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'IP address', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'network_ip', + 'unique_id': '8516fbb1-17f1-4194-0000002_network_ip', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_ip_address:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'T80 OMNI IP address', + }), + 'context': , + 'entity_id': 'sensor.t80_omni_ip_address', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '192.168.0.10', + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_main_brush_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.t80_omni_main_brush_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Main brush lifespan', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Main brush lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifespan_brush', + 'unique_id': '8516fbb1-17f1-4194-0000002_lifespan_brush', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_main_brush_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'T80 OMNI Main brush lifespan', + : '%', + }), + 'context': , + 'entity_id': 'sensor.t80_omni_main_brush_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_sewage_box_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.t80_omni_sewage_box_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sewage box lifespan', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sewage box lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifespan_sewage_box', + 'unique_id': '8516fbb1-17f1-4194-0000002_lifespan_sewage_box', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_sewage_box_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'T80 OMNI Sewage box lifespan', + : '%', + }), + 'context': , + 'entity_id': 'sensor.t80_omni_sewage_box_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '75', + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_side_brush_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.t80_omni_side_brush_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Side brush lifespan', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Side brush lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifespan_side_brush', + 'unique_id': '8516fbb1-17f1-4194-0000002_lifespan_side_brush', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_side_brush_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'T80 OMNI Side brush lifespan', + : '%', + }), + 'context': , + 'entity_id': 'sensor.t80_omni_side_brush_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_station_state:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'idle', + 'emptying_dustbin', + 'washing_mop', + 'drying_mop', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.t80_omni_station_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Station state', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Station state', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'station_state', + 'unique_id': '8516fbb1-17f1-4194-0000002_station_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_station_state:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'T80 OMNI Station state', + : list([ + 'idle', + 'emptying_dustbin', + 'washing_mop', + 'drying_mop', + ]), + }), + 'context': , + 'entity_id': 'sensor.t80_omni_station_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'emptying_dustbin', + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_total_area_cleaned:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.t80_omni_total_area_cleaned', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Total area cleaned', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total area cleaned', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_stats_area', + 'unique_id': '8516fbb1-17f1-4194-0000002_total_stats_area', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_total_area_cleaned:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'area', + : 'T80 OMNI Total area cleaned', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.t80_omni_total_area_cleaned', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_total_cleaning_duration:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.t80_omni_total_cleaning_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Total cleaning duration', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total cleaning duration', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_stats_time', + 'unique_id': '8516fbb1-17f1-4194-0000002_total_stats_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_total_cleaning_duration:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'T80 OMNI Total cleaning duration', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.t80_omni_total_cleaning_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40.0', + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_total_cleanings:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.t80_omni_total_cleanings', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Total cleanings', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total cleanings', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_stats_cleanings', + 'unique_id': '8516fbb1-17f1-4194-0000002_total_stats_cleanings', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_total_cleanings:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'T80 OMNI Total cleanings', + : , + }), + 'context': , + 'entity_id': 'sensor.t80_omni_total_cleanings', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123', + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_unit_care_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.t80_omni_unit_care_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Unit care lifespan', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Unit care lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifespan_unit_care', + 'unique_id': '8516fbb1-17f1-4194-0000002_lifespan_unit_care', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_unit_care_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'T80 OMNI Unit care lifespan', + : '%', + }), + 'context': , + 'entity_id': 'sensor.t80_omni_unit_care_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_wi_fi_rssi:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.t80_omni_wi_fi_rssi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wi-Fi RSSI', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi RSSI', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'network_rssi', + 'unique_id': '8516fbb1-17f1-4194-0000002_network_rssi', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_wi_fi_rssi:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'T80 OMNI Wi-Fi RSSI', + }), + 'context': , + 'entity_id': 'sensor.t80_omni_wi_fi_rssi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-62', + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_wi_fi_ssid:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.t80_omni_wi_fi_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wi-Fi SSID', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi SSID', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'network_ssid', + 'unique_id': '8516fbb1-17f1-4194-0000002_network_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[9eamof][sensor.t80_omni_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'T80 OMNI Wi-Fi SSID', + }), + 'context': , + 'entity_id': 'sensor.t80_omni_wi_fi_ssid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Testnetwork', + }) +# --- # name: test_sensors[qhe2o2][sensor.dusty_area_cleaned:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/ecovacs/test_button.py b/tests/components/ecovacs/test_button.py index d06be906d18f02..2e1938adee6bf5 100644 --- a/tests/components/ecovacs/test_button.py +++ b/tests/components/ecovacs/test_button.py @@ -95,8 +95,46 @@ def platforms() -> Platform | list[Platform]: ), ], ), + ( + "9eamof", + [ + ("button.t80_omni_relocate", SetRelocationState()), + ( + "button.t80_omni_reset_main_brush_lifespan", + ResetLifeSpan(LifeSpan.BRUSH), + ), + ( + "button.t80_omni_reset_cleaning_solution_lifespan", + ResetLifeSpan(LifeSpan.CLEANING_SOLUTION), + ), + ( + "button.t80_omni_reset_filter_lifespan", + ResetLifeSpan(LifeSpan.FILTER), + ), + ( + "button.t80_omni_reset_hand_filter_lifespan", + ResetLifeSpan(LifeSpan.HAND_FILTER), + ), + ( + "button.t80_omni_reset_sewage_box_lifespan", + ResetLifeSpan(LifeSpan.SEWAGE_BOX), + ), + ( + "button.t80_omni_reset_side_brush_lifespan", + ResetLifeSpan(LifeSpan.SIDE_BRUSH), + ), + ( + "button.t80_omni_reset_unit_care_lifespan", + ResetLifeSpan(LifeSpan.UNIT_CARE), + ), + ( + "button.t80_omni_empty_dustbin", + station_action.StationAction(StationAction.EMPTY_DUSTBIN), + ), + ], + ), ], - ids=["yna5x1", "5xu9h3", "qhe2o2"], + ids=["yna5x1", "5xu9h3", "qhe2o2", "9eamof"], ) async def test_buttons( hass: HomeAssistant, diff --git a/tests/components/ecovacs/test_sensor.py b/tests/components/ecovacs/test_sensor.py index 056d951a8386bc..8ef76e215d4d74 100644 --- a/tests/components/ecovacs/test_sensor.py +++ b/tests/components/ecovacs/test_sensor.py @@ -45,6 +45,8 @@ async def notify_events(hass: HomeAssistant, event_bus: EventBus): event_bus.notify(LifeSpanEvent(LifeSpan.BRUSH, 80, 60 * 60)) event_bus.notify(LifeSpanEvent(LifeSpan.FILTER, 56, 40 * 60)) event_bus.notify(LifeSpanEvent(LifeSpan.SIDE_BRUSH, 40, 20 * 60)) + event_bus.notify(LifeSpanEvent(LifeSpan.CLEANING_SOLUTION, 100, 100)) + event_bus.notify(LifeSpanEvent(LifeSpan.SEWAGE_BOX, 75, 2700)) event_bus.notify(ErrorEvent(0, "NoError: Robot is operational")) event_bus.notify(station.StationEvent(station.State.EMPTYING_DUSTBIN)) await block_till_done(hass, event_bus) @@ -110,8 +112,31 @@ async def notify_events(hass: HomeAssistant, event_bus: EventBus): "sensor.dusty_error", ], ), + ( + "9eamof", + [ + "sensor.t80_omni_area_cleaned", + "sensor.t80_omni_cleaning_duration", + "sensor.t80_omni_total_area_cleaned", + "sensor.t80_omni_total_cleaning_duration", + "sensor.t80_omni_total_cleanings", + "sensor.t80_omni_battery", + "sensor.t80_omni_ip_address", + "sensor.t80_omni_wi_fi_rssi", + "sensor.t80_omni_wi_fi_ssid", + "sensor.t80_omni_station_state", + "sensor.t80_omni_main_brush_lifespan", + "sensor.t80_omni_cleaning_solution_lifespan", + "sensor.t80_omni_filter_lifespan", + "sensor.t80_omni_hand_filter_lifespan", + "sensor.t80_omni_sewage_box_lifespan", + "sensor.t80_omni_side_brush_lifespan", + "sensor.t80_omni_unit_care_lifespan", + "sensor.t80_omni_error", + ], + ), ], - ids=["yna5x1", "5xu9h3", "qhe2o2"], + ids=["yna5x1", "5xu9h3", "qhe2o2", "9eamof"], ) async def test_sensors( hass: HomeAssistant, diff --git a/tests/components/edifier_infrared/conftest.py b/tests/components/edifier_infrared/conftest.py index 147074c40cc60c..ccb0e0b9bd44d4 100644 --- a/tests/components/edifier_infrared/conftest.py +++ b/tests/components/edifier_infrared/conftest.py @@ -35,7 +35,7 @@ def mock_config_entry() -> MockConfigEntry: CONF_COMMAND_SET: EdifierCommandSet.R1700BTS.value, }, unique_id=f"r1700bts_{MOCK_INFRARED_EMITTER_ENTITY_ID}", - version=2, + version=3, ) diff --git a/tests/components/edifier_infrared/test_config_flow.py b/tests/components/edifier_infrared/test_config_flow.py index afe7dc0fd7d829..5a3f03242290f2 100644 --- a/tests/components/edifier_infrared/test_config_flow.py +++ b/tests/components/edifier_infrared/test_config_flow.py @@ -26,6 +26,8 @@ (EdifierModel.R1700BTS, EdifierCommandSet.R1700BTS), (EdifierModel.R1280DB, EdifierCommandSet.R1280DB), (EdifierModel.R1280T, EdifierCommandSet.R1280T), + (EdifierModel.R2000DB, EdifierCommandSet.R2000DB), + (EdifierModel.R2730DB, EdifierCommandSet.R2730DB), (EdifierModel.S360DB, EdifierCommandSet.S360DB), (EdifierModel.RC20G, EdifierCommandSet.RC20G), (EdifierModel.S3000PRO, EdifierCommandSet.S3000PRO), diff --git a/tests/components/edifier_infrared/test_init.py b/tests/components/edifier_infrared/test_init.py index 036727af2a11d7..c79882f9df47c5 100644 --- a/tests/components/edifier_infrared/test_init.py +++ b/tests/components/edifier_infrared/test_init.py @@ -45,7 +45,7 @@ async def test_setup_and_unload_entry( ], ) @pytest.mark.usefixtures("mock_infrared_emitter_entity", "mock_edifier_code_to_command") -async def test_migrate_entry_v1_to_v2( +async def test_migrate_entry_from_v1( hass: HomeAssistant, old_model: str, old_command_set: str, @@ -70,7 +70,47 @@ async def test_migrate_entry_v1_to_v2( await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED - assert entry.version == 2 + assert entry.version == 3 assert entry.data[CONF_MODEL] == expected_model assert entry.data[CONF_COMMAND_SET] == expected_command_set assert entry.unique_id == f"{expected_command_set}_{EMITTER_ENTITY_ID}" + + +@pytest.mark.parametrize( + ("model", "old_command_set", "expected_command_set"), + [ + pytest.param("R2000DB", "r1280db", "r2000db", id="r2000db-split"), + pytest.param("R2730DB", "r1280db", "r2730db", id="r2730db-split"), + pytest.param("RC10D1", "r1280db", "r2730db", id="rc10d1-split"), + pytest.param("R1280DB", "r1280db", "r1280db", id="unchanged-model"), + ], +) +@pytest.mark.usefixtures("mock_infrared_emitter_entity", "mock_edifier_code_to_command") +async def test_migrate_entry_from_v2( + hass: HomeAssistant, + model: str, + old_command_set: str, + expected_command_set: str, +) -> None: + """Test v2 config entries are migrated to the split R2000DB command set.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=f"Edifier {model} via Test IR emitter", + data={ + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + CONF_MODEL: model, + CONF_COMMAND_SET: old_command_set, + }, + unique_id=f"{old_command_set}_{EMITTER_ENTITY_ID}", + version=2, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert entry.version == 3 + assert entry.data[CONF_MODEL] == model + assert entry.data[CONF_COMMAND_SET] == expected_command_set + assert entry.unique_id == f"{expected_command_set}_{EMITTER_ENTITY_ID}" diff --git a/tests/components/eurotronic_cometblue/test_init.py b/tests/components/eurotronic_cometblue/test_init.py index ed544950171efc..a4fd8bb62dba6d 100644 --- a/tests/components/eurotronic_cometblue/test_init.py +++ b/tests/components/eurotronic_cometblue/test_init.py @@ -1,8 +1,12 @@ """Test the Eurotronic Comet Blue integration setup.""" +from unittest.mock import patch + +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.eurotronic_cometblue.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -23,3 +27,29 @@ async def test_device_registry( device_entry = device_registry.async_get_device(identifiers={(DOMAIN, FIXTURE_MAC)}) assert device_entry == snapshot + + +async def test_setup_retries_when_device_not_found( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test logging when no device is found.""" + + mock_config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.eurotronic_cometblue.async_ble_device_from_address", + return_value=None, + ), + patch( + "homeassistant.components.eurotronic_cometblue.async_address_reachability_diagnostics", + return_value="mock reachability reason", + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert "aa:bb:cc:dd:ee:ff: mock reachability reason" in caplog.text diff --git a/tests/components/fish_audio/test_config_flow.py b/tests/components/fish_audio/test_config_flow.py index d0e73a2cf05919..ef7b11185bcb6f 100644 --- a/tests/components/fish_audio/test_config_flow.py +++ b/tests/components/fish_audio/test_config_flow.py @@ -10,6 +10,7 @@ CONF_LATENCY, CONF_SELF_ONLY, CONF_SORT_BY, + CONF_SPEED, CONF_TITLE, CONF_USER_ID, CONF_VOICE_ID, @@ -146,6 +147,7 @@ async def test_subflow_happy_path( CONF_VOICE_ID: "voice-alpha", CONF_BACKEND: "s1", CONF_LATENCY: "balanced", + CONF_SPEED: 1.5, CONF_NAME: "My Custom Voice", }, ) @@ -155,6 +157,7 @@ async def test_subflow_happy_path( assert result["data"][CONF_VOICE_ID] == "voice-alpha" assert result["data"][CONF_BACKEND] == "s1" assert result["data"][CONF_LATENCY] == "balanced" + assert result["data"][CONF_SPEED] == 1.5 assert result["unique_id"] == "voice-alpha-s1" entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) @@ -250,12 +253,14 @@ async def test_subflow_reconfigure( CONF_VOICE_ID: "voice-gamma", CONF_BACKEND: "s1", CONF_LATENCY: "normal", + CONF_SPEED: 1.3, CONF_NAME: "Updated Voice", }, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.subentries[subentry.subentry_id].data[CONF_SPEED] == 1.3 async def test_subflow_reconfigure_already_configured( diff --git a/tests/components/fish_audio/test_tts.py b/tests/components/fish_audio/test_tts.py index 09113640891f86..1ca9ffbcd38551 100644 --- a/tests/components/fish_audio/test_tts.py +++ b/tests/components/fish_audio/test_tts.py @@ -9,14 +9,21 @@ import pytest from homeassistant.components import tts -from homeassistant.components.fish_audio.const import CONF_BACKEND, DOMAIN +from homeassistant.components.fish_audio.const import ( + CONF_BACKEND, + CONF_LATENCY, + CONF_SPEED, + CONF_VOICE_ID, + DEFAULT_SPEED, + DOMAIN, +) from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, DOMAIN as MP_DOMAIN, SERVICE_PLAY_MEDIA, ) from homeassistant.config_entries import ConfigSubentryData -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core_config import async_process_ha_core_config from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -176,6 +183,109 @@ async def test_tts_supported_languages( ] +@pytest.mark.parametrize( + ("options", "expected_speed"), + [ + pytest.param({}, DEFAULT_SPEED, id="default"), + pytest.param({CONF_SPEED: 1.5}, 1.5, id="per_call_faster"), + pytest.param({CONF_SPEED: 0.75}, 0.75, id="per_call_slower"), + ], +) +async def test_tts_speed_option( + hass: HomeAssistant, + mock_fishaudio_client: AsyncMock, + mock_config_entry: MockConfigEntry, + options: dict[str, float], + expected_speed: float, +) -> None: + """Test the speech speed per-call option is forwarded to the client.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity = hass.data[tts.DOMAIN].get_entity("tts.test_voice_test_voice") + assert entity is not None + + await entity.async_get_tts_audio( + message="Hello world", + language="en", + options=options, + ) + + assert mock_fishaudio_client.tts.convert.call_args.kwargs["speed"] == expected_speed + + +async def test_tts_speed_from_subentry( + hass: HomeAssistant, + mock_fishaudio_client: AsyncMock, +) -> None: + """Test the configured per-voice speed is used without a per-call option. + + This is the Assist pipeline path, which does not pass per-call options. + """ + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "test-api-key"}, + unique_id="test_user", + subentries_data=[ + ConfigSubentryData( + data={ + CONF_VOICE_ID: "voice-123", + CONF_BACKEND: "s1", + CONF_LATENCY: "balanced", + CONF_SPEED: 1.25, + }, + subentry_type="tts", + title="Test Voice", + subentry_id="test-subentry-id", + unique_id="voice-123-s1", + ) + ], + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity = hass.data[tts.DOMAIN].get_entity("tts.test_voice_test_voice") + assert entity is not None + + # The configured speed is exposed as a default option so it is part of the cache key. + assert entity.default_options == {CONF_SPEED: 1.25} + + await entity.async_get_tts_audio( + message="Hello world", + language="en", + options={}, + ) + + assert mock_fishaudio_client.tts.convert.call_args.kwargs["speed"] == 1.25 + + +@pytest.mark.parametrize("speed", [0.1, 5.0]) +async def test_tts_speed_out_of_range( + hass: HomeAssistant, + mock_fishaudio_client: AsyncMock, + mock_config_entry: MockConfigEntry, + speed: float, +) -> None: + """Test an out-of-range per-call speed raises a validation error.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity = hass.data[tts.DOMAIN].get_entity("tts.test_voice_test_voice") + assert entity is not None + + with pytest.raises(ServiceValidationError): + await entity.async_get_tts_audio( + message="Hello world", + language="en", + options={CONF_SPEED: speed}, + ) + + mock_fishaudio_client.tts.convert.assert_not_called() + + # Service-level integration tests diff --git a/tests/components/go2rtc/conftest.py b/tests/components/go2rtc/conftest.py index 12292a75221d60..41d2f03031f1c9 100644 --- a/tests/components/go2rtc/conftest.py +++ b/tests/components/go2rtc/conftest.py @@ -5,7 +5,12 @@ from unittest.mock import AsyncMock, Mock, patch from awesomeversion import AwesomeVersion -from go2rtc_client.rest import _SchemesClient, _StreamClient, _WebRTCClient +from go2rtc_client.rest import ( + _PreloadClient, + _SchemesClient, + _StreamClient, + _WebRTCClient, +) import pytest from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN @@ -63,6 +68,8 @@ def rest_client() -> Generator[AsyncMock]: return_value=AwesomeVersion(RECOMMENDED_VERSION) ) client.webrtc = Mock(spec_set=_WebRTCClient) + client.preload = preload = Mock(spec_set=_PreloadClient) + preload.list.return_value = {} yield client diff --git a/tests/components/go2rtc/snapshots/test_server.ambr b/tests/components/go2rtc/snapshots/test_server.ambr index 61f5115e8ea2a6..e39b297b5f0a55 100644 --- a/tests/components/go2rtc/snapshots/test_server.ambr +++ b/tests/components/go2rtc/snapshots/test_server.ambr @@ -3,7 +3,7 @@ _CallList([ _Call( tuple( - b'# This file is managed by Home Assistant\n# Do not edit it manually\n\napp:\n modules: ["api","exec","ffmpeg","http","mjpeg","onvif","rtmp","rtsp","srtp","webrtc","ws"]\n\napi:\n listen: ""\n unix_listen: "/test/path/go2rtc.sock"\n allow_paths: ["/","/api","/api/frame.jpeg","/api/schemes","/api/streams","/api/webrtc","/api/ws"]\n local_auth: true\n username: d2a0b844f4cdbe773702176c47c9a675eb0c56a0779b8f880cdb3b492ed3b1c1\n password: bc495d266a32e66ba69b9c72546e00101e04fb573f1bd08863fe4ad1aac02949\n\n# ffmpeg needs the exec module\n# Restrict execution to only ffmpeg binary\nexec:\n allow_paths:\n - ffmpeg\n\nrtsp:\n listen: "127.0.0.1:18554"\n\nwebrtc:\n listen: ":18555/tcp"\n ice_servers: []\n', + b'# This file is managed by Home Assistant\n# Do not edit it manually\n\napp:\n modules: ["api","exec","ffmpeg","http","mjpeg","onvif","rtmp","rtsp","srtp","webrtc","ws"]\n\napi:\n listen: ""\n unix_listen: "/test/path/go2rtc.sock"\n allow_paths: ["/","/api","/api/frame.jpeg","/api/preload","/api/schemes","/api/streams","/api/webrtc","/api/ws"]\n local_auth: true\n username: d2a0b844f4cdbe773702176c47c9a675eb0c56a0779b8f880cdb3b492ed3b1c1\n password: bc495d266a32e66ba69b9c72546e00101e04fb573f1bd08863fe4ad1aac02949\n\n# ffmpeg needs the exec module\n# Restrict execution to only ffmpeg binary\nexec:\n allow_paths:\n - ffmpeg\n\nrtsp:\n listen: "127.0.0.1:18554"\n\nwebrtc:\n listen: ":18555/tcp"\n ice_servers: []\n', ), dict({ }), @@ -14,7 +14,7 @@ _CallList([ _Call( tuple( - b'# This file is managed by Home Assistant\n# Do not edit it manually\n\napp:\n modules: ["api","exec","ffmpeg","http","mjpeg","onvif","rtmp","rtsp","srtp","webrtc","ws","debug"]\n\napi:\n listen: ":11984"\n unix_listen: "/test/path/go2rtc.sock"\n allow_paths: ["/","/api","/api/frame.jpeg","/api/schemes","/api/streams","/api/webrtc","/api/ws","/api/config","/api/log","/api/streams.dot"]\n local_auth: true\n username: user\n password: pass\n\n# ffmpeg needs the exec module\n# Restrict execution to only ffmpeg binary\nexec:\n allow_paths:\n - ffmpeg\n\nrtsp:\n listen: "127.0.0.1:18554"\n\nwebrtc:\n listen: ":18555/tcp"\n ice_servers: []\n', + b'# This file is managed by Home Assistant\n# Do not edit it manually\n\napp:\n modules: ["api","exec","ffmpeg","http","mjpeg","onvif","rtmp","rtsp","srtp","webrtc","ws","debug"]\n\napi:\n listen: ":11984"\n unix_listen: "/test/path/go2rtc.sock"\n allow_paths: ["/","/api","/api/frame.jpeg","/api/preload","/api/schemes","/api/streams","/api/webrtc","/api/ws","/api/config","/api/log","/api/streams.dot"]\n local_auth: true\n username: user\n password: pass\n\n# ffmpeg needs the exec module\n# Restrict execution to only ffmpeg binary\nexec:\n allow_paths:\n - ffmpeg\n\nrtsp:\n listen: "127.0.0.1:18554"\n\nwebrtc:\n listen: ":18555/tcp"\n ice_servers: []\n', ), dict({ }), diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 64096cdb08c630..4a320832143823 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -65,6 +65,20 @@ ANSWER_SDP = "v=0\r\no=bob 2890844730 2890844730 IN IP4 host.example.com\r\n..." +async def _setup_camera_prefs( + hass: HomeAssistant, + entity_id: str, + settings: DynamicStreamSettings, +) -> CameraPreferences: + """Set up camera preferences with optional orientation and preload_stream.""" + prefs = CameraPreferences(hass) + await prefs.async_load() + hass.data[DATA_CAMERA_PREFS] = prefs + + prefs._dynamic_stream_settings_by_entity_id[entity_id] = settings + return prefs + + @pytest.fixture(name="has_go2rtc_entry") def has_go2rtc_entry_fixture() -> bool: """Fixture to control if a go2rtc config entry should be created.""" @@ -163,9 +177,9 @@ async def test(session: str) -> None: await test("session_3") rest_client.streams.add.assert_not_called() - assert isinstance(camera._webrtc_provider, WebRTCProvider) + assert isinstance(camera.webrtc_provider, WebRTCProvider) - provider = camera._webrtc_provider + provider = camera.webrtc_provider for session in sessions: assert session in provider._sessions @@ -798,12 +812,12 @@ async def test_async_get_image( ) -> None: """Test getting snapshot from go2rtc.""" camera = init_test_integration - assert isinstance(camera._webrtc_provider, WebRTCProvider) + assert isinstance(camera.webrtc_provider, WebRTCProvider) image_bytes = load_fixture_bytes("snapshot.jpg", DOMAIN) rest_client.get_jpeg_snapshot.return_value = image_bytes - assert await camera._webrtc_provider.async_get_image(camera) == image_bytes + assert await camera.webrtc_provider.async_get_image(camera) == image_bytes image = await async_get_image(hass, camera.entity_id) assert image.content == image_bytes @@ -824,7 +838,7 @@ async def test_generic_workaround( ) -> None: """Test workaround for generic integration cameras.""" camera = init_test_integration - assert isinstance(camera._webrtc_provider, WebRTCProvider) + assert isinstance(camera.webrtc_provider, WebRTCProvider) image_bytes = load_fixture_bytes("snapshot.jpg", DOMAIN) @@ -855,16 +869,11 @@ async def _test_camera_orientation( ) -> None: """Test camera orientation handling in go2rtc provider.""" # Ensure go2rtc provider is initialized - assert isinstance(camera._webrtc_provider, WebRTCProvider) - - prefs = CameraPreferences(hass) - await prefs.async_load() - hass.data[DATA_CAMERA_PREFS] = prefs + assert isinstance(camera.webrtc_provider, WebRTCProvider) - # Set the specific orientation for this test by directly setting - # the dynamic stream settings + # Set the specific orientation for this test by directly setting the dynamic stream settings test_settings = DynamicStreamSettings(orientation=orientation, preload_stream=False) - prefs._dynamic_stream_settings_by_entity_id[camera.entity_id] = test_settings + await _setup_camera_prefs(hass, camera.entity_id, test_settings) # Call the camera function that should trigger stream update await camera_fn(hass, camera) @@ -1198,3 +1207,157 @@ async def test_basic_auth_with_debug_ui(hass: HomeAssistant, server_dir: Path) - call_kwargs = mock_server_cls.call_args[1] assert call_kwargs["username"] == "test_user" assert call_kwargs["password"] == "test_pass" + + +@pytest.mark.usefixtures("init_integration", "ws_client") +@pytest.mark.parametrize("preload", [True, False]) +async def test_preload_settings_is_applied_on_register( + hass: HomeAssistant, + rest_client: AsyncMock, + init_test_integration: MockCamera, + preload: bool, +) -> None: + """Test preload settings are applied when camera is registered.""" + camera = init_test_integration + test_settings = DynamicStreamSettings( + orientation=Orientation.NO_TRANSFORM, preload_stream=preload + ) + await _setup_camera_prefs(hass, camera.entity_id, test_settings) + provider = camera.webrtc_provider + await provider.async_register_camera(camera) + if preload: + rest_client.preload.enable.assert_called_once_with( + get_camera_identifier(camera) + ) + else: + rest_client.preload.enable.assert_not_called() + + +@pytest.mark.usefixtures("init_integration", "ws_client") +async def test_preload_disabled_on_unregister( + hass: HomeAssistant, + rest_client: AsyncMock, + init_test_integration: MockCamera, +) -> None: + """Test async_unregister_camera disables preload when it is enabled.""" + camera = init_test_integration + assert isinstance(camera.webrtc_provider, WebRTCProvider) + provider = camera.webrtc_provider + identifier = get_camera_identifier(camera) + rest_client.preload.list.return_value = {identifier} + # The preference stays enabled, but go2rtc must not keep preloading a + # camera the provider no longer handles + await _setup_camera_prefs( + hass, + camera.entity_id, + DynamicStreamSettings( + orientation=Orientation.NO_TRANSFORM, preload_stream=True + ), + ) + + await provider.async_unregister_camera(camera) + + rest_client.preload.disable.assert_called_once_with(identifier) + + +@pytest.mark.usefixtures("init_integration", "ws_client") +async def test_preload_not_disabled_when_not_enabled( + rest_client: AsyncMock, + init_test_integration: MockCamera, +) -> None: + """Test async_unregister_camera doesn't disable preload when it is not enabled.""" + camera = init_test_integration + assert isinstance(camera.webrtc_provider, WebRTCProvider) + provider = camera.webrtc_provider + + await provider.async_unregister_camera(camera) + + rest_client.preload.disable.assert_not_called() + + +@pytest.mark.usefixtures("init_integration", "ws_client") +async def test_preload_toggle_on_preference_update( + hass: HomeAssistant, + rest_client: AsyncMock, + init_test_integration: MockCamera, +) -> None: + """Test preload is toggled when camera preferences are updated.""" + camera = init_test_integration + assert isinstance(camera.webrtc_provider, WebRTCProvider) + provider = camera.webrtc_provider + identifier = get_camera_identifier(camera) + test_settings = DynamicStreamSettings( + orientation=Orientation.NO_TRANSFORM, preload_stream=True + ) + prefs = await _setup_camera_prefs(hass, camera.entity_id, test_settings) + + # Trigger preference update + await provider.async_on_camera_prefs_update(camera) + + # Verify preload was enabled + rest_client.preload.enable.assert_called_once_with(identifier) + rest_client.preload.disable.assert_not_called() + + # Now disable preload preference + rest_client.preload.list.return_value = {identifier} + rest_client.preload.enable.reset_mock() + rest_client.preload.disable.reset_mock() + + test_settings = DynamicStreamSettings( + orientation=Orientation.NO_TRANSFORM, preload_stream=False + ) + prefs._dynamic_stream_settings_by_entity_id[camera.entity_id] = test_settings + + # Trigger preference update + await provider.async_on_camera_prefs_update(camera) + + # Verify preload was disabled + rest_client.preload.disable.assert_called_once_with(identifier) + rest_client.preload.enable.assert_not_called() + + +@pytest.mark.usefixtures("init_integration", "ws_client") +async def test_preload_no_change_when_already_enabled( + hass: HomeAssistant, + rest_client: AsyncMock, + init_test_integration: MockCamera, +) -> None: + """Test preload enable is not called when already enabled.""" + camera = init_test_integration + assert isinstance(camera.webrtc_provider, WebRTCProvider) + provider = camera.webrtc_provider + rest_client.preload.list.return_value = {get_camera_identifier(camera)} + test_settings = DynamicStreamSettings( + orientation=Orientation.NO_TRANSFORM, preload_stream=True + ) + await _setup_camera_prefs(hass, camera.entity_id, test_settings) + + # Trigger preference update + await provider.async_on_camera_prefs_update(camera) + + # Verify preload enable/disable were not called + rest_client.preload.enable.assert_not_called() + rest_client.preload.disable.assert_not_called() + + +@pytest.mark.usefixtures("init_integration", "ws_client") +async def test_preload_no_change_when_already_disabled( + hass: HomeAssistant, + rest_client: AsyncMock, + init_test_integration: MockCamera, +) -> None: + """Test preload disable is not called when already disabled.""" + camera = init_test_integration + assert isinstance(camera.webrtc_provider, WebRTCProvider) + provider = camera.webrtc_provider + test_settings = DynamicStreamSettings( + orientation=Orientation.NO_TRANSFORM, preload_stream=False + ) + await _setup_camera_prefs(hass, camera.entity_id, test_settings) + + # Trigger preference update + await provider.async_on_camera_prefs_update(camera) + + # Verify preload enable/disable were not called + rest_client.preload.enable.assert_not_called() + rest_client.preload.disable.assert_not_called() diff --git a/tests/components/harbor/conftest.py b/tests/components/harbor/conftest.py index 09b59dc3d25026..4acafffe2e5d74 100644 --- a/tests/components/harbor/conftest.py +++ b/tests/components/harbor/conftest.py @@ -23,7 +23,15 @@ HEARTBEAT_TOPIC = f"cameras/{SERIAL}/events/heartbeat" LIVEKIT_TOPIC = f"cameras/{SERIAL}/events/local_livekit_heartbeat" +SETTINGS_TOPIC = f"cameras/{SERIAL}/responses/get-settings" +SETTINGS_PAYLOAD: dict[str, Any] = { + "settings": { + "preference_stream_paused": False, + "preference_video_flip": True, + "preference_video_has_clock_display": False, + }, +} HEARTBEAT_PAYLOAD: dict[str, Any] = { "temperature": 98.6, "os_version": "1.2.3", diff --git a/tests/components/harbor/snapshots/test_switch.ambr b/tests/components/harbor/snapshots/test_switch.ambr new file mode 100644 index 00000000000000..5f4281ab547fa2 --- /dev/null +++ b/tests/components/harbor/snapshots/test_switch.ambr @@ -0,0 +1,151 @@ +# serializer version: 1 +# name: test_switches[switch.harbor_camera_1234567890_camera-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.harbor_camera_1234567890_camera', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Camera', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Camera', + 'platform': 'harbor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'camera_on', + 'unique_id': '1234567890_camera_on', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.harbor_camera_1234567890_camera-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Harbor Camera 1234567890 Camera', + }), + 'context': , + 'entity_id': 'switch.harbor_camera_1234567890_camera', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.harbor_camera_1234567890_clock_overlay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.harbor_camera_1234567890_clock_overlay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Clock overlay', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Clock overlay', + 'platform': 'harbor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'clock_display', + 'unique_id': '1234567890_clock_display', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.harbor_camera_1234567890_clock_overlay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Harbor Camera 1234567890 Clock overlay', + }), + 'context': , + 'entity_id': 'switch.harbor_camera_1234567890_clock_overlay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.harbor_camera_1234567890_flip_image-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.harbor_camera_1234567890_flip_image', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Flip image', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flip image', + 'platform': 'harbor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'video_flip', + 'unique_id': '1234567890_video_flip', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.harbor_camera_1234567890_flip_image-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Harbor Camera 1234567890 Flip image', + }), + 'context': , + 'entity_id': 'switch.harbor_camera_1234567890_flip_image', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/harbor/test_sensor.py b/tests/components/harbor/test_sensor.py index 79502050034ac3..bd55301cb820a7 100644 --- a/tests/components/harbor/test_sensor.py +++ b/tests/components/harbor/test_sensor.py @@ -1,12 +1,12 @@ """Test the Harbor sensors.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNKNOWN +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -31,7 +31,8 @@ async def test_sensors( snapshot: SnapshotAssertion, ) -> None: """Test the Harbor sensors report their values.""" - await setup_integration(hass, mock_config_entry) + with patch("homeassistant.components.harbor.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.LOADED await emit_message(mock_mqtt_client, HEARTBEAT_TOPIC, HEARTBEAT_PAYLOAD) diff --git a/tests/components/harbor/test_switch.py b/tests/components/harbor/test_switch.py new file mode 100644 index 00000000000000..7f76f0cb8f3e12 --- /dev/null +++ b/tests/components/harbor/test_switch.py @@ -0,0 +1,133 @@ +"""Test the Harbor switches.""" + +from unittest.mock import AsyncMock, patch + +from harbor import HarborCommandError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .conftest import SETTINGS_PAYLOAD, SETTINGS_TOPIC, emit_message + +from tests.common import MockConfigEntry, snapshot_platform + +CAMERA_ON_ENTITY = "switch.harbor_camera_1234567890_camera" +VIDEO_FLIP_ENTITY = "switch.harbor_camera_1234567890_flip_image" +CLOCK_DISPLAY_ENTITY = "switch.harbor_camera_1234567890_clock_overlay" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_switches( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the Harbor switches report their state.""" + with patch("homeassistant.components.harbor.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + await emit_message(mock_mqtt_client, SETTINGS_TOPIC, SETTINGS_PAYLOAD) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "library_method"), + [ + pytest.param(CAMERA_ON_ENTITY, "set_camera_on", id="camera_on"), + pytest.param(VIDEO_FLIP_ENTITY, "set_video_flip", id="video_flip"), + pytest.param(CLOCK_DISPLAY_ENTITY, "set_clock_display", id="clock_display"), + ], +) +@pytest.mark.parametrize( + ("service", "expected"), + [ + pytest.param(SERVICE_TURN_ON, True, id="turn_on"), + pytest.param(SERVICE_TURN_OFF, False, id="turn_off"), + ], +) +async def test_turn_on_and_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, + entity_id: str, + library_method: str, + service: str, + expected: bool, +) -> None: + """Test turning each switch on and off calls the library.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mock_method = getattr(mock_mqtt_client.return_value, library_method) + mock_method.assert_awaited_once_with(expected) + + +@pytest.mark.parametrize( + ("entity_id", "library_method"), + [ + pytest.param(CAMERA_ON_ENTITY, "set_camera_on", id="camera_on"), + pytest.param(VIDEO_FLIP_ENTITY, "set_video_flip", id="video_flip"), + pytest.param(CLOCK_DISPLAY_ENTITY, "set_clock_display", id="clock_display"), + ], +) +@pytest.mark.parametrize( + "service", + [ + pytest.param(SERVICE_TURN_ON, id="turn_on"), + pytest.param(SERVICE_TURN_OFF, id="turn_off"), + ], +) +@pytest.mark.parametrize( + "error", + [ + pytest.param( + HarborCommandError("command", {"error": "rejected"}), id="command" + ), + pytest.param(TimeoutError, id="timeout"), + pytest.param(ConnectionError, id="connection"), + ], +) +async def test_command_failure_raises( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, + entity_id: str, + library_method: str, + service: str, + error: Exception | type[Exception], +) -> None: + """Test a failed camera command surfaces as a HomeAssistantError.""" + await setup_integration(hass, mock_config_entry) + + getattr(mock_mqtt_client.return_value, library_method).side_effect = error + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index 867881bf3204f5..b6c3adbc20851b 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -623,7 +623,7 @@ async def test_power_switch_service_validation_errors( integration_setup: Callable[[MagicMock], Awaitable[bool]], exception_match: str, entity_id: str, - allowed_values: list[str | None] | None | HomeConnectError, + allowed_values: list[str | None] | HomeConnectError | None, service: str, ) -> None: """Test power switch functionality validation errors.""" diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index db977280af15f7..4a0033df691d28 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -11,6 +11,7 @@ from aiohomekit.model import Accessories, Accessory from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes +from aiohomekit.testing import FakeController from bleak.exc import BleakError import pytest @@ -392,6 +393,44 @@ async def test_discovery_ignored_hk_bridge( assert result["reason"] == "ignored_model" +async def test_discovery_ignored_hk_bridge_shared_mac( + hass: HomeAssistant, controller: FakeController, device_registry: dr.DeviceRegistry +) -> None: + """Ignore a homekit bridge even when another config entry shares its MAC. + + Several config entries can each own a device for the same MAC; the bridge must be + found among them, not just the first matching device. + """ + device = setup_mock_accessory(controller) + discovery_info = get_device_discovery_info(device) + formatted_mac = dr.format_mac("AA:BB:CC:DD:EE:FF") + + # A non-bridge entry owns a device with the MAC, registered first so it is the first + # match; the bridge's device is registered second. + other_entry = MockConfigEntry(domain="not_homekit", data={}) + other_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=other_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, formatted_mac)}, + ) + bridge_entry = MockConfigEntry(domain=config_flow.HOMEKIT_BRIDGE_DOMAIN, data={}) + bridge_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=bridge_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, formatted_mac)}, + ) + + discovery_info.properties[ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "ignored_model" + + async def test_discovery_does_not_ignore_non_homekit( hass: HomeAssistant, controller, device_registry: dr.DeviceRegistry ) -> None: diff --git a/tests/components/homekit_controller/test_connection.py b/tests/components/homekit_controller/test_connection.py index 69162a848c37b1..aa26ed965bd102 100644 --- a/tests/components/homekit_controller/test_connection.py +++ b/tests/components/homekit_controller/test_connection.py @@ -10,6 +10,7 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import Service, ServicesTypes from aiohomekit.testing import FakeController +import attr import pytest from homeassistant.components.climate import ATTR_CURRENT_TEMPERATURE @@ -183,6 +184,69 @@ async def test_migrate_device_id_no_serial( assert device.manufacturer == variant.manufacturer +async def test_migrate_device_id_shared_identifier_only_migrates_own( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Migrate this config entry's own split when the legacy identifier is shared. + + When several homekit config entries own a device with the same legacy identifier the + registry resolves the identifier to a read-only composite. The migration must still + rename this config entry's own device and leave the other entry's device untouched. + """ + before = {(DOMAIN, IDENTIFIER_LEGACY_ACCESSORY_ID, "00:00:00:00:00:00")} + after = {(IDENTIFIER_ACCESSORY_ID, "00:00:00:00:00:00:aid:1")} + + accessories = await setup_accessories_from_file( + hass, "ryse_smart_bridge_four_shades.json" + ) + fake_controller = await setup_platform(hass) + await fake_controller.add_paired_device(accessories, "00:00:00:00:00:00") + config_entry = MockConfigEntry( + version=1, + domain=DOMAIN, + entry_id="TestData", + data={"AccessoryPairingID": "00:00:00:00:00:00"}, + title="test", + ) + config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers=before, + manufacturer="Dummy Manufacturer", + model="Dummy Model", + name="Dummy Name", + ) + # A second homekit config entry owns a device with the same legacy identifier; both + # are splits of one pre-migration composite. + other_entry = MockConfigEntry(domain=DOMAIN) + other_entry.add_to_hass(hass) + other_device = device_registry.async_get_or_create( + config_entry_id=other_entry.entry_id, + identifiers=before, + manufacturer="Other", + model="Other", + name="Other", + ) + old_id = "composite00000000000000000000ab" + device_registry.devices[device.id] = attr.evolve(device, composite_device_id=old_id) + device_registry.devices[other_device.id] = attr.evolve( + other_device, composite_device_id=old_id + ) + # The shared identifier now resolves to the read-only composite + resolved = device_registry.async_get_device(identifiers=before) # type: ignore[arg-type] + assert resolved is not None + assert resolved.id == old_id + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # This entry's own device was migrated; the other entry's device was left untouched. + assert device_registry.async_get(device.id).identifiers == after + assert device_registry.async_get(other_device.id).identifiers == before + + async def test_migrate_ble_unique_id(hass: HomeAssistant) -> None: """Test that a config entry with incorrect unique_id is repaired.""" accessories = await setup_accessories_from_file(hass, "anker_eufycam.json") diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 591ebaecf4b064..18ebeed98a5a6a 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -924,59 +924,33 @@ async def test_ssl_issue_urls_configured( @pytest.mark.parametrize( - ( - "hassio", - "http_config", - "expected_serverhost", - "expected_issues", - ), + ("http_config", "expected_serverhost"), [ - (False, {}, ["0.0.0.0", "::"], {("http", "deprecated_yaml")}), - ( - False, - {"server_host": "0.0.0.0"}, - ["0.0.0.0"], - {("http", "deprecated_yaml")}, - ), - (True, {}, ["0.0.0.0", "::"], {("http", "deprecated_yaml")}), - ( - True, - {"server_host": "0.0.0.0"}, - [ - "0.0.0.0", - ], - { - ("http", "server_host_deprecated_hassio"), - ("http", "deprecated_yaml"), - }, - ), + pytest.param({}, ["0.0.0.0", "::"], id="default"), + pytest.param({"server_host": "0.0.0.0"}, ["0.0.0.0"], id="server_host"), ], ) async def test_server_host( hass: HomeAssistant, - hassio: bool, issue_registry: ir.IssueRegistry, http_config: dict, expected_serverhost: list, - expected_issues: set[tuple[str, str]], - caplog: pytest.LogCaptureFixture, mock_create_server: Mock, ) -> None: """Test server_host behavior.""" - with patch("homeassistant.components.http.is_hassio", return_value=hassio): - assert await async_setup_component( - hass, - DOMAIN, - {"http": http_config}, - ) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component( + hass, + DOMAIN, + {"http": http_config}, + ) + await hass.async_start() + await hass.async_block_till_done() mock_create_server.assert_called_once() assert hass.http.server_host == expected_serverhost assert hass.http.server_port == 8123 - assert set(issue_registry.issues) == expected_issues + assert set(issue_registry.issues) == {("http", "deprecated_yaml")} async def test_unix_socket_started_with_supervisor( diff --git a/tests/components/iometer/__init__.py b/tests/components/iometer/__init__.py index 0daf9cd994448d..fb223a3ecf9177 100644 --- a/tests/components/iometer/__init__.py +++ b/tests/components/iometer/__init__.py @@ -1,6 +1,7 @@ """Tests for the IOmeter integration.""" -from unittest.mock import patch +from collections.abc import Callable +from unittest.mock import MagicMock, patch from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -17,3 +18,23 @@ async def setup_platform( with patch("homeassistant.components.iometer.PLATFORMS", platforms): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + + +def get_reading_callback(mock: MagicMock) -> Callable: + """Get the reading callback registered with the SSE client.""" + return mock.subscribe_readings.call_args[0][0] + + +def get_status_callback(mock: MagicMock) -> Callable: + """Get the status callback registered with the SSE client.""" + return mock.subscribe_status.call_args[0][0] + + +def get_reading_error_callback(mock: MagicMock) -> Callable: + """Get the reading error callback registered with the SSE client.""" + return mock.subscribe_readings.call_args[0][1] + + +def get_status_error_callback(mock: MagicMock) -> Callable: + """Get the status error callback registered with the SSE client.""" + return mock.subscribe_status.call_args[0][1] diff --git a/tests/components/iometer/conftest.py b/tests/components/iometer/conftest.py index f8139c7c64cd41..705cc467eb5c40 100644 --- a/tests/components/iometer/conftest.py +++ b/tests/components/iometer/conftest.py @@ -1,7 +1,7 @@ """Common fixtures for the IOmeter tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from iometer import Reading, Status import pytest @@ -13,7 +13,7 @@ @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock]: +def mock_setup_entry() -> Generator[MagicMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.iometer.async_setup_entry", @@ -23,32 +23,40 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_iometer_client() -> Generator[AsyncMock]: - """Mock a new IOmeter client.""" - with ( - patch( - "homeassistant.components.iometer.IOmeterClient", - autospec=True, - ) as mock_client, - patch( - "homeassistant.components.iometer.config_flow.IOmeterClient", - new=mock_client, - ), - ): - client = mock_client.return_value - client.host = "10.0.0.2" - client.get_current_reading.return_value = Reading.from_json( - load_fixture("reading.json", DOMAIN) - ) - client.get_current_status.return_value = Status.from_json( - load_fixture("status.json", DOMAIN) +def mock_http_client() -> Generator[MagicMock]: + """Mock IOmeter HTTP client for config flow.""" + with patch( + "homeassistant.components.iometer.config_flow.IOmeterClient" + ) as mock_http_class: + http_client = mock_http_class.return_value + http_client.get_current_status = AsyncMock( + return_value=Status.from_json(load_fixture("status.json", DOMAIN)) ) - yield client + yield http_client + + +@pytest.fixture +def mock_iometer_client(mock_http_client: MagicMock) -> Generator[MagicMock]: + """Mock IOmeter SSE client for the coordinator.""" + + def subscribe_readings(on_reading, _on_error=None): + on_reading(Reading.from_json(load_fixture("reading.json", DOMAIN))) + return lambda: None + + def subscribe_status(on_status, _on_error=None): + on_status(Status.from_json(load_fixture("status.json", DOMAIN))) + return lambda: None + + with patch("homeassistant.components.iometer.IOmeterSSEClient") as mock_sse_class: + sse_client = mock_sse_class.return_value + sse_client.subscribe_readings.side_effect = subscribe_readings + sse_client.subscribe_status.side_effect = subscribe_status + yield sse_client @pytest.fixture def mock_config_entry() -> MockConfigEntry: - """Mock a IOmeter config entry.""" + """Mock an IOmeter config entry.""" return MockConfigEntry( domain=DOMAIN, title="IOmeter-1ISK0000000000", diff --git a/tests/components/iometer/test_binary_sensor.py b/tests/components/iometer/test_binary_sensor.py index ef9aa0289afa2e..404063ed230204 100644 --- a/tests/components/iometer/test_binary_sensor.py +++ b/tests/components/iometer/test_binary_sensor.py @@ -1,26 +1,27 @@ """Test the IOmeter binary sensors.""" -from datetime import timedelta -from unittest.mock import AsyncMock +import json +from unittest.mock import MagicMock -from freezegun.api import FrozenDateTimeFactory +from iometer import Status import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.iometer.const import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import setup_platform +from . import get_status_callback, setup_platform -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.common import MockConfigEntry, async_load_fixture, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_binary_sensors( hass: HomeAssistant, snapshot: SnapshotAssertion, - mock_iometer_client: AsyncMock, + mock_iometer_client: MagicMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: @@ -34,10 +35,9 @@ async def test_binary_sensors( async def test_connection_status_sensors( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_iometer_client: AsyncMock, - freezer: FrozenDateTimeFactory, + mock_iometer_client: MagicMock, ) -> None: - """Test connection status sensor.""" + """Test connection status sensor updates via SSE.""" await setup_platform(hass, mock_config_entry, [Platform.BINARY_SENSOR]) assert ( @@ -47,15 +47,9 @@ async def test_connection_status_sensors( == STATE_ON ) - freezer.tick(delta=timedelta(minutes=1)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - status = mock_iometer_client.get_current_status.return_value - status.device.core.connection_status = "disconnected" - - freezer.tick(delta=timedelta(minutes=1)) - async_fire_time_changed(hass) + status_data = json.loads(await async_load_fixture(hass, "status.json", DOMAIN)) + status_data["device"]["core"]["connectionStatus"] = "disconnected" + get_status_callback(mock_iometer_client)(Status.from_json(json.dumps(status_data))) await hass.async_block_till_done() assert ( @@ -70,10 +64,9 @@ async def test_connection_status_sensors( async def test_attachment_status_sensors( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_iometer_client: AsyncMock, - freezer: FrozenDateTimeFactory, + mock_iometer_client: MagicMock, ) -> None: - """Test connection status sensor.""" + """Test attachment status sensor updates via SSE.""" await setup_platform(hass, mock_config_entry, [Platform.BINARY_SENSOR]) assert ( @@ -83,15 +76,9 @@ async def test_attachment_status_sensors( == STATE_ON ) - freezer.tick(delta=timedelta(minutes=1)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - status = mock_iometer_client.get_current_status.return_value - status.device.core.attachment_status = "detached" - - freezer.tick(delta=timedelta(minutes=1)) - async_fire_time_changed(hass) + status_data = json.loads(await async_load_fixture(hass, "status.json", DOMAIN)) + status_data["device"]["core"]["attachmentStatus"] = "detached" + get_status_callback(mock_iometer_client)(Status.from_json(json.dumps(status_data))) await hass.async_block_till_done() assert ( @@ -103,13 +90,12 @@ async def test_attachment_status_sensors( @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_attachment_status_sensors_unkown( +async def test_attachment_status_sensors_unknown( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_iometer_client: AsyncMock, - freezer: FrozenDateTimeFactory, + mock_iometer_client: MagicMock, ) -> None: - """Test connection status sensor.""" + """Test attachment status sensor shows unknown state via SSE.""" await setup_platform(hass, mock_config_entry, [Platform.BINARY_SENSOR]) assert ( @@ -119,15 +105,9 @@ async def test_attachment_status_sensors_unkown( == STATE_ON ) - freezer.tick(delta=timedelta(minutes=1)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - status = mock_iometer_client.get_current_status.return_value - status.device.core.attachment_status = None - - freezer.tick(delta=timedelta(minutes=1)) - async_fire_time_changed(hass) + status_data = json.loads(await async_load_fixture(hass, "status.json", DOMAIN)) + del status_data["device"]["core"]["attachmentStatus"] + get_status_callback(mock_iometer_client)(Status.from_json(json.dumps(status_data))) await hass.async_block_till_done() assert ( diff --git a/tests/components/iometer/test_config_flow.py b/tests/components/iometer/test_config_flow.py index 34e7ccb90b87eb..7f6642f1b8a740 100644 --- a/tests/components/iometer/test_config_flow.py +++ b/tests/components/iometer/test_config_flow.py @@ -1,9 +1,14 @@ """Test the IOmeter config flow.""" from ipaddress import ip_address -from unittest.mock import AsyncMock +from unittest.mock import MagicMock -from iometer import IOmeterConnectionError, IOmeterNoReadingsError, IOmeterNoStatusError +from iometer import ( + IOmeterConnectionError, + IOmeterNoStatusError, + IOmeterTimeoutError, + Status, +) import pytest from homeassistant.components.iometer.const import DOMAIN @@ -13,7 +18,7 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_load_fixture IP_ADDRESS = "10.0.0.2" IOMETER_DEVICE_ID = "658c2b34-2017-45f2-a12b-731235f8bb97" @@ -29,16 +34,16 @@ ) +@pytest.mark.usefixtures("mock_setup_entry") async def test_user_flow( hass: HomeAssistant, - mock_iometer_client: AsyncMock, + mock_http_client: MagicMock, ) -> None: """Test full user configuration flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -46,7 +51,6 @@ async def test_user_flow( result["flow_id"], user_input={CONF_HOST: IP_ADDRESS}, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "IOmeter 1ISK0000000000" @@ -54,9 +58,10 @@ async def test_user_flow( assert result["result"].unique_id == IOMETER_DEVICE_ID +@pytest.mark.usefixtures("mock_setup_entry") async def test_zeroconf_flow( hass: HomeAssistant, - mock_iometer_client: AsyncMock, + mock_http_client: MagicMock, ) -> None: """Test zeroconf flow.""" result = await hass.config_entries.flow.async_init( @@ -64,7 +69,6 @@ async def test_zeroconf_flow( context={"source": SOURCE_ZEROCONF}, data=ZEROCONF_DISCOVERY, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM assert result["step_id"] == "zeroconf_confirm" @@ -95,23 +99,22 @@ async def test_zeroconf_flow_abort_duplicate( @pytest.mark.parametrize( - ("method_name", "exception", "reason"), + ("exception", "reason"), [ - ("get_current_status", IOmeterConnectionError(), "cannot_connect"), - ("get_current_status", IOmeterNoStatusError(), "no_status"), - ("get_current_reading", IOmeterNoReadingsError(), "no_readings"), + (IOmeterConnectionError(), "cannot_connect"), + (IOmeterTimeoutError(), "cannot_connect"), + (IOmeterNoStatusError(), "no_status"), ], - ids=["status-connection", "status-missing", "reading-missing"], + ids=["connection-error", "timeout", "status-missing"], ) async def test_zeroconf_flow_abort_errors( hass: HomeAssistant, - mock_iometer_client: AsyncMock, - method_name: str, + mock_http_client: MagicMock, exception: Exception, reason: str, ) -> None: - """Test zeroconf flow aborts when the client raises an exception.""" - getattr(mock_iometer_client, method_name).side_effect = exception + """Test zeroconf flow aborts when the HTTP client raises an exception.""" + mock_http_client.get_current_status.side_effect = exception result = await hass.config_entries.flow.async_init( DOMAIN, @@ -123,31 +126,51 @@ async def test_zeroconf_flow_abort_errors( assert result["reason"] == reason +async def test_zeroconf_flow_abort_no_meter( + hass: HomeAssistant, + mock_http_client: MagicMock, +) -> None: + """Test zeroconf flow aborts when the status contains no meter info.""" + mock_status = MagicMock() + mock_status.meter = None + mock_http_client.get_current_status.return_value = mock_status + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_readings" + + @pytest.mark.parametrize( - ("method_name", "exception", "error_key"), + ("exception", "error_key"), [ - ("get_current_status", IOmeterConnectionError(), "cannot_connect"), - ("get_current_status", IOmeterNoStatusError(), "no_status"), - ("get_current_reading", IOmeterNoReadingsError(), "no_readings"), + (IOmeterConnectionError(), "cannot_connect"), + (IOmeterTimeoutError(), "cannot_connect"), + (IOmeterNoStatusError(), "no_status"), ], - ids=["status-connection", "status-missing", "reading-missing"], + ids=["connection-error", "timeout", "status-missing"], ) @pytest.mark.usefixtures("mock_setup_entry") async def test_user_flow_errors( hass: HomeAssistant, - mock_iometer_client: AsyncMock, - method_name: str, + mock_http_client: MagicMock, exception: Exception, error_key: str, ) -> None: - """Test user flow returns errors for client exceptions.""" - getattr(mock_iometer_client, method_name).side_effect = exception + """Test user flow shows errors for HTTP client exceptions and recovers on retry.""" + valid_status = Status.from_json( + await async_load_fixture(hass, "status.json", DOMAIN) + ) + mock_http_client.get_current_status.side_effect = [exception, valid_status] result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -159,7 +182,41 @@ async def test_user_flow_errors( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error_key} - getattr(mock_iometer_client, method_name).side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: IP_ADDRESS}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow_no_meter_error( + hass: HomeAssistant, + mock_http_client: MagicMock, +) -> None: + """Test user flow shows error when status contains no meter info.""" + mock_status = MagicMock() + mock_status.meter = None + valid_status = Status.from_json( + await async_load_fixture(hass, "status.json", DOMAIN) + ) + mock_http_client.get_current_status.side_effect = [mock_status, valid_status] + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: IP_ADDRESS}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "no_readings"} result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -172,7 +229,7 @@ async def test_user_flow_errors( @pytest.mark.usefixtures("mock_setup_entry") async def test_flow_abort_duplicate( hass: HomeAssistant, - mock_iometer_client: AsyncMock, + mock_http_client: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test duplicate flow.""" @@ -182,7 +239,6 @@ async def test_flow_abort_duplicate( DOMAIN, context={"source": SOURCE_USER}, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -191,6 +247,5 @@ async def test_flow_abort_duplicate( {CONF_HOST: IP_ADDRESS}, ) await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" diff --git a/tests/components/iometer/test_init.py b/tests/components/iometer/test_init.py index db4fd36be05b98..04f54743627766 100644 --- a/tests/components/iometer/test_init.py +++ b/tests/components/iometer/test_init.py @@ -1,10 +1,19 @@ """Tests for the IOmeter integration.""" -from datetime import timedelta -from unittest.mock import AsyncMock +import asyncio +import contextlib +import json +import logging +from unittest.mock import AsyncMock, MagicMock, patch -from freezegun.api import FrozenDateTimeFactory -from iometer import IOmeterConnectionError +from iometer import ( + IOmeterConnectionError, + IOmeterNoReadingsError, + IOmeterNoStatusError, + IOmeterTimeoutError, + Status, +) +import pytest from homeassistant.components.iometer.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -12,19 +21,23 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from . import setup_platform +from . import ( + get_reading_error_callback, + get_status_callback, + get_status_error_callback, + setup_platform, +) -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_load_fixture async def test_new_firmware_version( hass: HomeAssistant, - mock_iometer_client: AsyncMock, + mock_iometer_client: MagicMock, mock_config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, - freezer: FrozenDateTimeFactory, ) -> None: - """Test device registry integration.""" + """Test device registry is updated when firmware version changes via SSE.""" assert mock_config_entry.unique_id is not None await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) @@ -33,13 +46,13 @@ async def test_new_firmware_version( ) assert device_entry is not None assert device_entry.sw_version == "build-58/build-65" - mock_iometer_client.get_current_status.return_value.device.core.version = "build-62" - mock_iometer_client.get_current_status.return_value.device.bridge.version = ( - "build-69" - ) - freezer.tick(timedelta(minutes=1)) - async_fire_time_changed(hass) + + status_data = json.loads(await async_load_fixture(hass, "status.json", DOMAIN)) + status_data["device"]["core"]["version"] = "build-62" + status_data["device"]["bridge"]["version"] = "build-69" + get_status_callback(mock_iometer_client)(Status.from_json(json.dumps(status_data))) await hass.async_block_till_done() + device_entry = device_registry.async_get_device( identifiers={(DOMAIN, mock_config_entry.unique_id)} ) @@ -47,19 +60,160 @@ async def test_new_firmware_version( assert device_entry.sw_version == "build-62/build-69" -async def test_async_setup_entry_connection_error( +async def test_first_data_timeout( hass: HomeAssistant, - mock_iometer_client: AsyncMock, + mock_iometer_client: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: - """Test async_setup_entry raises ConfigEntryNotReady on connection error.""" - + """Test setup retries when the 30s timeout waiting for first SSE data expires.""" mock_config_entry.add_to_hass(hass) - mock_iometer_client.get_current_status.side_effect = IOmeterConnectionError( - "cannot connect" - ) - await hass.config_entries.async_setup(mock_config_entry.entry_id) + mock_timeout = MagicMock() + mock_timeout.return_value.__aenter__ = AsyncMock(side_effect=TimeoutError) + mock_timeout.return_value.__aexit__ = AsyncMock(return_value=False) + + with patch( + "homeassistant.components.iometer.coordinator.asyncio.timeout", + mock_timeout, + ): + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert not result assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - assert mock_iometer_client.get_current_status.await_count == 1 + + +@pytest.mark.parametrize( + ("exception", "expected_log"), + [ + pytest.param(IOmeterTimeoutError("t"), "timed out", id="timeout"), + pytest.param(IOmeterNoReadingsError("n"), "stream error", id="no-readings"), + pytest.param( + IOmeterConnectionError("c"), "stream error", id="connection-error" + ), + pytest.param( + RuntimeError("u"), "Unexpected error in reading stream", id="unexpected" + ), + ], +) +async def test_reading_error_callback( + hass: HomeAssistant, + mock_iometer_client: MagicMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + expected_log: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test reading error callback logs correctly before the library reconnects.""" + await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) + + with caplog.at_level(logging.DEBUG, logger="homeassistant.components.iometer"): + get_reading_error_callback(mock_iometer_client)(exception) + + assert expected_log in caplog.text + + +@pytest.mark.parametrize( + ("exception", "expected_log"), + [ + pytest.param(IOmeterTimeoutError("t"), "timed out", id="timeout"), + pytest.param(IOmeterNoStatusError("n"), "stream error", id="no-status"), + pytest.param( + IOmeterConnectionError("c"), "stream error", id="connection-error" + ), + pytest.param( + RuntimeError("u"), "Unexpected error in status stream", id="unexpected" + ), + ], +) +async def test_status_error_callback( + hass: HomeAssistant, + mock_iometer_client: MagicMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + expected_log: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test status error callback logs correctly before the library reconnects.""" + await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) + + with caplog.at_level(logging.DEBUG, logger="homeassistant.components.iometer"): + get_status_error_callback(mock_iometer_client)(exception) + + assert expected_log in caplog.text + + +async def test_error_before_first_data_does_not_mark_unavailable( + hass: HomeAssistant, + mock_iometer_client: MagicMock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that stream errors before first data do not mark entities unavailable.""" + subscribed = asyncio.Event() + + def subscribe_readings_noop(*_): + subscribed.set() + return lambda: None + + mock_iometer_client.subscribe_readings.side_effect = subscribe_readings_noop + mock_iometer_client.subscribe_status.side_effect = lambda *_: lambda: None + + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.iometer.PLATFORMS", []): + setup_task = asyncio.get_running_loop().create_task( + hass.config_entries.async_setup(mock_config_entry.entry_id) + ) + await asyncio.wait_for(subscribed.wait(), timeout=5.0) + + with caplog.at_level(logging.WARNING, logger="homeassistant.components.iometer"): + get_reading_error_callback(mock_iometer_client)(IOmeterConnectionError("err")) + + assert "stream error" in caplog.text + assert "Update failed" not in caplog.text + + setup_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await setup_task + + +@pytest.mark.usefixtures("mock_iometer_client") +async def test_async_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that unloading an entry succeeds and cleans up the coordinator.""" + await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_async_stop_calls_cancel_on_unload( + hass: HomeAssistant, + mock_iometer_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that unloading calls the cancel functions returned by the library.""" + readings_cancel = MagicMock() + status_cancel = MagicMock() + + original_readings = mock_iometer_client.subscribe_readings.side_effect + original_status = mock_iometer_client.subscribe_status.side_effect + + def subscribe_readings_with_cancel(*args): + original_readings(*args) + return readings_cancel + + def subscribe_status_with_cancel(*args): + original_status(*args) + return status_cancel + + mock_iometer_client.subscribe_readings.side_effect = subscribe_readings_with_cancel + mock_iometer_client.subscribe_status.side_effect = subscribe_status_with_cancel + + await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + readings_cancel.assert_called_once() + status_cancel.assert_called_once() diff --git a/tests/components/iometer/test_sensor.py b/tests/components/iometer/test_sensor.py index 19ce8477170119..9c1df8c65d0fa3 100644 --- a/tests/components/iometer/test_sensor.py +++ b/tests/components/iometer/test_sensor.py @@ -1,5 +1,7 @@ """Test the sensors provided by the Powerfox integration.""" +from unittest.mock import MagicMock + import pytest from syrupy.assertion import SnapshotAssertion @@ -10,13 +12,12 @@ from . import setup_platform from tests.common import MockConfigEntry, snapshot_platform -from tests.components.conftest import AsyncMock @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_all_sensors( hass: HomeAssistant, - mock_iometer_client: AsyncMock, + mock_iometer_client: MagicMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, diff --git a/tests/components/led_infrared/snapshots/test_button.ambr b/tests/components/led_infrared/snapshots/test_button.ambr index e49c361a7bc491..60efb80e19fad8 100644 --- a/tests/components/led_infrared/snapshots/test_button.ambr +++ b/tests/components/led_infrared/snapshots/test_button.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_setup[button.led_infrared_via_test_ir_emitter_brightness_down-entry] +# name: test_setup[generic_13_key][button.led_infrared_via_test_ir_emitter_brightness_down-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -36,7 +36,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_setup[button.led_infrared_via_test_ir_emitter_brightness_down-state] +# name: test_setup[generic_13_key][button.led_infrared_via_test_ir_emitter_brightness_down-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LED Infrared via Test IR emitter Brightness down', @@ -49,7 +49,7 @@ 'state': 'unknown', }) # --- -# name: test_setup[button.led_infrared_via_test_ir_emitter_brightness_up-entry] +# name: test_setup[generic_13_key][button.led_infrared_via_test_ir_emitter_brightness_up-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -86,7 +86,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_setup[button.led_infrared_via_test_ir_emitter_brightness_up-state] +# name: test_setup[generic_13_key][button.led_infrared_via_test_ir_emitter_brightness_up-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'LED Infrared via Test IR emitter Brightness up', @@ -99,3 +99,1253 @@ 'state': 'unknown', }) # --- +# name: test_setup[generic_13_key][button.led_infrared_via_test_ir_emitter_timer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_timer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Timer', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Timer', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'timer', + 'unique_id': '1234567890_timer', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_13_key][button.led_infrared_via_test_ir_emitter_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter Timer', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_24_key][button.led_infrared_via_test_ir_emitter_brightness_down-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_brightness_down', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Brightness down', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Brightness down', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'brightness_down', + 'unique_id': '1234567890_brightness_down', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_24_key][button.led_infrared_via_test_ir_emitter_brightness_down-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter Brightness down', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_brightness_down', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_24_key][button.led_infrared_via_test_ir_emitter_brightness_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_brightness_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Brightness up', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Brightness up', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'brightness_up', + 'unique_id': '1234567890_brightness_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_24_key][button.led_infrared_via_test_ir_emitter_brightness_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter Brightness up', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_brightness_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_40_key][button.led_infrared_via_test_ir_emitter_brightness_down-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_brightness_down', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Brightness down', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Brightness down', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'brightness_down', + 'unique_id': '1234567890_brightness_down', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_40_key][button.led_infrared_via_test_ir_emitter_brightness_down-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter Brightness down', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_brightness_down', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_40_key][button.led_infrared_via_test_ir_emitter_brightness_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_brightness_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Brightness up', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Brightness up', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'brightness_up', + 'unique_id': '1234567890_brightness_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_40_key][button.led_infrared_via_test_ir_emitter_brightness_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter Brightness up', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_brightness_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_40_key][button.led_infrared_via_test_ir_emitter_quick-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_quick', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Quick', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Quick', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'quick', + 'unique_id': '1234567890_quick', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_40_key][button.led_infrared_via_test_ir_emitter_quick-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter Quick', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_quick', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_40_key][button.led_infrared_via_test_ir_emitter_slow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_slow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Slow', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Slow', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'slow', + 'unique_id': '1234567890_slow', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_40_key][button.led_infrared_via_test_ir_emitter_slow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter Slow', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_slow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_40_key][button.led_infrared_via_test_ir_emitter_white_brightness_100-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_white_brightness_100', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'White brightness 100%', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'White brightness 100%', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'white_brightness_100', + 'unique_id': '1234567890_white_brightness_100', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_40_key][button.led_infrared_via_test_ir_emitter_white_brightness_100-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter White brightness 100%', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_white_brightness_100', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_40_key][button.led_infrared_via_test_ir_emitter_white_brightness_25-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_white_brightness_25', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'White brightness 25%', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'White brightness 25%', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'white_brightness_25', + 'unique_id': '1234567890_white_brightness_25', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_40_key][button.led_infrared_via_test_ir_emitter_white_brightness_25-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter White brightness 25%', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_white_brightness_25', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_40_key][button.led_infrared_via_test_ir_emitter_white_brightness_50-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_white_brightness_50', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'White brightness 50%', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'White brightness 50%', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'white_brightness_50', + 'unique_id': '1234567890_white_brightness_50', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_40_key][button.led_infrared_via_test_ir_emitter_white_brightness_50-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter White brightness 50%', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_white_brightness_50', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_40_key][button.led_infrared_via_test_ir_emitter_white_brightness_75-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_white_brightness_75', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'White brightness 75%', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'White brightness 75%', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'white_brightness_75', + 'unique_id': '1234567890_white_brightness_75', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_40_key][button.led_infrared_via_test_ir_emitter_white_brightness_75-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter White brightness 75%', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_white_brightness_75', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_40_key][button.led_infrared_via_test_ir_emitter_white_brightness_down-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_white_brightness_down', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'White brightness down', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'White brightness down', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'white_brightness_down', + 'unique_id': '1234567890_white_brightness_down', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_40_key][button.led_infrared_via_test_ir_emitter_white_brightness_down-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter White brightness down', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_white_brightness_down', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_40_key][button.led_infrared_via_test_ir_emitter_white_brightness_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_white_brightness_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'White brightness up', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'White brightness up', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'white_brightness_up', + 'unique_id': '1234567890_white_brightness_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_40_key][button.led_infrared_via_test_ir_emitter_white_brightness_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter White brightness up', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_white_brightness_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_40_key][button.led_infrared_via_test_ir_emitter_white_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_white_off', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'White off', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'White off', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'white_off', + 'unique_id': '1234567890_white_off', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_40_key][button.led_infrared_via_test_ir_emitter_white_off-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter White off', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_white_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_40_key][button.led_infrared_via_test_ir_emitter_white_on-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_white_on', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'White on', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'White on', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'white_on', + 'unique_id': '1234567890_white_on', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_40_key][button.led_infrared_via_test_ir_emitter_white_on-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter White on', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_white_on', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_44_key][button.led_infrared_via_test_ir_emitter_blue_down-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_blue_down', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Blue down', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Blue down', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'blue_down', + 'unique_id': '1234567890_blue_down', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_44_key][button.led_infrared_via_test_ir_emitter_blue_down-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter Blue down', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_blue_down', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_44_key][button.led_infrared_via_test_ir_emitter_blue_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_blue_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Blue up', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Blue up', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'blue_up', + 'unique_id': '1234567890_blue_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_44_key][button.led_infrared_via_test_ir_emitter_blue_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter Blue up', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_blue_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_44_key][button.led_infrared_via_test_ir_emitter_brightness_down-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_brightness_down', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Brightness down', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Brightness down', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'brightness_down', + 'unique_id': '1234567890_brightness_down', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_44_key][button.led_infrared_via_test_ir_emitter_brightness_down-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter Brightness down', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_brightness_down', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_44_key][button.led_infrared_via_test_ir_emitter_brightness_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_brightness_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Brightness up', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Brightness up', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'brightness_up', + 'unique_id': '1234567890_brightness_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_44_key][button.led_infrared_via_test_ir_emitter_brightness_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter Brightness up', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_brightness_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_44_key][button.led_infrared_via_test_ir_emitter_green_down-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_green_down', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Green down', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Green down', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'green_down', + 'unique_id': '1234567890_green_down', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_44_key][button.led_infrared_via_test_ir_emitter_green_down-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter Green down', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_green_down', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_44_key][button.led_infrared_via_test_ir_emitter_green_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_green_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Green up', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Green up', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'green_up', + 'unique_id': '1234567890_green_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_44_key][button.led_infrared_via_test_ir_emitter_green_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter Green up', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_green_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_44_key][button.led_infrared_via_test_ir_emitter_quick-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_quick', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Quick', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Quick', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'quick', + 'unique_id': '1234567890_quick', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_44_key][button.led_infrared_via_test_ir_emitter_quick-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter Quick', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_quick', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_44_key][button.led_infrared_via_test_ir_emitter_red_down-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_red_down', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Red down', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Red down', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'red_down', + 'unique_id': '1234567890_red_down', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_44_key][button.led_infrared_via_test_ir_emitter_red_down-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter Red down', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_red_down', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_44_key][button.led_infrared_via_test_ir_emitter_red_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_red_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Red up', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Red up', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'red_up', + 'unique_id': '1234567890_red_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_44_key][button.led_infrared_via_test_ir_emitter_red_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter Red up', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_red_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[generic_44_key][button.led_infrared_via_test_ir_emitter_slow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.led_infrared_via_test_ir_emitter_slow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Slow', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Slow', + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'slow', + 'unique_id': '1234567890_slow', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[generic_44_key][button.led_infrared_via_test_ir_emitter_slow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LED Infrared via Test IR emitter Slow', + }), + 'context': , + 'entity_id': 'button.led_infrared_via_test_ir_emitter_slow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/led_infrared/test_button.py b/tests/components/led_infrared/test_button.py index ef14f1737b0150..1d18aa0f89d3e3 100644 --- a/tests/components/led_infrared/test_button.py +++ b/tests/components/led_infrared/test_button.py @@ -3,7 +3,12 @@ from collections.abc import Generator from unittest.mock import patch -from infrared_protocols.codes.generic.led import Generic13KeyCode, Generic24KeyCode +from infrared_protocols.codes.generic.led import ( + Generic13KeyCode, + Generic24KeyCode, + Generic40KeyCode, + Generic44KeyCode, +) import pytest from syrupy.assertion import SnapshotAssertion @@ -34,6 +39,16 @@ def button_only() -> Generator[None]: yield +@pytest.mark.parametrize( + "config_entry", + [ + LEDIrDeviceType.GENERIC_13_KEY, + LEDIrDeviceType.GENERIC_24_KEY, + LEDIrDeviceType.GENERIC_40_KEY, + LEDIrDeviceType.GENERIC_44_KEY, + ], + indirect=True, +) @pytest.mark.usefixtures("mock_infrared_emitter_entity") async def test_setup( hass: HomeAssistant, @@ -80,6 +95,116 @@ async def test_setup( "timer", [Generic13KeyCode.TIMER], ), + ( + LEDIrDeviceType.GENERIC_40_KEY, + "brightness_up", + [Generic40KeyCode.BRIGHTNESS_UP], + ), + ( + LEDIrDeviceType.GENERIC_40_KEY, + "brightness_down", + [Generic40KeyCode.BRIGHTNESS_DOWN], + ), + ( + LEDIrDeviceType.GENERIC_40_KEY, + "white_brightness_up", + [Generic40KeyCode.WHITE_BRIGHTNESS_UP], + ), + ( + LEDIrDeviceType.GENERIC_40_KEY, + "white_brightness_down", + [Generic40KeyCode.WHITE_BRIGHTNESS_DOWN], + ), + ( + LEDIrDeviceType.GENERIC_40_KEY, + "white_on", + [Generic40KeyCode.WHITE_ON], + ), + ( + LEDIrDeviceType.GENERIC_40_KEY, + "white_off", + [Generic40KeyCode.WHITE_OFF], + ), + ( + LEDIrDeviceType.GENERIC_40_KEY, + "white_brightness_25", + [Generic40KeyCode.WHITE_BRIGHTNESS_25], + ), + ( + LEDIrDeviceType.GENERIC_40_KEY, + "white_brightness_50", + [Generic40KeyCode.WHITE_BRIGHTNESS_50], + ), + ( + LEDIrDeviceType.GENERIC_40_KEY, + "white_brightness_75", + [Generic40KeyCode.WHITE_BRIGHTNESS_75], + ), + ( + LEDIrDeviceType.GENERIC_40_KEY, + "white_brightness_100", + [Generic40KeyCode.WHITE_BRIGHTNESS_100], + ), + ( + LEDIrDeviceType.GENERIC_40_KEY, + "quick", + [Generic40KeyCode.QUICK], + ), + ( + LEDIrDeviceType.GENERIC_40_KEY, + "slow", + [Generic40KeyCode.SLOW], + ), + ( + LEDIrDeviceType.GENERIC_44_KEY, + "brightness_up", + [Generic44KeyCode.BRIGHTNESS_UP], + ), + ( + LEDIrDeviceType.GENERIC_44_KEY, + "brightness_down", + [Generic44KeyCode.BRIGHTNESS_DOWN], + ), + ( + LEDIrDeviceType.GENERIC_44_KEY, + "red_up", + [Generic44KeyCode.RED_UP], + ), + ( + LEDIrDeviceType.GENERIC_44_KEY, + "green_up", + [Generic44KeyCode.GREEN_UP], + ), + ( + LEDIrDeviceType.GENERIC_44_KEY, + "blue_up", + [Generic44KeyCode.BLUE_UP], + ), + ( + LEDIrDeviceType.GENERIC_44_KEY, + "red_down", + [Generic44KeyCode.RED_DOWN], + ), + ( + LEDIrDeviceType.GENERIC_44_KEY, + "green_down", + [Generic44KeyCode.GREEN_DOWN], + ), + ( + LEDIrDeviceType.GENERIC_44_KEY, + "blue_down", + [Generic44KeyCode.BLUE_DOWN], + ), + ( + LEDIrDeviceType.GENERIC_44_KEY, + "quick", + [Generic44KeyCode.QUICK], + ), + ( + LEDIrDeviceType.GENERIC_44_KEY, + "slow", + [Generic44KeyCode.SLOW], + ), ], ) @pytest.mark.usefixtures("infrared_codes") @@ -89,7 +214,9 @@ async def test_button_press( entity_registry: er.EntityRegistry, device_type: LEDIrDeviceType, key: str, - expected_codes: list[Generic24KeyCode | Generic13KeyCode], + expected_codes: list[ + Generic24KeyCode | Generic13KeyCode | Generic40KeyCode | Generic44KeyCode + ], ) -> None: """Test button press action.""" config_entry = MockConfigEntry( diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index 8511efb9a27f43..03630840d0c39d 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -27,7 +27,7 @@ async def help_test_async_lock_service( hass: HomeAssistant, entity_id: str, service: str, - code: str | None | UndefinedType = UNDEFINED, + code: str | UndefinedType | None = UNDEFINED, ) -> None: """Help to lock a test lock.""" data: dict[str, Any] = {"entity_id": entity_id} diff --git a/tests/components/mysensors/conftest.py b/tests/components/mysensors/conftest.py index f14ad617f23e6b..a7a2e5dda869ac 100644 --- a/tests/components/mysensors/conftest.py +++ b/tests/components/mysensors/conftest.py @@ -126,8 +126,15 @@ async def serial_entry_fixture(hass: HomeAssistant) -> MockConfigEntry: @pytest.fixture(name="config_entry") -def config_entry_fixture(serial_entry: MockConfigEntry) -> MockConfigEntry: +def config_entry_fixture( + serial_entry: MockConfigEntry, request: pytest.FixtureRequest +) -> MockConfigEntry: """Provide the config entry used for integration set up.""" + if hasattr(request, "param"): + return MockConfigEntry( + domain=DOMAIN, + data={**serial_entry.data, CONF_VERSION: request.param}, + ) return serial_entry @@ -225,6 +232,12 @@ def cover_node_percentage_state_fixture() -> dict: return load_nodes_state("cover_node_percentage_state.json") +@pytest.fixture(name="cover_node_tilt_state", scope="package") +def cover_node_tilt_state_fixture() -> dict: + """Load the cover tilt node state.""" + return load_nodes_state("cover_node_tilt_state.json") + + @pytest.fixture def cover_node_percentage( gateway_nodes: dict[int, Sensor], cover_node_percentage_state: dict @@ -234,6 +247,15 @@ def cover_node_percentage( return nodes[1] +@pytest.fixture +def cover_node_tilt( + gateway_nodes: dict[int, Sensor], cover_node_tilt_state: dict +) -> Sensor: + """Load the cover tilt child node.""" + nodes = update_gateway_nodes(gateway_nodes, deepcopy(cover_node_tilt_state)) + return nodes[1] + + @pytest.fixture(name="door_sensor_state", scope="package") def door_sensor_state_fixture() -> dict: """Load the door sensor state.""" diff --git a/tests/components/mysensors/fixtures/cover_node_tilt_state.json b/tests/components/mysensors/fixtures/cover_node_tilt_state.json new file mode 100644 index 00000000000000..9692ad5daa9fd3 --- /dev/null +++ b/tests/components/mysensors/fixtures/cover_node_tilt_state.json @@ -0,0 +1,25 @@ +{ + "1": { + "sensor_id": 1, + "children": { + "1": { + "id": 1, + "type": 5, + "description": "", + "values": { + "3": "0", + "29": "0", + "30": "0", + "31": "0", + "58": "0" + } + } + }, + "type": 17, + "sketch_name": "Cover Tilt Node", + "sketch_version": "1.0", + "battery_level": 0, + "protocol_version": "2.4", + "heartbeat": 0 + } +} diff --git a/tests/components/mysensors/test_cover.py b/tests/components/mysensors/test_cover.py index aecd5facbcdf0d..69d3b3aad4c598 100644 --- a/tests/components/mysensors/test_cover.py +++ b/tests/components/mysensors/test_cover.py @@ -4,15 +4,22 @@ from unittest.mock import MagicMock, call from mysensors.sensor import Sensor +import pytest from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, ATTR_POSITION, + ATTR_TILT_POSITION, DOMAIN as COVER_DOMAIN, SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER, + SERVICE_STOP_COVER_TILT, CoverState, ) from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID @@ -275,3 +282,98 @@ async def test_cover_node_binary( assert state assert state.state == CoverState.CLOSED + + +@pytest.mark.parametrize("config_entry", ["2.4"], indirect=True) +async def test_cover_node_tilt( + hass: HomeAssistant, + cover_node_tilt: Sensor, + receive_message: Callable[[str], None], + transport_write: MagicMock, +) -> None: + """Test a cover tilt node.""" + entity_id = "cover.cover_tilt_node_1_1" + + state = hass.states.get(entity_id) + + assert state + assert hass.states.async_entity_ids(COVER_DOMAIN) == [entity_id] + assert state.state == CoverState.CLOSED + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: entity_id, ATTR_TILT_POSITION: 25}, + blocking=True, + ) + + assert transport_write.call_count == 1 + assert transport_write.call_args == call("1;1;1;1;58;25\n") + receive_message("1;1;1;0;58;25\n") + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + assert state + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 25 + + transport_write.reset_mock() + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert transport_write.call_count == 1 + assert transport_write.call_args == call("1;1;1;1;58;100\n") + + receive_message("1;1;1;0;58;100\n") + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + assert state + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 + + transport_write.reset_mock() + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert transport_write.call_count == 1 + assert transport_write.call_args == call("1;1;1;1;58;0\n") + + receive_message("1;1;1;0;58;0\n") + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + assert state + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 + + transport_write.reset_mock() + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER_TILT, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert transport_write.call_count == 1 + assert transport_write.call_args == call("1;1;1;1;31;1\n") + + receive_message("1;1;1;0;31;1\n") + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + assert state + assert state.state == CoverState.CLOSED diff --git a/tests/components/nfandroidtv/test_init.py b/tests/components/nfandroidtv/test_init.py index 01f2809a5b7a36..8bb93cb9847188 100644 --- a/tests/components/nfandroidtv/test_init.py +++ b/tests/components/nfandroidtv/test_init.py @@ -5,6 +5,7 @@ from notifications_android_tv.notifications import ConnectError import pytest +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -41,3 +42,4 @@ async def test_config_entry_not_ready( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert hass.services.has_service(NOTIFY_DOMAIN, "android_tv_fire_tv_1_2_3_4") diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index 378e3c7f66580e..00980de44328ab 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -20,6 +20,7 @@ GET_MAPS_SERVICE_NAME, GET_VACUUM_CURRENT_POSITION_SERVICE_NAME, SET_VACUUM_GOTO_POSITION_SERVICE_NAME, + SET_VACUUM_ZONED_CLEANING_SERVICE_NAME, ) from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, @@ -318,6 +319,63 @@ async def test_goto_not_supported( ) +async def test_zoned_cleaning( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + vacuum_command: Mock, +) -> None: + """Test cleaning specific zones.""" + await hass.services.async_call( + DOMAIN, + SET_VACUUM_ZONED_CLEANING_SERVICE_NAME, + { + ATTR_ENTITY_ID: ENTITY_ID, + "x1": 28582, + "y1": 21363, + "x2": 27425, + "y2": 22816, + "repeats": 0, + }, + blocking=True, + ) + assert vacuum_command.send.call_count == 1 + assert vacuum_command.send.call_args == ( + call(RoborockCommand.APP_ZONED_CLEAN, params=[[28582, 21363, 27425, 22816, 0]]) + ) + + +@pytest.mark.parametrize( + "entity_id", + [ + Q7_ENTITY_ID, + Q10_ENTITY_ID, + ], +) +async def test_zoned_cleaning_not_supported( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + entity_id: str, +) -> None: + """Test that unsupported vacuums raise ServiceNotSupported for zoned cleaning.""" + with pytest.raises( + ServiceNotSupported, + match="does not support action roborock.set_vacuum_zoned_cleaning", + ): + await hass.services.async_call( + DOMAIN, + SET_VACUUM_ZONED_CLEANING_SERVICE_NAME, + { + ATTR_ENTITY_ID: entity_id, + "x1": 28582, + "y1": 21363, + "x2": 27425, + "y2": 22816, + "repeats": 0, + }, + blocking=True, + ) + + async def test_get_current_position( hass: HomeAssistant, setup_entry: MockConfigEntry, diff --git a/tests/components/schlage/conftest.py b/tests/components/schlage/conftest.py index 334d3e62edafeb..93f28769d63c17 100644 --- a/tests/components/schlage/conftest.py +++ b/tests/components/schlage/conftest.py @@ -1,6 +1,6 @@ """Common fixtures for the Schlage tests.""" -from collections.abc import Generator +from collections.abc import Awaitable, Callable, Generator from typing import Any from unittest.mock import AsyncMock, Mock, create_autospec, patch @@ -32,20 +32,32 @@ def mock_config_entry() -> MockSchlageConfigEntry: @pytest.fixture async def mock_added_config_entry( + mock_add_config_entry: Callable[[], Awaitable[MockSchlageConfigEntry]], +) -> MockSchlageConfigEntry: + """Return a mock ConfigEntry that has been added to and set up in Home Assistant.""" + return await mock_add_config_entry() + + +@pytest.fixture +async def mock_add_config_entry( hass: HomeAssistant, mock_config_entry: MockSchlageConfigEntry, mock_pyschlage_auth: Mock, mock_schlage: Mock, mock_lock: Mock, -) -> MockSchlageConfigEntry: - """Mock ConfigEntry that's been added to HA.""" - mock_schlage.locks.return_value = [mock_lock] - mock_schlage.users.return_value = [] - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - assert DOMAIN in hass.config_entries.async_domains() - return mock_config_entry +) -> Callable[[], Awaitable[MockSchlageConfigEntry]]: + """Return a callable that adds and sets up the mock ConfigEntry in HA.""" + + async def callback() -> MockSchlageConfigEntry: + mock_schlage.locks.return_value = [mock_lock] + mock_schlage.users.return_value = [] + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert DOMAIN in hass.config_entries.async_domains() + return mock_config_entry + + return callback @pytest.fixture @@ -58,14 +70,14 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_schlage() -> Mock: +def mock_schlage() -> Generator[Mock]: """Mock pyschlage.Schlage.""" with patch("pyschlage.Schlage", autospec=True) as mock_schlage: yield mock_schlage.return_value @pytest.fixture -def mock_pyschlage_auth() -> Mock: +def mock_pyschlage_auth() -> Generator[Mock]: """Mock pyschlage.Auth.""" with patch("pyschlage.Auth", autospec=True) as mock_auth: mock_auth.return_value.user_id = "abc123" diff --git a/tests/components/schlage/snapshots/test_binary_sensor.ambr b/tests/components/schlage/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000000..b7794ddebd38e5 --- /dev/null +++ b/tests/components/schlage/snapshots/test_binary_sensor.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_binary_sensor_attributes[binary_sensor.vault_door_keypad_disabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.vault_door_keypad_disabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Keypad disabled', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Keypad disabled', + 'platform': 'schlage', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'keypad_disabled', + 'unique_id': 'test_keypad_disabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_attributes[binary_sensor.vault_door_keypad_disabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : 'Vault Door Keypad disabled', + }), + 'context': , + 'entity_id': 'binary_sensor.vault_door_keypad_disabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/schlage/snapshots/test_lock.ambr b/tests/components/schlage/snapshots/test_lock.ambr new file mode 100644 index 00000000000000..da61205399ea10 --- /dev/null +++ b/tests/components/schlage/snapshots/test_lock.ambr @@ -0,0 +1,53 @@ +# serializer version: 1 +# name: test_lock_attributes[lock.vault_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.vault_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'schlage', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock_attributes[lock.vault_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'thumbturn', + : 'Vault Door', + : , + }), + 'context': , + 'entity_id': 'lock.vault_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- diff --git a/tests/components/schlage/snapshots/test_select.ambr b/tests/components/schlage/snapshots/test_select.ambr new file mode 100644 index 00000000000000..a53b76dbd76a30 --- /dev/null +++ b/tests/components/schlage/snapshots/test_select.ambr @@ -0,0 +1,76 @@ +# serializer version: 1 +# name: test_select_attributes[select.vault_door_auto_lock_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + '0', + '5', + '15', + '30', + '60', + '120', + '240', + '300', + '360', + '600', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.vault_door_auto_lock_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Auto-lock time', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto-lock time', + 'platform': 'schlage', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'auto_lock_time', + 'unique_id': 'test_auto_lock_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_attributes[select.vault_door_auto_lock_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Vault Door Auto-lock time', + : list([ + '0', + '5', + '15', + '30', + '60', + '120', + '240', + '300', + '360', + '600', + ]), + }), + 'context': , + 'entity_id': 'select.vault_door_auto_lock_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15', + }) +# --- diff --git a/tests/components/schlage/snapshots/test_sensor.ambr b/tests/components/schlage/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..ae8d301eaf28dd --- /dev/null +++ b/tests/components/schlage/snapshots/test_sensor.ambr @@ -0,0 +1,56 @@ +# serializer version: 1 +# name: test_sensor_attributes[sensor.vault_door_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.vault_door_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'schlage', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test_battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_attributes[sensor.vault_door_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'battery', + : 'Vault Door Battery', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.vault_door_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- diff --git a/tests/components/schlage/snapshots/test_switch.ambr b/tests/components/schlage/snapshots/test_switch.ambr new file mode 100644 index 00000000000000..83ada67d660f20 --- /dev/null +++ b/tests/components/schlage/snapshots/test_switch.ambr @@ -0,0 +1,103 @@ +# serializer version: 1 +# name: test_switch_attributes[switch.vault_door_1_touch_locking-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.vault_door_1_touch_locking', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': '1-Touch Locking', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '1-Touch Locking', + 'platform': 'schlage', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lock_and_leave', + 'unique_id': 'test_lock_and_leve', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_attributes[switch.vault_door_1_touch_locking-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'switch', + : 'Vault Door 1-Touch Locking', + }), + 'context': , + 'entity_id': 'switch.vault_door_1_touch_locking', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_attributes[switch.vault_door_keypress_beep-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.vault_door_keypress_beep', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Keypress Beep', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Keypress Beep', + 'platform': 'schlage', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'beeper', + 'unique_id': 'test_beeper', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_attributes[switch.vault_door_keypress_beep-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'switch', + : 'Vault Door Keypress Beep', + }), + 'context': , + 'entity_id': 'switch.vault_door_keypress_beep', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/schlage/test_binary_sensor.py b/tests/components/schlage/test_binary_sensor.py index a073097f755d76..ef8fadfaa18fb3 100644 --- a/tests/components/schlage/test_binary_sensor.py +++ b/tests/components/schlage/test_binary_sensor.py @@ -1,65 +1,96 @@ """Test Schlage binary_sensor.""" -from datetime import timedelta -from unittest.mock import Mock +from collections.abc import Awaitable, Callable +from unittest.mock import Mock, patch from freezegun.api import FrozenDateTimeFactory from pyschlage.exceptions import UnknownError +from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.const import STATE_ON +from homeassistant.components.schlage.const import UPDATE_INTERVAL +from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import MockSchlageConfigEntry -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform + + +async def test_binary_sensor_attributes( + hass: HomeAssistant, + mock_add_config_entry: Callable[[], Awaitable[MockSchlageConfigEntry]], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test binary sensor attributes.""" + with patch("homeassistant.components.schlage.PLATFORMS", [Platform.BINARY_SENSOR]): + config_entry = await mock_add_config_entry() + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) async def test_keypad_disabled_binary_sensor( hass: HomeAssistant, - mock_schlage: Mock, mock_lock: Mock, - mock_added_config_entry: MockSchlageConfigEntry, - freezer: FrozenDateTimeFactory, + mock_add_config_entry: Callable[[], Awaitable[MockSchlageConfigEntry]], ) -> None: """Test the keypad_disabled binary_sensor.""" mock_lock.keypad_disabled.reset_mock() mock_lock.keypad_disabled.return_value = True - - # Make the coordinator refresh data. - freezer.tick(timedelta(seconds=30)) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + with patch("homeassistant.components.schlage.PLATFORMS", [Platform.BINARY_SENSOR]): + await mock_add_config_entry() keypad = hass.states.get("binary_sensor.vault_door_keypad_disabled") assert keypad is not None assert keypad.state == STATE_ON assert keypad.attributes["device_class"] == BinarySensorDeviceClass.PROBLEM + mock_lock.keypad_disabled.assert_called_once_with([]) + + +async def test_keypad_disabled_binary_sensor_logs_failure( + hass: HomeAssistant, + mock_lock: Mock, + mock_add_config_entry: Callable[[], Awaitable[MockSchlageConfigEntry]], +) -> None: + """Test the keypad_disabled binary_sensor when loading logs fails on initial setup.""" + mock_lock.keypad_disabled.reset_mock() + mock_lock.keypad_disabled.return_value = True + mock_lock.logs.side_effect = UnknownError("Cannot load logs") + with patch("homeassistant.components.schlage.PLATFORMS", [Platform.BINARY_SENSOR]): + await mock_add_config_entry() + keypad = hass.states.get("binary_sensor.vault_door_keypad_disabled") + assert keypad is not None + assert keypad.state == STATE_ON + assert keypad.attributes["device_class"] == BinarySensorDeviceClass.PROBLEM mock_lock.keypad_disabled.assert_called_once_with([]) -async def test_keypad_disabled_binary_sensor_use_previous_logs_on_failure( +async def test_keypad_disabled_uses_previous_logs_on_refresh_failure( hass: HomeAssistant, - mock_schlage: Mock, mock_lock: Mock, - mock_added_config_entry: MockSchlageConfigEntry, + mock_add_config_entry: Callable[[], Awaitable[MockSchlageConfigEntry]], freezer: FrozenDateTimeFactory, ) -> None: - """Test the keypad_disabled binary_sensor.""" + """Test that previous logs are used when a coordinator refresh fails to fetch logs.""" + with patch("homeassistant.components.schlage.PLATFORMS", [Platform.BINARY_SENSOR]): + await mock_add_config_entry() + + keypad = hass.states.get("binary_sensor.vault_door_keypad_disabled") + assert keypad is not None + assert keypad.state == STATE_OFF + mock_lock.keypad_disabled.reset_mock() mock_lock.keypad_disabled.return_value = True - mock_lock.logs.reset_mock() mock_lock.logs.side_effect = UnknownError("Cannot load logs") - # Make the coordinator refresh data. - freezer.tick(timedelta(seconds=30)) + freezer.tick(UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) keypad = hass.states.get("binary_sensor.vault_door_keypad_disabled") assert keypad is not None assert keypad.state == STATE_ON - assert keypad.attributes["device_class"] == BinarySensorDeviceClass.PROBLEM - + # Previous logs (empty list from initial refresh) are passed when fetch fails. mock_lock.keypad_disabled.assert_called_once_with([]) diff --git a/tests/components/schlage/test_lock.py b/tests/components/schlage/test_lock.py index 9756141005d6eb..f593ecd513aa49 100644 --- a/tests/components/schlage/test_lock.py +++ b/tests/components/schlage/test_lock.py @@ -1,12 +1,13 @@ """Test schlage lock.""" -from datetime import timedelta -from unittest.mock import Mock +from collections.abc import Awaitable, Callable +from unittest.mock import Mock, patch from freezegun.api import FrozenDateTimeFactory from pyschlage.code import AccessCode from pyschlage.exceptions import Error as SchlageError import pytest +from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState @@ -22,34 +23,42 @@ SERVICE_LOCK, SERVICE_UNLOCK, STATE_UNAVAILABLE, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er from . import MockSchlageConfigEntry -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform async def test_lock_attributes( hass: HomeAssistant, - mock_added_config_entry: MockSchlageConfigEntry, - mock_schlage: Mock, - mock_lock: Mock, - freezer: FrozenDateTimeFactory, + mock_add_config_entry: Callable[[], Awaitable[MockSchlageConfigEntry]], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test lock attributes.""" - lock = hass.states.get("lock.vault_door") - assert lock is not None - assert lock.state == LockState.UNLOCKED - assert lock.attributes["changed_by"] == "thumbturn" + with patch("homeassistant.components.schlage.PLATFORMS", [Platform.LOCK]): + config_entry = await mock_add_config_entry() + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + +async def test_lock_jammed( + hass: HomeAssistant, + mock_lock: Mock, + mock_added_config_entry: MockSchlageConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test lock jammed state.""" mock_lock.is_locked = False mock_lock.is_jammed = True - # Make the coordinator refresh data. - freezer.tick(timedelta(seconds=30)) + freezer.tick(UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) + lock = hass.states.get("lock.vault_door") assert lock is not None assert lock.state == LockState.JAMMED @@ -107,16 +116,13 @@ async def test_changed_by( """Test population of the changed_by attribute.""" mock_lock.last_changed_by.reset_mock() mock_lock.last_changed_by.return_value = "access code - foo" - - # Make the coordinator refresh data. - freezer.tick(timedelta(seconds=30)) + freezer.tick(UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - mock_lock.last_changed_by.assert_called_with() - lock_device = hass.states.get("lock.vault_door") - assert lock_device is not None - assert lock_device.attributes.get("changed_by") == "access code - foo" + lock = hass.states.get("lock.vault_door") + assert lock is not None + assert lock.attributes["changed_by"] == "access code - foo" @pytest.mark.parametrize( diff --git a/tests/components/schlage/test_select.py b/tests/components/schlage/test_select.py index c18ceb0ec8e987..21e49580b3333f 100644 --- a/tests/components/schlage/test_select.py +++ b/tests/components/schlage/test_select.py @@ -1,8 +1,10 @@ """Test Schlage select.""" -from unittest.mock import Mock +from collections.abc import Awaitable, Callable +from unittest.mock import Mock, patch from pyschlage.lock import AUTO_LOCK_TIMES +from syrupy.assertion import SnapshotAssertion from homeassistant.components.schlage.const import DOMAIN from homeassistant.components.select import ( @@ -12,27 +14,43 @@ ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.translation import LOCALE_EN, async_get_translations from . import MockSchlageConfigEntry +from tests.common import snapshot_platform + + +async def test_select_attributes( + hass: HomeAssistant, + mock_add_config_entry: Callable[[], Awaitable[MockSchlageConfigEntry]], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test select attributes.""" + with patch("homeassistant.components.schlage.PLATFORMS", [Platform.SELECT]): + config_entry = await mock_add_config_entry() + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + async def test_select( hass: HomeAssistant, mock_lock: Mock, - mock_added_config_entry: MockSchlageConfigEntry, + mock_add_config_entry: Callable[[], Awaitable[MockSchlageConfigEntry]], ) -> None: """Test the auto-lock time select entity.""" - entity_id = "select.vault_door_auto_lock_time" + with patch("homeassistant.components.schlage.PLATFORMS", [Platform.SELECT]): + await mock_add_config_entry() - select = hass.states.get(entity_id) + select = hass.states.get("select.vault_door_auto_lock_time") assert select is not None assert select.state == "15" await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "30"}, + {ATTR_ENTITY_ID: select.entity_id, ATTR_OPTION: "30"}, blocking=True, ) mock_lock.set_auto_lock_time.assert_called_once_with(30) diff --git a/tests/components/schlage/test_sensor.py b/tests/components/schlage/test_sensor.py index 9a489f6ff73d9a..84a3745ffe2d9e 100644 --- a/tests/components/schlage/test_sensor.py +++ b/tests/components/schlage/test_sensor.py @@ -1,18 +1,26 @@ """Test schlage sensor.""" -from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.const import PERCENTAGE +from collections.abc import Awaitable, Callable +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import MockSchlageConfigEntry +from tests.common import snapshot_platform + -async def test_battery_sensor( - hass: HomeAssistant, mock_added_config_entry: MockSchlageConfigEntry +async def test_sensor_attributes( + hass: HomeAssistant, + mock_add_config_entry: Callable[[], Awaitable[MockSchlageConfigEntry]], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: - """Test the battery sensor.""" - battery_sensor = hass.states.get("sensor.vault_door_battery") - assert battery_sensor is not None - assert battery_sensor.state == "20" - assert battery_sensor.attributes["unit_of_measurement"] == PERCENTAGE - assert battery_sensor.attributes["device_class"] == SensorDeviceClass.BATTERY + """Test sensor attributes.""" + with patch("homeassistant.components.schlage.PLATFORMS", [Platform.SENSOR]): + config_entry = await mock_add_config_entry() + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/schlage/test_switch.py b/tests/components/schlage/test_switch.py index fc5acc4399f787..c50976b30cb90a 100644 --- a/tests/components/schlage/test_switch.py +++ b/tests/components/schlage/test_switch.py @@ -1,13 +1,36 @@ """Test schlage switch.""" -from unittest.mock import Mock +from collections.abc import Awaitable, Callable +from unittest.mock import Mock, patch + +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + Platform, +) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import MockSchlageConfigEntry +from tests.common import snapshot_platform + + +async def test_switch_attributes( + hass: HomeAssistant, + mock_add_config_entry: Callable[[], Awaitable[MockSchlageConfigEntry]], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test switch attributes.""" + with patch("homeassistant.components.schlage.PLATFORMS", [Platform.SWITCH]): + config_entry = await mock_add_config_entry() + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + async def test_beeper_services( hass: HomeAssistant, diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 6bd9b80705262c..0848d30409d395 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -18,6 +18,7 @@ from homeassistant.setup import async_setup_component from . import ( + set_attribute_value, setup_integration, snapshot_smartthings_entities, trigger_health_update, @@ -63,6 +64,28 @@ async def test_state_update( assert hass.states.get("sensor.theater_ac_office_granit_temperature").state == "20" +@pytest.mark.parametrize("device_fixture", ["multipurpose_sensor"]) +async def test_three_axis_none_value( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test three axis coordinate sensors handle a None value.""" + set_attribute_value(devices, Capability.THREE_AXIS, Attribute.THREE_AXIS, None) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("sensor.theater_deck_door_x_coordinate").state == ( + STATE_UNKNOWN + ) + assert hass.states.get("sensor.theater_deck_door_y_coordinate").state == ( + STATE_UNKNOWN + ) + assert hass.states.get("sensor.theater_deck_door_z_coordinate").state == ( + STATE_UNKNOWN + ) + + @pytest.mark.parametrize( ( "device_fixture", diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 78ba927837a26e..1df6d98b2f90b9 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -485,14 +485,14 @@ def __init__( item_id: str, parent_id: str, item_class: str, - album_art_uri: None | str = None, + album_art_uri: str | None = None, ) -> None: """Initialize the mock item.""" self.title = title self.item_id = item_id self.item_class = item_class self.parent_id = parent_id - self.album_art_uri: None | str = album_art_uri + self.album_art_uri: str | None = album_art_uri def get_uri(self) -> str: """Return URI.""" diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index 9fe566ed877f9e..e28284904118d9 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -1192,6 +1192,21 @@ async def test_flow_preview( }, CoverState.OPEN, ), + ( + # Missing Key + CoverState.OPEN, + { + "current_cover_position": 0, + "current_cover_tilt_position": 10, + "is_closing": False, + }, + STATE_UNKNOWN, + { + "current_position": None, + "current_tilt_position": None, + }, + CoverState.OPEN, + ), ( STATE_UNAVAILABLE, { diff --git a/tests/components/template/test_device_tracker.py b/tests/components/template/test_device_tracker.py index 1eb3d54b490df0..30ff477c20adf2 100644 --- a/tests/components/template/test_device_tracker.py +++ b/tests/components/template/test_device_tracker.py @@ -684,6 +684,21 @@ async def test_flow_preview( "gps_accuracy": 10.0, }, ), + ( + # Missing Key + STATE_HOME, + { + "in_zones": [], + "location_accuracy": 10.0, + }, + STATE_UNKNOWN, + { + "in_zones": [], + "latitude": None, + "longitude": None, + "gps_accuracy": None, + }, + ), ( STATE_UNAVAILABLE, { diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index 1c2ad479f9bb6d..61b812ebe40eba 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -1435,6 +1435,21 @@ async def test_flow_preview( "direction": DIRECTION_FORWARD, }, ), + ( + # Missing Key + STATE_ON, + { + "is_on": True, + "percentage": 0, + }, + STATE_UNKNOWN, + { + "percentage": None, + "preset_mode": None, + "oscillating": None, + "direction": None, + }, + ), ( STATE_UNAVAILABLE, { diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index e990abd83501bc..6fcf33ca5fde10 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -31,12 +31,14 @@ ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.helpers.typing import ConfigType from .conftest import ( ConfigurationStyle, TemplatePlatformSetup, assert_action, + assert_state_and_attributes, async_get_flow_preview_state, async_trigger, make_test_action, @@ -44,12 +46,15 @@ setup_and_test_nested_unique_id, setup_and_test_unique_id, setup_entity, + setup_mock_template_entity_restore_state, + setup_restore_template_entity, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_mock_restore_state_shutdown_restart from tests.typing import WebSocketGenerator TEST_STATE_ENTITY_ID = "light.test_state" +TEST_SENSOR_STATE_ENTITY_ID = "sensor.test_state" TEST_AVAILABILITY_ENTITY = "binary_sensor.availability" TEST_LIGHT = TemplatePlatformSetup( @@ -57,6 +62,7 @@ "test_light", make_test_trigger( TEST_STATE_ENTITY_ID, + TEST_SENSOR_STATE_ENTITY_ID, TEST_AVAILABILITY_ENTITY, ), ) @@ -1893,3 +1899,335 @@ async def test_flow_preview( ) assert state["state"] == STATE_ON + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ( + "saved_state", + "saved_extra_data", + "initial_state", + "initial_attributes", + ), + [ + ( + STATE_OFF, + { + "is_on": False, + "brightness": None, + "color_mode": "color_temp", + "color_temp_kelvin": None, + "effect_list": None, + "effect": None, + "hs_color": None, + "max_color_temp_kelvin": 6535, + "min_color_temp_kelvin": 2000, + "rgb_color": None, + "rgbw_color": None, + "rgbww_color": None, + "supported_color_modes": ["color_temp"], + "xy_color": None, + }, + STATE_OFF, + { + "brightness": None, + "color_mode": None, + "color_temp_kelvin": None, + "effect_list": None, + "effect": None, + "max_color_temp_kelvin": 6535, + "min_color_temp_kelvin": 2000, + "rgb_color": None, + "rgbw_color": None, + "rgbww_color": None, + "supported_color_modes": [ColorMode.COLOR_TEMP], + "xy_color": None, + }, + ), + ( + # Missing key + STATE_OFF, + { + "is_on": False, + "brightness": None, + "color_mode": "color_temp", + "color_temp_kelvin": None, + "effect_list": None, + "effect": None, + "hs_color": None, + "max_color_temp_kelvin": 6535, + "min_color_temp_kelvin": 2000, + "rgb_color": None, + "rgbw_color": None, + "rgbww_color": None, + "xy_color": None, + }, + STATE_UNKNOWN, + {}, + ), + ( + # Bad color mode + STATE_OFF, + { + "is_on": False, + "brightness": None, + "color_mode": "color_tem", + "color_temp_kelvin": None, + "effect_list": None, + "effect": None, + "hs_color": None, + "max_color_temp_kelvin": 6535, + "min_color_temp_kelvin": 2000, + "rgb_color": None, + "rgbw_color": None, + "rgbww_color": None, + "supported_color_modes": ["color_temp"], + "xy_color": None, + }, + STATE_UNKNOWN, + {}, + ), + ( + # Bad supported color modes + STATE_OFF, + { + "is_on": False, + "brightness": None, + "color_mode": "color_temp", + "color_temp_kelvin": None, + "effect_list": None, + "effect": None, + "hs_color": None, + "max_color_temp_kelvin": 6535, + "min_color_temp_kelvin": 2000, + "rgb_color": None, + "rgbw_color": None, + "rgbww_color": None, + "supported_color_modes": ["color_tep"], + "xy_color": None, + }, + STATE_UNKNOWN, + {}, + ), + ( + STATE_UNAVAILABLE, + { + "is_on": False, + "brightness": None, + "color_mode": None, + "color_temp_kelvin": None, + "effect_list": None, + "effect": None, + "hs_color": None, + "max_color_temp_kelvin": 6535, + "min_color_temp_kelvin": 2000, + "rgb_color": None, + "rgbw_color": None, + "rgbww_color": None, + "supported_color_modes": ["onoff"], + "xy_color": None, + }, + STATE_UNKNOWN, + { + "brightness": None, + "color_mode": None, + "color_temp_kelvin": None, + "effect_list": None, + "effect": None, + "max_color_temp_kelvin": 6535, + "min_color_temp_kelvin": 2000, + "rgb_color": None, + "rgbw_color": None, + "rgbww_color": None, + "supported_color_modes": [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ColorMode.RGB, + ColorMode.RGBW, + ColorMode.RGBWW, + ColorMode.XY, + ], + "xy_color": None, + }, + ), + ( + STATE_UNKNOWN, + { + "is_on": False, + "brightness": None, + "color_mode": None, + "color_temp_kelvin": None, + "effect_list": None, + "effect": None, + "hs_color": None, + "max_color_temp_kelvin": 6535, + "min_color_temp_kelvin": 2000, + "rgb_color": None, + "rgbw_color": None, + "rgbww_color": None, + "supported_color_modes": ["onoff"], + "xy_color": None, + }, + STATE_UNKNOWN, + { + "brightness": None, + "color_mode": None, + "color_temp_kelvin": None, + "effect_list": None, + "effect": None, + "max_color_temp_kelvin": 6535, + "min_color_temp_kelvin": 2000, + "rgb_color": None, + "rgbw_color": None, + "rgbww_color": None, + "supported_color_modes": [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ColorMode.RGB, + ColorMode.RGBW, + ColorMode.RGBWW, + ColorMode.XY, + ], + "xy_color": None, + }, + ), + ], +) +async def test_restore_state( + hass: HomeAssistant, + style: ConfigurationStyle, + saved_state: str, + saved_extra_data: dict | None, + initial_state: str, + initial_attributes: ConfigType, +) -> None: + """Test restoring trigger template light.""" + + restored_attributes = { # These should be ignored + "current_position": 5, + "current_tilt_position": 5, + } + + setup_mock_template_entity_restore_state( + hass, + TEST_LIGHT, + saved_state, + saved_extra_data=saved_extra_data, + saved_attributes=restored_attributes, + ) + + await setup_restore_template_entity( + hass, + TEST_LIGHT, + style, + { + "state": "{{ state_attr('sensor.test_state', 'is_on') }}", + "turn_on": [], + "turn_off": [], + "level": "{{ state_attr('sensor.test_state', 'brightness') }}", + "set_level": [], + "temperature": "{{ state_attr('sensor.test_state', 'color_temp_kelvin') }}", + "set_temperature": [], + "effect_list": "{{ state_attr('sensor.test_state', 'effect_list') }}", + "effect": "{{ state_attr('sensor.test_state', 'effect') }}", + "set_effect": [], + "hs": "{{ state_attr('sensor.test_state', 'hs_color') }}", + "set_hs": [], + "min_mireds": "{{ state_attr('sensor.test_state', 'max_color_temp_kelvin') }}", + "max_mireds": "{{ state_attr('sensor.test_state', 'min_color_temp_kelvin') }}", + "rgb": "{{ state_attr('sensor.test_state', 'rgb_color') }}", + "set_rgb": [], + "rgbw": "{{ state_attr('sensor.test_state', 'rgbw_color') }}", + "set_rgbw": [], + "rgbww": "{{ state_attr('sensor.test_state', 'rgbww_color') }}", + "set_rgbww": [], + "xy": "{{ state_attr('sensor.test_state', 'xy_color') }}", + "set_xy": [], + }, + "state_attr('sensor.test_state', 'is_on') is true", + ) + + assert_state_and_attributes( + hass, + TEST_LIGHT, + initial_state, + initial_attributes, + ) + + await async_trigger( + hass, + "sensor.test_state", + "anything", + {"is_on": True}, + ) + + assert_state_and_attributes(hass, TEST_LIGHT, STATE_ON) + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +async def test_saving_state( + hass: HomeAssistant, + style: ConfigurationStyle, + hass_storage: dict[str, Any], +) -> None: + """Test restore saved state.""" + + await setup_entity( + hass, + TEST_LIGHT, + style, + 1, + config={ + "state": "{{ state_attr('light.test_state', 'is_on') }}", + "turn_on": [], + "turn_off": [], + "level": "{{ state_attr('light.test_state', 'brightness') }}", + "set_level": [], + }, + ) + + await async_trigger( + hass, + TEST_STATE_ENTITY_ID, + "anything", + {"is_on": True, "brightness": 255}, + ) + + assert_state_and_attributes( + hass, + TEST_LIGHT, + STATE_ON, + { + "brightness": 255, + "color_mode": ColorMode.BRIGHTNESS, + "supported_color_modes": [ColorMode.BRIGHTNESS], + }, + ) + + await async_mock_restore_state_shutdown_restart(hass) + + assert len(hass_storage[RESTORE_STATE_KEY]["data"]) == 1 + state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"] + assert state["entity_id"] == TEST_LIGHT.entity_id + + extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] + assert extra_data == { + "is_on": True, + "brightness": 255, + "color_mode": "brightness", + "color_temp_kelvin": None, + "effect_list": None, + "effect": None, + "hs_color": None, + "max_color_temp_kelvin": 6535, + "min_color_temp_kelvin": 2000, + "rgb_color": None, + "rgbw_color": None, + "rgbww_color": None, + "supported_color_modes": ["brightness"], + "xy_color": None, + } diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py index 85bd86cc46b6ec..07443e25104353 100644 --- a/tests/components/template/test_lock.py +++ b/tests/components/template/test_lock.py @@ -24,6 +24,7 @@ from .conftest import ( ConfigurationStyle, TemplatePlatformSetup, + assert_state_and_attributes, async_get_flow_preview_state, async_trigger, make_test_action, @@ -31,6 +32,8 @@ setup_and_test_nested_unique_id, setup_and_test_unique_id, setup_entity, + setup_mock_template_entity_restore_state, + setup_restore_template_entity, ) from tests.common import MockConfigEntry @@ -998,3 +1001,157 @@ async def test_flow_preview( ) assert state["state"] == LockState.LOCKED + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ( + "saved_state", + "saved_extra_data", + "initial_state", + "initial_attributes", + ), + [ + ( + LockState.JAMMED, + { + "code_format": ".+", + "is_locked": False, + "is_locking": False, + "is_open": False, + "is_opening": False, + "is_unlocking": False, + "is_jammed": True, + }, + LockState.JAMMED, + { + "code_format": ".+", + }, + ), + ( + LockState.LOCKED, + { + "code_format": ".+", + "is_locked": True, + "is_locking": False, + "is_open": False, + "is_opening": False, + "is_unlocking": False, + "is_jammed": False, + }, + LockState.LOCKED, + { + "code_format": ".+", + }, + ), + ( + LockState.LOCKING, + { + "code_format": ".+", + "is_locked": False, + "is_locking": True, + "is_open": False, + "is_opening": False, + "is_unlocking": False, + "is_jammed": False, + }, + LockState.LOCKING, + { + "code_format": ".+", + }, + ), + ( + LockState.JAMMED, + { + "code_format": ".+", + "is_locked": False, + "is_locking": False, + "is_opening": False, + "is_unlocking": False, + "is_jammed": True, + }, + STATE_UNKNOWN, + { + "code_format": None, + }, + ), + ( + STATE_UNAVAILABLE, + { + "code_format": ".+", + "is_locked": False, + "is_locking": False, + "is_open": False, + "is_opening": False, + "is_unlocking": False, + "is_jammed": True, + }, + STATE_UNKNOWN, + { + "code_format": None, + }, + ), + ( + STATE_UNKNOWN, + { + "code_format": ".+", + "is_locked": False, + "is_locking": False, + "is_open": False, + "is_opening": False, + "is_unlocking": False, + "is_jammed": True, + }, + STATE_UNKNOWN, + { + "code_format": None, + }, + ), + ], +) +async def test_restore_state( + hass: HomeAssistant, + style: ConfigurationStyle, + saved_state: LockState | str, + saved_extra_data: dict | None, + initial_state: LockState | str, + initial_attributes: ConfigType, +) -> None: + """Test restoring state.""" + + setup_mock_template_entity_restore_state( + hass, + TEST_LOCK, + saved_state, + saved_extra_data=saved_extra_data, + ) + + await setup_restore_template_entity( + hass, + TEST_LOCK, + style, + { + "code_format": "{{ state_attr('sensor.test_state', 'code_format') }}", + "state": "{{ state_attr('sensor.test_state', 'lock_state') }}", + "lock": [], + "open": [], + "unlock": [], + }, + "is_state_attr('sensor.test_state', 'lock_state', 'unlocked')", + ) + + assert_state_and_attributes(hass, TEST_LOCK, initial_state, initial_attributes) + + await async_trigger( + hass, + "sensor.test_state", + "anything", + {"lock_state": LockState.UNLOCKED, "code_format": "\\\\d+"}, + ) + + # The first trigger should replace the restored code_format attribute + assert_state_and_attributes( + hass, TEST_LOCK, LockState.UNLOCKED, {"code_format": "\\\\d+"} + ) diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py index 028d77818c2f41..4fa8229096d9f5 100644 --- a/tests/components/template/test_select.py +++ b/tests/components/template/test_select.py @@ -26,11 +26,13 @@ ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.typing import ConfigType from .conftest import ( ConfigurationStyle, TemplatePlatformSetup, assert_action, + assert_state_and_attributes, async_get_flow_preview_state, async_trigger, make_test_action, @@ -38,6 +40,8 @@ setup_and_test_nested_unique_id, setup_and_test_unique_id, setup_entity, + setup_mock_template_entity_restore_state, + setup_restore_template_entity, ) from tests.common import MockConfigEntry, assert_setup_component @@ -213,6 +217,9 @@ async def test_template_select(hass: HomeAssistant, calls: list[ServiceCall]) -> await async_trigger(hass, TEST_STATE_ENTITY_ID, "c", attributes) _verify(hass, "c", ["a", "b", "c"]) + await async_trigger(hass, TEST_STATE_ENTITY_ID, "None", attributes) + _verify(hass, STATE_UNKNOWN, ["a", "b", "c"]) + def _verify( hass: HomeAssistant, @@ -556,3 +563,125 @@ async def test_nested_unique_id( TEST_OPTIONS_WITHOUT_STATE, "{{ 'test' }}", ) + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ( + "saved_state", + "saved_extra_data", + "initial_state", + "initial_attributes", + ), + [ + ( + "something", + { + "current_option": "something", + "options": ["something", "anything"], + }, + "something", + { + "options": ["something", "anything"], + }, + ), + ( + "something", + { + "current_option": "something", + }, + STATE_UNKNOWN, + { + "options": [], + }, + ), + ( + "something", + { + "options": ["something", "anything"], + }, + STATE_UNKNOWN, + { + "options": [], + }, + ), + ( + STATE_UNAVAILABLE, + { + "current_option": "something", + "options": ["something", "anything"], + }, + STATE_UNKNOWN, + { + "options": [], + }, + ), + ( + STATE_UNKNOWN, + { + "current_option": "something", + "options": ["something", "anything"], + }, + STATE_UNKNOWN, + { + "options": [], + }, + ), + ], +) +async def test_restore_state( + hass: HomeAssistant, + style: ConfigurationStyle, + saved_state: str, + saved_extra_data: dict | None, + initial_state: str, + initial_attributes: ConfigType, +) -> None: + """Test restoring state.""" + + setup_mock_template_entity_restore_state( + hass, + TEST_SELECT, + saved_state, + saved_extra_data=saved_extra_data, + ) + + await setup_restore_template_entity( + hass, + TEST_SELECT, + style, + { + "state": "{{ state_attr('sensor.test_state', 'option') }}", + "options": "{{ state_attr('sensor.test_state', 'options') or [] }}", + "select_option": [], + }, + "is_state('sensor.test_state', 'something_new')", + ) + + assert_state_and_attributes( + hass, + TEST_SELECT, + initial_state, + initial_attributes, + ) + + await async_trigger( + hass, + "sensor.test_state", + "anything", + { + "options": ["something", "anything", "something_new"], + "option": "something_new", + }, + ) + + assert_state_and_attributes( + hass, + TEST_SELECT, + "something_new", + { + "options": ["something", "anything", "something_new"], + }, + ) diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index fa2a4799c2e6a3..1dc35e33ecd04d 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -25,12 +25,14 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.helpers.typing import ConfigType from .conftest import ( ConfigurationStyle, TemplatePlatformSetup, assert_action, + assert_state_and_attributes, async_get_flow_preview_state, async_trigger, make_test_action, @@ -38,9 +40,11 @@ setup_and_test_nested_unique_id, setup_and_test_unique_id, setup_entity, + setup_mock_template_entity_restore_state, + setup_restore_template_entity, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_mock_restore_state_shutdown_restart from tests.components.vacuum import common from tests.typing import WebSocketGenerator @@ -1283,3 +1287,187 @@ async def test_flow_preview( ) assert state["state"] == VacuumActivity.CLEANING + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ( + "saved_state", + "saved_extra_data", + "initial_state", + "initial_attributes", + ), + [ + ( + "some_value", + { + "activity": VacuumActivity.DOCKED, + "fan_speed": "high", + }, + VacuumActivity.DOCKED, + { + "fan_speed": "high", + }, + ), + ( + "some_value", + { + "activity": "do", + }, + STATE_UNKNOWN, + { + "fan_speed": None, + }, + ), + ( + "some_value", + { + "activity": VacuumActivity.DOCKED, + }, + STATE_UNKNOWN, + { + "fan_speed": None, + }, + ), + ( + "some_value", + { + "fan_speed": "high", + }, + STATE_UNKNOWN, + { + "fan_speed": None, + }, + ), + ( + STATE_UNAVAILABLE, + { + "activity": VacuumActivity.DOCKED, + "fan_speed": "high", + }, + STATE_UNKNOWN, + { + "fan_speed": None, + }, + ), + ( + STATE_UNKNOWN, + { + "activity": VacuumActivity.DOCKED, + "fan_speed": "high", + }, + STATE_UNKNOWN, + { + "fan_speed": None, + }, + ), + ], +) +async def test_restore_state( + hass: HomeAssistant, + style: ConfigurationStyle, + saved_state: str, + saved_extra_data: dict | None, + initial_state: str, + initial_attributes: ConfigType, +) -> None: + """Test restoring trigger template vacuum.""" + + setup_mock_template_entity_restore_state( + hass, + TEST_VACUUM, + saved_state, + saved_extra_data=saved_extra_data, + ) + + await setup_restore_template_entity( + hass, + TEST_VACUUM, + style, + { + "state": "{{ state_attr('sensor.test_state', 'activity') }}", + "start": [], + "fan_speed": "{{ state_attr('sensor.test_state', 'fan_speed') }}", + "fan_speeds": ["low", "high"], + "set_fan_speed": [], + }, + "state_attr('sensor.test_state', 'activity') == 'cleaning'", + ) + + assert_state_and_attributes( + hass, + TEST_VACUUM, + initial_state, + initial_attributes, + ) + + await async_trigger( + hass, + "sensor.test_state", + "anything", + {"activity": VacuumActivity.CLEANING, "fan_speed": "low"}, + ) + + assert_state_and_attributes( + hass, + TEST_VACUUM, + VacuumActivity.CLEANING, + { + "fan_speed": "low", + }, + ) + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +async def test_saving_state( + hass: HomeAssistant, + style: ConfigurationStyle, + hass_storage: dict[str, Any], +) -> None: + """Test restore saved state.""" + + await setup_entity( + hass, + TEST_VACUUM, + style, + 1, + config={ + "state": "{{ state_attr('sensor.test_state', 'activity') }}", + "start": [], + "fan_speed": "{{ state_attr('sensor.test_state', 'fan_speed') }}", + "fan_speeds": ["low", "high"], + "set_fan_speed": [], + }, + ) + + await async_trigger( + hass, + TEST_STATE_ENTITY_ID, + "anything", + {"activity": VacuumActivity.DOCKED, "fan_speed": "high"}, + ) + + assert_state_and_attributes( + hass, + TEST_VACUUM, + VacuumActivity.DOCKED, + { + "fan_speed": "high", + }, + ) + + await async_mock_restore_state_shutdown_restart(hass) + + assert len(hass_storage[RESTORE_STATE_KEY]["data"]) == 1 + state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"] + assert state["entity_id"] == TEST_VACUUM.entity_id + + extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] + assert extra_data == { + "activity": "docked", + "fan_speed": "high", + } diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index 07250e00180a95..50b7665121f9e3 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -297,7 +297,7 @@ async def test_create_task_service_call_raises( ) -> None: """Test adding an item to an invalid project raises an error.""" - with pytest.raises(ServiceValidationError, match="project_invalid"): + with pytest.raises(ServiceValidationError) as exc: await hass.services.async_call( DOMAIN, SERVICE_NEW_TASK, @@ -309,6 +309,7 @@ async def test_create_task_service_call_raises( }, blocking=True, ) + assert exc.value.translation_key == "project_invalid" async def test_create_task_service_call_with_section( diff --git a/tests/components/todoist/test_init.py b/tests/components/todoist/test_init.py index 453276474b36b6..1404c58a147914 100644 --- a/tests/components/todoist/test_init.py +++ b/tests/components/todoist/test_init.py @@ -5,10 +5,19 @@ import pytest -from homeassistant.components.todoist.const import DOMAIN +from homeassistant.components.todoist.const import ( + ASSIGNEE, + CONTENT, + DOMAIN, + LABELS, + PROJECT_NAME, + SERVICE_NEW_TASK, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from .conftest import PROJECT_ID + from tests.common import MockConfigEntry @@ -27,6 +36,24 @@ async def test_load_unload( assert todoist_config_entry.state is ConfigEntryState.NOT_LOADED +@pytest.mark.usefixtures("setup_integration") +async def test_new_task_service_uses_config_entry( + hass: HomeAssistant, + api: AsyncMock, +) -> None: + """Test the new_task service reaches the config entry coordinator.""" + await hass.services.async_call( + DOMAIN, + SERVICE_NEW_TASK, + {ASSIGNEE: "user", CONTENT: "task", LABELS: ["Label1"], PROJECT_NAME: "Name"}, + blocking=True, + ) + + api.add_task.assert_called_with( + "task", project_id=PROJECT_ID, labels=["Label1"], assignee_id="1" + ) + + @pytest.mark.parametrize("todoist_api_status", [HTTPStatus.INTERNAL_SERVER_ERROR]) async def test_init_failure( hass: HomeAssistant, diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 8c9c9dab1d7a60..34df3f31b91622 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -2007,7 +2007,7 @@ async def stream_message(): async def test_tts_cache() -> None: """Test TTSCache.""" - async def data_gen(queue: asyncio.Queue[bytes | None | Exception]): + async def data_gen(queue: asyncio.Queue[bytes | Exception | None]): while chunk := await queue.get(): if isinstance(chunk, Exception): raise chunk diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index 986818c6cad22c..f31c71aee05983 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -254,11 +254,18 @@ def subscribe_devices_websocket_state( ufp.devices_ws_state_subscription = ws_state_subscription return Mock() + def subscribe_events_websocket_state( + ws_state_subscription: Callable[[WebsocketState], None], + ) -> Any: + ufp.events_ws_state_subscription = ws_state_subscription + return Mock() + ufp_client.subscribe_websocket = subscribe ufp_client.subscribe_websocket_state = subscribe_websocket_state ufp_client.subscribe_devices_websocket = subscribe_devices_websocket ufp_client.subscribe_events = subscribe_events ufp_client.subscribe_devices_websocket_state = subscribe_devices_websocket_state + ufp_client.subscribe_events_websocket_state = subscribe_events_websocket_state async def update_public() -> Any: # Mirror the library prime: build each camera's public model from the diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 42fd0fec0c58aa..b2b773a3daa7d4 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -1,5 +1,6 @@ """Test the UniFi Protect binary_sensor platform.""" +from collections.abc import Callable from datetime import datetime, timedelta from unittest.mock import Mock @@ -13,7 +14,7 @@ ModelType, MountType, Sensor, - SmartDetectObjectType, + SmartDetectAudioType, ) from uiprotect.data.public_devices import SensorFeatureCapability from uiprotect.websocket import WebsocketState @@ -27,11 +28,8 @@ SENSE_SENSORS, ProtectBinaryEntityDescription, ) -from homeassistant.components.unifiprotect.const import ( - ATTR_EVENT_SCORE, - DEFAULT_ATTRIBUTION, - DOMAIN, -) +from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION, DOMAIN +from homeassistant.components.unifiprotect.number import CAMERA_NUMBERS from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, @@ -41,7 +39,7 @@ STATE_UNAVAILABLE, Platform, ) -from homeassistant.core import Event as HAEvent, EventStateChangedData, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from .utils import ( @@ -50,10 +48,12 @@ assert_entity_counts, ids_from_device_description, init_entry, + make_public_camera, make_public_light, make_public_sensor, public_device_ws_message, remove_entities, + setup_public_camera, setup_public_light, setup_public_sensor, ) @@ -76,11 +76,11 @@ async def test_binary_sensor_camera_remove( ufp.api.bootstrap.nvr.system_info.ustorage = None await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 6) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 7, 6) await remove_entities(hass, ufp, [doorbell, unadopted_camera]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 0, 0) await adopt_devices(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 6) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 7, 6) async def test_binary_sensor_light_remove( @@ -148,8 +148,9 @@ async def test_binary_sensor_setup_camera_all( """Test binary_sensor entity setup for camera devices (all features).""" ufp.api.bootstrap.nvr.system_info.ustorage = None + setup_public_camera(ufp) await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 6) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 7, 6) description = EVENT_SENSORS[0] unique_id, entity_id = await ids_from_device_description( @@ -180,8 +181,8 @@ async def test_binary_sensor_setup_camera_all( assert state.state == STATE_OFF assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - # Motion - description = EVENT_SENSORS[1] + # Motion (migrated to the public path, available via setup_public_camera) + description = next(d for d in CAMERA_SENSORS if d.key == "motion") unique_id, entity_id = await ids_from_device_description( hass, Platform.BINARY_SENSOR, doorbell, description ) @@ -685,49 +686,73 @@ async def test_binary_sensor_update_motion( ufp: MockUFPFixture, doorbell: Camera, unadopted_camera: Camera, - fixed_now: datetime, ) -> None: - """Test binary_sensor motion entity.""" + """Test the migrated motion binary sensor reads sustained state from the public API.""" + setup_public_camera(ufp) await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.BINARY_SENSOR, 14, 12) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 13, 12) + motion = next(d for d in CAMERA_SENSORS if d.key == "motion") _, entity_id = await ids_from_device_description( - hass, Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[1] + hass, Platform.BINARY_SENSOR, doorbell, motion ) - event = Event( - model=ModelType.EVENT, - id="test_event_id", - type=EventType.MOTION, - start=fixed_now - timedelta(seconds=1), - end=None, - score=100, - smart_detect_types=[], - smart_detect_event_ids=[], - camera_id=doorbell.id, - api=ufp.api, + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + ufp.devices_ws_subscription( + public_device_ws_message(make_public_camera(doorbell, is_motion_detected=True)) ) + await hass.async_block_till_done() - new_camera = doorbell.model_copy() - new_camera.is_motion_detected = True - new_camera.last_motion_event_id = event.id + assert hass.states.get(entity_id).state == STATE_ON - ufp.api.bootstrap.cameras = {new_camera.id: new_camera} - ufp.api.bootstrap.events = {event.id: event} + # Detection ends -> sustained state clears. + ufp.devices_ws_subscription(public_device_ws_message(make_public_camera(doorbell))) + await hass.async_block_till_done() - mock_msg = Mock() - mock_msg.changed_data = {} - mock_msg.new_obj = event - ufp.ws_msg(mock_msg) + assert hass.states.get(entity_id).state == STATE_OFF + + +async def test_binary_sensor_detection_unavailable_on_events_ws_disconnect( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, +) -> None: + """Detection sensors follow the events websocket their values derive from. + + A migrated value fed by the devices websocket (microphone level) must not + be affected by an events websocket outage. + """ + setup_public_camera(ufp) + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + motion = next(d for d in CAMERA_SENSORS if d.key == "motion") + _, motion_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, doorbell, motion + ) + mic_level = next(d for d in CAMERA_NUMBERS if d.key == "mic_level") + _, mic_id = await ids_from_device_description( + hass, Platform.NUMBER, doorbell, mic_level + ) + assert hass.states.get(motion_id).state == STATE_OFF + assert hass.states.get(mic_id).state != STATE_UNAVAILABLE + + assert ufp.events_ws_state_subscription is not None + ufp.events_ws_state_subscription(WebsocketState.DISCONNECTED) await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - assert state.attributes[ATTR_EVENT_SCORE] == 100 + assert hass.states.get(motion_id).state == STATE_UNAVAILABLE + assert hass.states.get(mic_id).state != STATE_UNAVAILABLE + + ufp.events_ws_state_subscription(WebsocketState.CONNECTED) + await hass.async_block_till_done() + + assert hass.states.get(motion_id).state == STATE_OFF async def test_binary_sensor_update_light_motion( @@ -837,144 +862,121 @@ async def test_binary_sensor_person_detected( ufp: MockUFPFixture, doorbell: Camera, unadopted_camera: Camera, - fixed_now: datetime, ) -> None: - """Test binary_sensor person detected detection entity.""" + """Test the migrated person-detection binary sensor over the public API.""" + setup_public_camera(ufp) await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.BINARY_SENSOR, 14, 14) - - doorbell.smart_detect_settings.object_types.append(SmartDetectObjectType.PERSON) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 13, 13) + person = next(d for d in CAMERA_SENSORS if d.key == "smart_obj_person") _, entity_id = await ids_from_device_description( - hass, Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[3] + hass, Platform.BINARY_SENSOR, doorbell, person ) - events = async_capture_events(hass, EVENT_STATE_CHANGED) + assert hass.states.get(entity_id).state == STATE_OFF - event = Event( - model=ModelType.EVENT, - id="test_event_id", - type=EventType.SMART_DETECT, - start=fixed_now - timedelta(seconds=1), - end=None, - score=50, - smart_detect_types=[], - smart_detect_event_ids=[], - camera_id=doorbell.id, - api=ufp.api, + # Person detection starts (camera update pushed on the public devices WS). + ufp.devices_ws_subscription( + public_device_ws_message( + make_public_camera( + doorbell, + is_smart_currently_detected=True, + is_person_currently_detected=True, + ) + ) ) + await hass.async_block_till_done() - new_camera = doorbell.model_copy() - new_camera.is_smart_detected = True + assert hass.states.get(entity_id).state == STATE_ON - ufp.api.bootstrap.cameras = {new_camera.id: new_camera} - ufp.api.bootstrap.events = {event.id: event} + # Detection ends -> sustained state clears. + ufp.devices_ws_subscription(public_device_ws_message(make_public_camera(doorbell))) + await hass.async_block_till_done() - mock_msg = Mock() - mock_msg.changed_data = {} - mock_msg.new_obj = event - ufp.ws_msg(mock_msg) + assert hass.states.get(entity_id).state == STATE_OFF - await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state - assert state.state == STATE_OFF +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("key", "make_disabled"), + [ + ("smart_obj_person", lambda c: make_public_camera(c, object_types=[])), + ("smart_audio_smoke", lambda c: make_public_camera(c, audio_types=[])), + ], +) +async def test_binary_sensor_detection_disabled_unavailable( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, + key: str, + make_disabled: Callable[[Camera], Mock], +) -> None: + """A migrated detection binary is unavailable when its type is disabled in Protect.""" - event = Event( - model=ModelType.EVENT, - id="test_event_id", - type=EventType.SMART_DETECT, - start=fixed_now - timedelta(seconds=1), - end=fixed_now + timedelta(seconds=1), - score=65, - smart_detect_types=[SmartDetectObjectType.PERSON], - smart_detect_event_ids=[], - camera_id=doorbell.id, - api=ufp.api, + # Ensure the audio-alarm capability so the smoke binary is created. + doorbell.feature_flags.smart_detect_audio_types = [SmartDetectAudioType.SMOKE] + setup_public_camera(ufp) + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + + description = next(d for d in CAMERA_SENSORS if d.key == key) + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, doorbell, description ) + assert hass.states.get(entity_id).state == STATE_OFF - new_camera = doorbell.model_copy() - new_camera.is_smart_detected = True - new_camera.last_smart_detect_event_ids[SmartDetectObjectType.PERSON] = event.id + # The detection type is turned off in Protect -> the enabled gate fails. + ufp.devices_ws_subscription(public_device_ws_message(make_disabled(doorbell))) + await hass.async_block_till_done() - ufp.api.bootstrap.cameras = {new_camera.id: new_camera} - ufp.api.bootstrap.events = {event.id: event} + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE - mock_msg = Mock() - mock_msg.changed_data = {} - mock_msg.new_obj = event - ufp.ws_msg(mock_msg) - await hass.async_block_till_done() +async def test_binary_sensor_doorbell_ring( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, + fixed_now: datetime, +) -> None: + """The doorbell occupancy binary stays on the private ring event path.""" - entity_events = [event for event in events if event.data["entity_id"] == entity_id] - assert len(entity_events) == 3 - assert entity_events[0].data["new_state"].state == STATE_OFF - assert entity_events[1].data["new_state"].state == STATE_ON - assert entity_events[2].data["new_state"].state == STATE_OFF + await init_entry(hass, ufp, [doorbell, unadopted_camera]) - # Event is already seen and has end, should now be off - state = hass.states.get(entity_id) - assert state - assert state.state == STATE_OFF + description = next(d for d in EVENT_SENSORS if d.key == "doorbell") + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, doorbell, description + ) + assert hass.states.get(entity_id).state == STATE_OFF - # Now send an event that has an end right away + state_changes = async_capture_events(hass, EVENT_STATE_CHANGED) event = Event( model=ModelType.EVENT, - id="new_event_id", - type=EventType.SMART_DETECT, + id="ring-1", + type=EventType.RING, start=fixed_now - timedelta(seconds=1), - end=fixed_now + timedelta(seconds=1), - score=80, - smart_detect_types=[SmartDetectObjectType.PERSON], + end=fixed_now, + smart_detect_types=[], smart_detect_event_ids=[], camera_id=doorbell.id, api=ufp.api, ) - new_camera = doorbell.model_copy() - new_camera.is_smart_detected = True - new_camera.last_smart_detect_event_ids[SmartDetectObjectType.PERSON] = event.id - + new_camera.last_ring_event_id = event.id ufp.api.bootstrap.cameras = {new_camera.id: new_camera} ufp.api.bootstrap.events = {event.id: event} mock_msg = Mock() mock_msg.changed_data = {} mock_msg.new_obj = event - - state_changes: list[HAEvent[EventStateChangedData]] = async_capture_events( - hass, EVENT_STATE_CHANGED - ) ufp.ws_msg(mock_msg) - await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state - assert state.state == STATE_OFF - - assert len(state_changes) == 2 - - on_event = state_changes[0] - state = on_event.data["new_state"] - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - assert state.attributes[ATTR_EVENT_SCORE] == 80 - - off_event = state_changes[1] - state = off_event.data["new_state"] - assert state - assert state.state == STATE_OFF - assert ATTR_EVENT_SCORE not in state.attributes - - # replay and ensure ignored - ufp.ws_msg(mock_msg) - await hass.async_block_till_done() - assert len(state_changes) == 2 + # A momentary ring blips on, then immediately clears. + ring_changes = [e for e in state_changes if e.data["entity_id"] == entity_id] + assert any(c.data["new_state"].state == STATE_ON for c in ring_changes) + assert hass.states.get(entity_id).state == STATE_OFF async def test_aiport_no_binary_sensor_entities( @@ -1003,171 +1005,62 @@ async def test_binary_sensor_simultaneous_person_and_vehicle_detection( ufp: MockUFPFixture, doorbell: Camera, unadopted_camera: Camera, - fixed_now: datetime, ) -> None: - """Test that when an event is updated with additional detection types, both trigger. + """Person and vehicle detected at once both report ON. - This is a regression test for https://github.com/home-assistant/core/issues/152133 - where an event starting with vehicle detection gets updated to also include person - detection (e.g., someone getting out of a car). Both sensors should be ON - simultaneously, not queued. + Regression for https://github.com/home-assistant/core/issues/152133 (a second + type added to an ongoing detection): on the public path each type's sustained + state is derived independently by the library, so adding person to an ongoing + vehicle detection turns both ON without queueing. """ + setup_public_camera(ufp) await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.BINARY_SENSOR, 14, 14) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 13, 13) - doorbell.smart_detect_settings.object_types.append(SmartDetectObjectType.PERSON) - doorbell.smart_detect_settings.object_types.append(SmartDetectObjectType.VEHICLE) - - # Get entity IDs for both person and vehicle detection + person = next(d for d in CAMERA_SENSORS if d.key == "smart_obj_person") + vehicle = next(d for d in CAMERA_SENSORS if d.key == "smart_obj_vehicle") _, person_entity_id = await ids_from_device_description( - hass, - Platform.BINARY_SENSOR, - doorbell, - EVENT_SENSORS[3], # person detected + hass, Platform.BINARY_SENSOR, doorbell, person ) _, vehicle_entity_id = await ids_from_device_description( - hass, - Platform.BINARY_SENSOR, - doorbell, - EVENT_SENSORS[4], # vehicle detected + hass, Platform.BINARY_SENSOR, doorbell, vehicle ) - # Step 1: Initial event with only VEHICLE detection (car arriving) - event = Event( - model=ModelType.EVENT, - id="combined_event_id", - type=EventType.SMART_DETECT, - start=fixed_now - timedelta(seconds=5), - end=None, # Event is ongoing - score=90, - smart_detect_types=[SmartDetectObjectType.VEHICLE], - smart_detect_event_ids=[], - camera_id=doorbell.id, - api=ufp.api, + # Vehicle arrives. + ufp.devices_ws_subscription( + public_device_ws_message( + make_public_camera( + doorbell, + is_smart_currently_detected=True, + is_vehicle_currently_detected=True, + ) + ) ) - - new_camera = doorbell.model_copy() - new_camera.is_smart_detected = True - new_camera.last_smart_detect_event_ids[SmartDetectObjectType.VEHICLE] = event.id - - ufp.api.bootstrap.cameras = {new_camera.id: new_camera} - ufp.api.bootstrap.events = {event.id: event} - - mock_msg = Mock() - mock_msg.changed_data = {} - mock_msg.new_obj = event - ufp.ws_msg(mock_msg) - await hass.async_block_till_done() - # Vehicle sensor should be ON - vehicle_state = hass.states.get(vehicle_entity_id) - assert vehicle_state - assert vehicle_state.state == STATE_ON, "Vehicle detection should be ON" - - # Person sensor should still be OFF (no person detected yet) - person_state = hass.states.get(person_entity_id) - assert person_state - assert person_state.state == STATE_OFF, "Person detection should be OFF initially" - - # Step 2: Same event gets updated to include PERSON detection - # (someone gets out of the car - Protect adds PERSON to the same event) - # - # BUG SCENARIO: UniFi Protect updates the event to include PERSON in - # smart_detect_types, BUT does NOT update last_smart_detect_event_ids[PERSON] - # until the event ends. This is the core issue reported in #152133. - updated_event = Event( - model=ModelType.EVENT, - id="combined_event_id", # Same event ID! - type=EventType.SMART_DETECT, - start=fixed_now - timedelta(seconds=5), - end=None, # Event still ongoing - score=90, - smart_detect_types=[ - SmartDetectObjectType.VEHICLE, - SmartDetectObjectType.PERSON, - ], - smart_detect_event_ids=[], - camera_id=doorbell.id, - api=ufp.api, - ) - - # IMPORTANT: The camera's last_smart_detect_event_ids is NOT updated for PERSON! - # This simulates the real bug where UniFi Protect doesn't immediately update - # the camera's last_smart_detect_event_ids when a new detection type is added - # to an ongoing event. - new_camera = doorbell.model_copy() - new_camera.is_smart_detected = True - # Only VEHICLE has the event ID - PERSON does not (simulating the bug) - new_camera.last_smart_detect_event_ids[SmartDetectObjectType.VEHICLE] = ( - updated_event.id + assert hass.states.get(vehicle_entity_id).state == STATE_ON + assert hass.states.get(person_entity_id).state == STATE_OFF + + # Person joins the same scene -> both ON simultaneously. + ufp.devices_ws_subscription( + public_device_ws_message( + make_public_camera( + doorbell, + is_smart_currently_detected=True, + is_vehicle_currently_detected=True, + is_person_currently_detected=True, + ) + ) ) - # NOTE: We're NOT setting last_smart_detect_event_ids[PERSON] to simulate the bug! - - ufp.api.bootstrap.cameras = {new_camera.id: new_camera} - ufp.api.bootstrap.events = {updated_event.id: updated_event} - - mock_msg = Mock() - mock_msg.changed_data = {} - mock_msg.new_obj = updated_event - ufp.ws_msg(mock_msg) - await hass.async_block_till_done() - # CRITICAL: Both sensors should now be ON simultaneously - vehicle_state = hass.states.get(vehicle_entity_id) - assert vehicle_state - assert vehicle_state.state == STATE_ON, ( - "Vehicle detection should still be ON after event update" - ) - - person_state = hass.states.get(person_entity_id) - assert person_state - assert person_state.state == STATE_ON, ( - "Person detection should be ON immediately when added to event, " - "not waiting for vehicle detection to end" - ) - - # Verify both have correct attributes - assert vehicle_state.attributes[ATTR_EVENT_SCORE] == 90 - assert person_state.attributes[ATTR_EVENT_SCORE] == 90 - - # Step 3: Event ends - both sensors should turn OFF - ended_event = Event( - model=ModelType.EVENT, - id="combined_event_id", - type=EventType.SMART_DETECT, - start=fixed_now - timedelta(seconds=5), - end=fixed_now, # Event ended now - score=90, - smart_detect_types=[ - SmartDetectObjectType.VEHICLE, - SmartDetectObjectType.PERSON, - ], - smart_detect_event_ids=[], - camera_id=doorbell.id, - api=ufp.api, - ) - - ufp.api.bootstrap.events = {ended_event.id: ended_event} - - mock_msg = Mock() - mock_msg.changed_data = {} - mock_msg.new_obj = ended_event - ufp.ws_msg(mock_msg) + assert hass.states.get(vehicle_entity_id).state == STATE_ON + assert hass.states.get(person_entity_id).state == STATE_ON + # Scene clears -> both OFF. + ufp.devices_ws_subscription(public_device_ws_message(make_public_camera(doorbell))) await hass.async_block_till_done() - # Both should be OFF now - vehicle_state = hass.states.get(vehicle_entity_id) - assert vehicle_state - assert vehicle_state.state == STATE_OFF, ( - "Vehicle detection should be OFF after event ends" - ) - - person_state = hass.states.get(person_entity_id) - assert person_state - assert person_state.state == STATE_OFF, ( - "Person detection should be OFF after event ends" - ) + assert hass.states.get(vehicle_entity_id).state == STATE_OFF + assert hass.states.get(person_entity_id).state == STATE_OFF diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index 1ebeacb070972a..47cdd3967968f7 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -5,6 +5,7 @@ from aiohttp.client_exceptions import ServerDisconnectedError import pytest +from uiprotect.api import RTSPSStreams from uiprotect.data import ( AiPort, Camera as ProtectCamera, @@ -285,9 +286,18 @@ async def _prime_without_camera() -> Any: async def test_streams_unavailable( - hass: HomeAssistant, ufp: MockUFPFixture, camera_all: ProtectCamera + hass: HomeAssistant, + ufp: MockUFPFixture, + camera_all: ProtectCamera, + issue_registry: ir.IssueRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: - """A camera the library leaves unprimed (no streams) has no stream source.""" + """A camera whose RTSPS streams could not be read has no stream source. + + An unreadable stream state is not "no streams": it must log a warning + instead of raising the enable-stream repair, which could offer to create a + stream that already exists on the console. + """ async def _prime_streamless() -> Any: pb = ufp.api.public_bootstrap @@ -302,6 +312,10 @@ async def _prime_streamless() -> Any: high_id = _channel_entity_id(camera_all, 0) assert await async_get_stream_source(hass, high_id) is None + assert ( + issue_registry.async_get_issue(DOMAIN, f"rtsp_disabled_{camera_all.id}") is None + ) + assert "Could not read RTSPS streams" in caplog.text async def test_public_bootstrap_failure_not_ready( @@ -825,7 +839,7 @@ async def test_public_only_streamless_camera_gets_repair( for channel in camera.channels: channel._api = ufp.api public = make_public_camera(camera) - public.rtsps_streams = None + public.rtsps_streams = RTSPSStreams() async def _prime_public_only() -> Any: pb = ufp.api.public_bootstrap diff --git a/tests/components/unifiprotect/test_event.py b/tests/components/unifiprotect/test_event.py index d22fcbc6a65227..7f97bd68927ac3 100644 --- a/tests/components/unifiprotect/test_event.py +++ b/tests/components/unifiprotect/test_event.py @@ -2,6 +2,8 @@ import asyncio from datetime import datetime, timedelta +import json +from pathlib import Path from unittest.mock import Mock, patch import pytest @@ -12,18 +14,24 @@ Event, EventType, ModelType, - PublicBootstrap, + SmartDetectAudioType, SmartDetectObjectType, ) +from uiprotect.websocket import WebsocketState from homeassistant.components.unifiprotect.const import ( ATTR_EVENT_ID, + ATTR_SMART_DETECT_TYPES, DEFAULT_ATTRIBUTION, EVENT_TYPE_PACKAGE_DETECTED, ) -from homeassistant.components.unifiprotect.event import EVENT_DESCRIPTIONS -from homeassistant.const import ATTR_ATTRIBUTION, Platform +from homeassistant.components.unifiprotect.event import ( + _MAX_TRACKED_EVENTS, + EVENT_DESCRIPTIONS, +) +from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNAVAILABLE, Platform from homeassistant.core import Event as HAEvent, HomeAssistant, callback +from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.helpers.event import async_track_state_change_event from .utils import ( @@ -33,6 +41,7 @@ ids_from_device_description, init_entry, remove_entities, + setup_public_camera, ) # Short delay for testing @@ -56,11 +65,11 @@ async def test_camera_remove( ufp.api.bootstrap.nvr.system_info.ustorage = None await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.EVENT, 5, 5) + assert_entity_counts(hass, Platform.EVENT, 7, 7) await remove_entities(hass, ufp, [doorbell, unadopted_camera]) assert_entity_counts(hass, Platform.EVENT, 0, 0) await adopt_devices(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.EVENT, 5, 5) + assert_entity_counts(hass, Platform.EVENT, 7, 7) async def test_doorbell_ring( @@ -73,18 +82,12 @@ async def test_doorbell_ring( """Test a doorbell ring event fired from the public events websocket.""" # Ring is delivered over the public events websocket, which is only - # subscribed once update_public() has primed the public bootstrap. - ufp.api.has_public_bootstrap = True - ufp.api.public_bootstrap = Mock( - spec=PublicBootstrap, - relays={}, - sirens={}, - arm_mode=None, - arm_profiles={}, - ) + # subscribed once update_public() has primed the public bootstrap; the + # entity's availability also requires the public camera to resolve. + setup_public_camera(ufp) await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.EVENT, 5, 5) + assert_entity_counts(hass, Platform.EVENT, 7, 7) events: list[HAEvent] = [] @callback @@ -133,7 +136,8 @@ def _capture_event(event: HAEvent) -> None: await hass.async_block_till_done() assert len(events) == 1 - # Only the start of an event is dispatched; an update must be ignored. + # Updates are dispatched too, but the entity fires each event id only + # once, so a repeat dispatch of the same ring event must be suppressed. ufp.events_msg( ProtectEvent( id="test_ring_event", @@ -151,28 +155,31 @@ def _capture_event(event: HAEvent) -> None: unsub() +@pytest.mark.parametrize( + "event_type", + [ + pytest.param(EventType.SMART_DETECT, id="zone"), + pytest.param(EventType.SMART_DETECT_LINE, id="line"), + pytest.param(EventType.SMART_DETECT_LOITER, id="loiter"), + ], +) async def test_package_detected( hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera, unadopted_camera: Camera, fixed_now: datetime, + event_type: EventType, ) -> None: """Test a package detection event fired from the public events websocket.""" # Package detection is delivered over the public events websocket, which is - # only subscribed once update_public() has primed the public bootstrap. - ufp.api.has_public_bootstrap = True - ufp.api.public_bootstrap = Mock( - spec=PublicBootstrap, - relays={}, - sirens={}, - arm_mode=None, - arm_profiles={}, - ) + # only subscribed once update_public() has primed the public bootstrap; the + # entity's availability also requires the public camera to resolve. + setup_public_camera(ufp) await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.EVENT, 5, 5) + assert_entity_counts(hass, Platform.EVENT, 7, 7) events: list[HAEvent] = [] @callback @@ -186,12 +193,13 @@ def _capture_event(event: HAEvent) -> None: unsub = async_track_state_change_event(hass, entity_id, _capture_event) # Package detection arrives on the public events websocket as a - # smartDetectZone detection event with the package object type. Protect - # records it already-ended; the event entity fires on the detection start. + # smartDetectZone, smartDetectLine, or smartDetectLoiterZone detection + # event with the package object type. Protect records it already-ended; + # the event entity fires on the detection start. ufp.events_msg( ProtectEvent( id="test_package_event", - type=EventType.SMART_DETECT, + type=event_type, channel=ProtectEventChannel.DETECTION, device_id=doorbell.id, device_mac=doorbell.mac, @@ -227,7 +235,8 @@ def _capture_event(event: HAEvent) -> None: await hass.async_block_till_done() assert len(events) == 1 - # Only the start of a detection is dispatched; an update must be ignored. + # Updates are dispatched too, but the entity fires each (event id, type) + # once, so a repeat dispatch of the same package event must be suppressed. ufp.events_msg( ProtectEvent( id="test_package_event", @@ -315,7 +324,7 @@ async def test_doorbell_nfc_scanned( """Test a doorbell NFC scanned event.""" await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.EVENT, 5, 5) + assert_entity_counts(hass, Platform.EVENT, 7, 7) events: list[HAEvent] = [] @callback @@ -390,7 +399,7 @@ async def test_doorbell_nfc_scanned_ulpusr_deactivated( """Test a doorbell NFC scanned event.""" await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.EVENT, 5, 5) + assert_entity_counts(hass, Platform.EVENT, 7, 7) events: list[HAEvent] = [] @callback @@ -466,7 +475,7 @@ async def test_doorbell_nfc_scanned_no_ulpusr( """Test a doorbell NFC scanned event.""" await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.EVENT, 5, 5) + assert_entity_counts(hass, Platform.EVENT, 7, 7) events: list[HAEvent] = [] @callback @@ -534,7 +543,7 @@ async def test_doorbell_nfc_scanned_no_keyring( """Test a doorbell NFC scanned event.""" await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.EVENT, 5, 5) + assert_entity_counts(hass, Platform.EVENT, 7, 7) events: list[HAEvent] = [] @callback @@ -595,7 +604,7 @@ async def test_doorbell_fingerprint_identified( """Test a doorbell fingerprint identified event.""" await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.EVENT, 5, 5) + assert_entity_counts(hass, Platform.EVENT, 7, 7) events: list[HAEvent] = [] @callback @@ -663,7 +672,7 @@ async def test_doorbell_fingerprint_identified_user_deactivated( """Test a doorbell fingerprint identified event.""" await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.EVENT, 5, 5) + assert_entity_counts(hass, Platform.EVENT, 7, 7) events: list[HAEvent] = [] @callback @@ -732,7 +741,7 @@ async def test_doorbell_fingerprint_identified_no_user( """Test a doorbell fingerprint identified event.""" await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.EVENT, 5, 5) + assert_entity_counts(hass, Platform.EVENT, 7, 7) events: list[HAEvent] = [] @callback @@ -793,7 +802,7 @@ async def test_doorbell_fingerprint_not_identified( """Test a doorbell fingerprint identified event.""" await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.EVENT, 5, 5) + assert_entity_counts(hass, Platform.EVENT, 7, 7) events: list[HAEvent] = [] @callback @@ -851,7 +860,7 @@ async def test_vehicle_detection_basic( """Test basic vehicle detection event with thumbnails.""" await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.EVENT, 5, 5) + assert_entity_counts(hass, Platform.EVENT, 7, 7) events: list[HAEvent] = [] @callback @@ -925,7 +934,7 @@ async def test_vehicle_detection_with_lpr_ufp6( """Test vehicle detection with license plate recognition (UFP 6.0+ format).""" await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.EVENT, 5, 5) + assert_entity_counts(hass, Platform.EVENT, 7, 7) events: list[HAEvent] = [] @callback @@ -1009,7 +1018,7 @@ async def test_vehicle_detection_with_lpr_legacy( """Test vehicle detection with license plate recognition (legacy format).""" await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.EVENT, 5, 5) + assert_entity_counts(hass, Platform.EVENT, 7, 7) events: list[HAEvent] = [] @callback @@ -1082,7 +1091,7 @@ async def test_vehicle_detection_multiple_thumbnails( """Test vehicle detection with multiple thumbnails - should pick best LPR.""" await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.EVENT, 5, 5) + assert_entity_counts(hass, Platform.EVENT, 7, 7) events: list[HAEvent] = [] @callback @@ -1183,7 +1192,7 @@ async def test_vehicle_detection_no_thumbnails( """Test vehicle detection event without thumbnails - should not fire.""" await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.EVENT, 5, 5) + assert_entity_counts(hass, Platform.EVENT, 7, 7) events: list[HAEvent] = [] @callback @@ -1241,7 +1250,7 @@ async def test_vehicle_detection_timer_reset_on_new_thumbnail( """Test that timer resets when new thumbnails arrive for same event.""" await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.EVENT, 5, 5) + assert_entity_counts(hass, Platform.EVENT, 7, 7) events: list[HAEvent] = [] @callback @@ -1352,7 +1361,7 @@ async def test_vehicle_detection_new_event_cancels_timer( """Test that new event cancels timer for previous event.""" await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.EVENT, 5, 5) + assert_entity_counts(hass, Platform.EVENT, 7, 7) events: list[HAEvent] = [] @callback @@ -1479,7 +1488,7 @@ async def test_vehicle_detection_timer_cleanup_on_remove( """Test that pending timer is cancelled when entity is removed.""" await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.EVENT, 5, 5) + assert_entity_counts(hass, Platform.EVENT, 7, 7) _, entity_id = await ids_from_device_description( hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[3] @@ -1544,7 +1553,7 @@ async def test_vehicle_detection_refire_on_lpr_data( """Test that event refires when LPR data arrives after initial detection.""" await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.EVENT, 5, 5) + assert_entity_counts(hass, Platform.EVENT, 7, 7) events: list[HAEvent] = [] @callback @@ -1655,7 +1664,7 @@ async def test_vehicle_detection_no_refire_same_data( """Test that event does NOT refire when same data arrives again.""" await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.EVENT, 5, 5) + assert_entity_counts(hass, Platform.EVENT, 7, 7) events: list[HAEvent] = [] @callback @@ -1740,3 +1749,520 @@ async def test_aiport_no_event_entities( # AI Port should not create any camera-specific event entities # (doorbell, motion, etc.) assert_entity_counts(hass, Platform.EVENT, 0, 0) + + +async def test_motion_detection_event( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, + fixed_now: datetime, +) -> None: + """The motion event entity fires from the public events websocket.""" + setup_public_camera(ufp) + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + + description = next(d for d in EVENT_DESCRIPTIONS if d.key == "motion_detection") + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, description + ) + + ufp.events_msg( + ProtectEvent( + id="motion-1", + type=EventType.MOTION, + channel=ProtectEventChannel.DETECTION, + device_id=doorbell.id, + device_mac=doorbell.mac, + start=fixed_now - timedelta(seconds=1), + end=fixed_now, + ), + EventChange.STARTED, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.attributes["event_type"] == "motion" + assert state.attributes[ATTR_EVENT_ID] == "motion-1" + + +@pytest.mark.parametrize( + "event_type", + [ + pytest.param(EventType.SMART_DETECT, id="zone"), + pytest.param(EventType.SMART_DETECT_LINE, id="line"), + pytest.param(EventType.SMART_DETECT_LOITER, id="loiter"), + ], +) +async def test_smart_detection_event( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, + fixed_now: datetime, + event_type: EventType, +) -> None: + """The smart-detection event entity fires per object type with the full type set. + + smartDetectZone, smartDetectLine, and smartDetectLoiterZone events all carry + smart detections, so a standalone line-crossing or loitering event must fire + the entity too. + """ + setup_public_camera(ufp) + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + + description = next(d for d in EVENT_DESCRIPTIONS if d.key == "smart_detection") + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, description + ) + + events: list[HAEvent] = [] + + @callback + def _capture(event: HAEvent) -> None: + events.append(event) + + unsub = async_track_state_change_event(hass, entity_id, _capture) + ufp.events_msg( + ProtectEvent( + id="smart-1", + type=event_type, + channel=ProtectEventChannel.DETECTION, + device_id=doorbell.id, + device_mac=doorbell.mac, + start=fixed_now - timedelta(seconds=1), + end=fixed_now, + smart_detect_types=( + SmartDetectObjectType.PERSON, + SmartDetectObjectType.VEHICLE, + ), + ), + EventChange.STARTED, + ) + await hass.async_block_till_done() + unsub() + + # One fire per surfaced type, each carrying the full co-detected set. + fired = [event.data["new_state"].attributes["event_type"] for event in events] + assert fired == ["person", "vehicle"] + last = events[-1].data["new_state"] + assert last.attributes[ATTR_EVENT_ID] == "smart-1" + assert last.attributes[ATTR_SMART_DETECT_TYPES] == ["person", "vehicle"] + + +async def test_sound_detection_event( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, + fixed_now: datetime, +) -> None: + """The sound-detection event entity fires for audio types (slugged event type).""" + doorbell.feature_flags.smart_detect_audio_types = [SmartDetectAudioType.SMOKE] + setup_public_camera(ufp) + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + + description = next(d for d in EVENT_DESCRIPTIONS if d.key == "sound_detection") + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, description + ) + + ufp.events_msg( + ProtectEvent( + id="audio-1", + type=EventType.SMART_AUDIO_DETECT, + channel=ProtectEventChannel.DETECTION, + device_id=doorbell.id, + device_mac=doorbell.mac, + start=fixed_now - timedelta(seconds=1), + end=fixed_now, + smart_detect_types=(SmartDetectObjectType.SMOKE,), + ), + EventChange.STARTED, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.attributes["event_type"] == "smoke" + assert state.attributes[ATTR_EVENT_ID] == "audio-1" + + +async def test_sound_detection_event_late_type( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, + fixed_now: datetime, +) -> None: + """Audio types arrive on a later update, not at start; fire once when they appear.""" + doorbell.feature_flags.smart_detect_audio_types = [SmartDetectAudioType.SMOKE] + setup_public_camera(ufp) + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + + description = next(d for d in EVENT_DESCRIPTIONS if d.key == "sound_detection") + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, description + ) + + events: list[HAEvent] = [] + + @callback + def _capture(event: HAEvent) -> None: + events.append(event) + + unsub = async_track_state_change_event(hass, entity_id, _capture) + base = { + "id": "audio-1", + "type": EventType.SMART_AUDIO_DETECT, + "channel": ProtectEventChannel.DETECTION, + "device_id": doorbell.id, + "device_mac": doorbell.mac, + "start": fixed_now - timedelta(seconds=1), + } + # Start carries no type yet -> nothing fires. + ufp.events_msg( + ProtectEvent(**base, end=None, smart_detect_types=()), EventChange.STARTED + ) + await hass.async_block_till_done() + assert events == [] + + # The type appears on a later update -> fires once. + ufp.events_msg( + ProtectEvent( + **base, end=None, smart_detect_types=(SmartDetectObjectType.SMOKE,) + ), + EventChange.UPDATED, + ) + await hass.async_block_till_done() + + # A further update for the same type is deduped (no re-fire). + ufp.events_msg( + ProtectEvent( + **base, end=fixed_now, smart_detect_types=(SmartDetectObjectType.SMOKE,) + ), + EventChange.UPDATED, + ) + await hass.async_block_till_done() + unsub() + + fired = [event.data["new_state"].attributes["event_type"] for event in events] + assert fired == ["smoke"] + + +async def test_sound_detection_absent_without_audio_types( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + entity_registry: EntityRegistry, +) -> None: + """Object smart-detect without audio support creates no sound-detection entity.""" + doorbell.feature_flags.smart_detect_audio_types = [] + await init_entry(hass, ufp, [doorbell]) + + smart = next(d for d in EVENT_DESCRIPTIONS if d.key == "smart_detection") + sound = next(d for d in EVENT_DESCRIPTIONS if d.key == "sound_detection") + _, smart_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, smart + ) + _, sound_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, sound + ) + + # object detection stays, audio (sound) detection is gated out + assert entity_registry.async_get(smart_id) is not None + assert entity_registry.async_get(sound_id) is None + + +async def test_detection_event_removed_change_ignored( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, + fixed_now: datetime, +) -> None: + """A REMOVED (eviction) change does not fire a detection event.""" + setup_public_camera(ufp) + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + + description = next(d for d in EVENT_DESCRIPTIONS if d.key == "smart_detection") + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, description + ) + + events: list[HAEvent] = [] + + @callback + def _capture(event: HAEvent) -> None: + events.append(event) + + unsub = async_track_state_change_event(hass, entity_id, _capture) + ufp.events_msg( + ProtectEvent( + id="ev-removed", + type=EventType.SMART_DETECT, + channel=ProtectEventChannel.DETECTION, + device_id=doorbell.id, + device_mac=doorbell.mac, + start=fixed_now - timedelta(seconds=1), + end=fixed_now, + smart_detect_types=(SmartDetectObjectType.PERSON,), + ), + EventChange.REMOVED, + ) + await hass.async_block_till_done() + unsub() + + assert events == [] + + +async def test_doorbell_ring_dedup_across_dispatches( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, + fixed_now: datetime, +) -> None: + """A ring fires once even though start, update and end are all dispatched.""" + setup_public_camera(ufp) + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[0] + ) + + events: list[HAEvent] = [] + + @callback + def _capture(event: HAEvent) -> None: + events.append(event) + + unsub = async_track_state_change_event(hass, entity_id, _capture) + base = { + "id": "ring-1", + "type": EventType.RING, + "channel": ProtectEventChannel.DETECTION, + "device_id": doorbell.id, + "device_mac": doorbell.mac, + "start": fixed_now - timedelta(seconds=1), + } + for change, end in ( + (EventChange.STARTED, None), + (EventChange.UPDATED, None), + (EventChange.ENDED, fixed_now), + ): + ufp.events_msg(ProtectEvent(**base, end=end), change) + await hass.async_block_till_done() + unsub() + + assert len(events) == 1 + + +def test_detection_event_types_have_translations() -> None: + """Every category detection event type has a strings.json state label. + + Guards against a uiprotect enum addition slugging into ``event_types`` (and + firing) without a matching translation label. + """ + strings = json.loads( + ( + Path(__file__).parents[3] + / "homeassistant/components/unifiprotect/strings.json" + ).read_text() + ) + event_states = strings["entity"]["event"] + for key in ("motion_detection", "smart_detection", "sound_detection"): + description = next(d for d in EVENT_DESCRIPTIONS if d.key == key) + labels = event_states[key]["state_attributes"]["event_type"]["state"] + missing = [t for t in description.event_types or () if t not in labels] + assert not missing, f"{key} missing event_type labels: {missing}" + + +async def test_smart_detection_event_interleaved_dedup( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, + fixed_now: datetime, +) -> None: + """Two overlapping same-category events whose dispatches interleave don't re-fire.""" + setup_public_camera(ufp) + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + + description = next(d for d in EVENT_DESCRIPTIONS if d.key == "smart_detection") + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, description + ) + + events: list[HAEvent] = [] + + @callback + def _capture(event: HAEvent) -> None: + events.append(event) + + unsub = async_track_state_change_event(hass, entity_id, _capture) + common = { + "type": EventType.SMART_DETECT, + "channel": ProtectEventChannel.DETECTION, + "device_id": doorbell.id, + "device_mac": doorbell.mac, + "start": fixed_now - timedelta(seconds=1), + "end": None, + } + # Event A and B overlap; A re-dispatches after B started. + ufp.events_msg( + ProtectEvent( + id="evt-a", smart_detect_types=(SmartDetectObjectType.PERSON,), **common + ), + EventChange.STARTED, + ) + ufp.events_msg( + ProtectEvent( + id="evt-b", smart_detect_types=(SmartDetectObjectType.VEHICLE,), **common + ), + EventChange.STARTED, + ) + ufp.events_msg( + ProtectEvent( + id="evt-a", smart_detect_types=(SmartDetectObjectType.PERSON,), **common + ), + EventChange.UPDATED, + ) + await hass.async_block_till_done() + unsub() + + # A's person is not re-fired when its update arrives after B's dispatch. + fired = [event.data["new_state"].attributes["event_type"] for event in events] + assert fired == ["person", "vehicle"] + + +async def test_detection_event_dedup_is_bounded( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, + fixed_now: datetime, +) -> None: + """The fire-dedup tracker is bounded; distinct events keep firing.""" + setup_public_camera(ufp) + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + + description = next(d for d in EVENT_DESCRIPTIONS if d.key == "motion_detection") + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, description + ) + + events: list[HAEvent] = [] + + @callback + def _capture(event: HAEvent) -> None: + events.append(event) + + unsub = async_track_state_change_event(hass, entity_id, _capture) + for index in range(20): + ufp.events_msg( + ProtectEvent( + id=f"motion-{index}", + type=EventType.MOTION, + channel=ProtectEventChannel.DETECTION, + device_id=doorbell.id, + device_mac=doorbell.mac, + start=fixed_now - timedelta(seconds=1), + end=fixed_now, + ), + EventChange.STARTED, + ) + await hass.async_block_till_done() + unsub() + + assert len(events) == 20 + + +async def test_detection_event_dedup_evicts_oldest( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, + fixed_now: datetime, +) -> None: + """Exceeding the dedup cap evicts the oldest event id, letting it refire. + + An unbounded tracker would also pass ``test_detection_event_dedup_is_bounded`` + (it only sends distinct ids), so this replays an id that should have aged out. + """ + setup_public_camera(ufp) + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + + description = next(d for d in EVENT_DESCRIPTIONS if d.key == "motion_detection") + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, description + ) + + events: list[HAEvent] = [] + + @callback + def _capture(event: HAEvent) -> None: + events.append(event) + + unsub = async_track_state_change_event(hass, entity_id, _capture) + + def _send(index: int) -> None: + ufp.events_msg( + ProtectEvent( + id=f"motion-{index}", + type=EventType.MOTION, + channel=ProtectEventChannel.DETECTION, + device_id=doorbell.id, + device_mac=doorbell.mac, + start=fixed_now - timedelta(seconds=1), + end=fixed_now, + ), + EventChange.STARTED, + ) + + # A (cap + 1)th distinct id evicts the oldest tracked id ("motion-0"). + for index in range(_MAX_TRACKED_EVENTS + 1): + _send(index) + await hass.async_block_till_done() + assert len(events) == _MAX_TRACKED_EVENTS + 1 + + # Replaying the evicted id fires again; a bug that never evicts would dedup it. + _send(0) + await hass.async_block_till_done() + unsub() + + assert len(events) == _MAX_TRACKED_EVENTS + 2 + + +async def test_event_entities_unavailable_on_events_ws_disconnect( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, +) -> None: + """Public event entities follow the events websocket they fire from.""" + setup_public_camera(ufp) + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + + ring = next(d for d in EVENT_DESCRIPTIONS if d.key == "doorbell") + _, ring_id = await ids_from_device_description(hass, Platform.EVENT, doorbell, ring) + motion = next(d for d in EVENT_DESCRIPTIONS if d.key == "motion_detection") + _, motion_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, motion + ) + assert hass.states.get(ring_id).state != STATE_UNAVAILABLE + assert hass.states.get(motion_id).state != STATE_UNAVAILABLE + + assert ufp.events_ws_state_subscription is not None + ufp.events_ws_state_subscription(WebsocketState.DISCONNECTED) + await hass.async_block_till_done() + + assert hass.states.get(ring_id).state == STATE_UNAVAILABLE + assert hass.states.get(motion_id).state == STATE_UNAVAILABLE + + ufp.events_ws_state_subscription(WebsocketState.CONNECTED) + await hass.async_block_till_done() + + assert hass.states.get(ring_id).state != STATE_UNAVAILABLE + assert hass.states.get(motion_id).state != STATE_UNAVAILABLE diff --git a/tests/components/unifiprotect/test_recorder.py b/tests/components/unifiprotect/test_recorder.py index 49f3680eac9765..2d94743052a453 100644 --- a/tests/components/unifiprotect/test_recorder.py +++ b/tests/components/unifiprotect/test_recorder.py @@ -1,22 +1,26 @@ """The tests for unifiprotect recorder.""" from datetime import datetime, timedelta -from unittest.mock import Mock -from uiprotect.data import Camera, Event, EventType, ModelType +from uiprotect import EventChange, ProtectEvent, ProtectEventChannel +from uiprotect.data import Camera, EventType, SmartDetectObjectType from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states -from homeassistant.components.unifiprotect.binary_sensor import EVENT_SENSORS from homeassistant.components.unifiprotect.const import ( ATTR_EVENT_ID, - ATTR_EVENT_SCORE, - DEFAULT_ATTRIBUTION, + ATTR_SMART_DETECT_TYPES, ) -from homeassistant.const import ATTR_ATTRIBUTION, ATTR_FRIENDLY_NAME, STATE_ON, Platform +from homeassistant.components.unifiprotect.event import EVENT_DESCRIPTIONS +from homeassistant.const import ATTR_FRIENDLY_NAME, Platform from homeassistant.core import HomeAssistant -from .utils import MockUFPFixture, ids_from_device_description, init_entry +from .utils import ( + MockUFPFixture, + ids_from_device_description, + init_entry, + setup_public_camera, +) from tests.components.recorder.common import async_wait_recording_done @@ -29,44 +33,37 @@ async def test_exclude_attributes( unadopted_camera: Camera, fixed_now: datetime, ) -> None: - """Test binary_sensor has event_id and event_score excluded from recording.""" + """The smart-detect event entity excludes event_id/smart_detect_types from recording.""" now = fixed_now + # Smart-detect events arrive on the public events websocket; the entity's + # availability also requires the public camera to resolve. + setup_public_camera(ufp) await init_entry(hass, ufp, [doorbell, unadopted_camera]) + description = next(d for d in EVENT_DESCRIPTIONS if d.key == "smart_detection") _, entity_id = await ids_from_device_description( - hass, Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[1] + hass, Platform.EVENT, doorbell, description ) - event = Event( - model=ModelType.EVENT, - id="test_event_id", - type=EventType.MOTION, - start=fixed_now - timedelta(seconds=1), - end=None, - score=100, - smart_detect_types=[], - smart_detect_event_ids=[], - camera_id=doorbell.id, + ufp.events_msg( + ProtectEvent( + id="test_event_id", + type=EventType.SMART_DETECT, + channel=ProtectEventChannel.DETECTION, + device_id=doorbell.id, + device_mac=doorbell.mac, + start=fixed_now - timedelta(seconds=1), + end=fixed_now, + smart_detect_types=(SmartDetectObjectType.PERSON,), + ), + EventChange.STARTED, ) - - new_camera = doorbell.model_copy() - new_camera.is_motion_detected = True - new_camera.last_motion_event_id = event.id - - mock_msg = Mock() - mock_msg.changed_data = {} - mock_msg.new_obj = new_camera - - ufp.api.bootstrap.cameras = {new_camera.id: new_camera} - ufp.api.bootstrap.events = {event.id: event} - ufp.ws_msg(mock_msg) await hass.async_block_till_done() state = hass.states.get(entity_id) assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - assert state.attributes[ATTR_EVENT_SCORE] == 100 + assert state.attributes[ATTR_EVENT_ID] == "test_event_id" + assert ATTR_SMART_DETECT_TYPES in state.attributes await async_wait_recording_done(hass) states = await hass.async_add_executor_job( @@ -75,6 +72,6 @@ async def test_exclude_attributes( assert len(states) >= 1 for entity_states in states.values(): for state in entity_states: - assert ATTR_EVENT_SCORE not in state.attributes assert ATTR_EVENT_ID not in state.attributes + assert ATTR_SMART_DETECT_TYPES not in state.attributes assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/unifiprotect/test_relay.py b/tests/components/unifiprotect/test_relay.py index 9dc316af6ec1d2..329e16b3377565 100644 --- a/tests/components/unifiprotect/test_relay.py +++ b/tests/components/unifiprotect/test_relay.py @@ -455,6 +455,27 @@ async def test_public_ws_state_change_without_public_bootstrap( assert data.last_public_update_success is False +async def test_events_ws_state_change_without_public_bootstrap( + hass: HomeAssistant, + ufp: MockUFPFixture, +) -> None: + """Events WS state changes flip the flag but no-op without a bootstrap.""" + await init_entry(hass, ufp, []) + data = ufp.entry.runtime_data + assert data.last_events_update_success is True + assert ufp.events_ws_state_subscription is not None + + # No public bootstrap -> re-signal step returns early. + ufp.events_ws_state_subscription(WebsocketState.DISCONNECTED) + await hass.async_block_till_done() + assert data.last_events_update_success is False + + # Same state again -> handler early-returns. + ufp.events_ws_state_subscription(WebsocketState.DISCONNECTED) + await hass.async_block_till_done() + assert data.last_events_update_success is False + + async def test_relay_public_ws_message_without_public_old_obj( hass: HomeAssistant, ufp_with_relay: tuple[MockUFPFixture, Mock], diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 4ae15eef9acf29..9407c4c150db07 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -23,6 +23,8 @@ ProtectModelWithId, PublicBootstrap, Sensor, + SmartDetectAudioType, + SmartDetectObjectType, WSSubscriptionMessage, ) from uiprotect.data.bootstrap import ProtectDeviceRef @@ -35,6 +37,7 @@ PublicSensor, PublicSensorLeakSettings, PublicSensorMotionSettingsRead, + PublicSmartDetectSettings, PublicWirelessBatteryStatus, PublicWirelessConnectionState, SensorFeatureCapability, @@ -62,6 +65,7 @@ class MockUFPFixture: devices_ws_subscription: Callable[[WSSubscriptionMessage], None] | None = None events_subscription: Callable[[ProtectEvent, EventChange], None] | None = None devices_ws_state_subscription: Callable[[WebsocketState], None] | None = None + events_ws_state_subscription: Callable[[WebsocketState], None] | None = None def ws_msg(self, msg: WSSubscriptionMessage) -> None: """Emit WS message for testing.""" @@ -234,19 +238,21 @@ async def init_entry( await hass.async_block_till_done() -def public_rtsps_for(camera: Camera) -> RTSPSStreams | None: +def public_rtsps_for(camera: Camera) -> RTSPSStreams: """Build a camera's primed RTSPS streams from its RTSP-enabled channels. Mirrors what the library writes onto ``PublicCamera.rtsps_streams`` during - ``update_public()`` — only RTSP-enabled channels carry an active URL, and a - camera with none is left streamless (``None``). + ``update_public()`` — only RTSP-enabled channels carry an active URL. The + server answers a successful read with ``null`` per inactive quality even + when nothing is enabled, so a read always yields a streams object; + ``None`` on the camera means the read itself failed (best-effort prime). """ urls = { channel.rtsps_quality: channel.rtsps_url for channel in camera.channels if channel.is_rtsp_enabled and channel.rtsps_quality is not None } - return RTSPSStreams(**urls) if urls else None + return RTSPSStreams(**urls) def make_public_sensor( @@ -408,18 +414,41 @@ def make_public_light( } +_ALL_OBJECT_TYPES = [t for t in SmartDetectObjectType if t.audio_type is None] +_ALL_AUDIO_TYPES = list(SmartDetectAudioType) + + def make_public_camera( camera: Camera, *, state: DeviceState | None = None, + is_motion_detected: bool = False, + is_smart_currently_detected: bool = False, + is_person_currently_detected: bool = False, + is_vehicle_currently_detected: bool = False, + is_animal_currently_detected: bool = False, + is_audio_currently_detected: bool = False, + is_smoke_currently_detected: bool = False, + is_cmonx_currently_detected: bool = False, + is_siren_currently_detected: bool = False, + is_baby_cry_currently_detected: bool = False, + is_speaking_currently_detected: bool = False, + is_bark_currently_detected: bool = False, + is_car_alarm_currently_detected: bool = False, + is_car_horn_currently_detected: bool = False, + is_glass_break_currently_detected: bool = False, + object_types: list[SmartDetectObjectType] | None = None, + audio_types: list[SmartDetectAudioType] | None = None, mic_volume: int | None = None, hdr_type: PublicHdrMode | None = None, ) -> Mock: """Build a public-API camera mirroring a private camera's migrated fields. - ``mic_volume`` and ``hdr_type`` default to values derived from the private - fixture so the public mirror matches it; pass an override to assert a value - the private object would not produce. + The stream tiers/mic/HDR back the migrated stream and select entities; the + ``is_*`` flags back the migrated ``ufp_public_value`` detection paths and the + ``smart_detect_settings`` types back the per-type ``ufp_public_enabled_fn`` + gates (default: all types enabled). ``mic_volume`` and ``hdr_type`` default to + values derived from the private fixture so the public mirror matches it. """ public = Mock(spec=PublicCamera) public.id = camera.id @@ -430,6 +459,43 @@ def make_public_camera( public.model = ModelType.CAMERA public.state = DeviceState[camera.state.name] if state is None else state public.mic_volume = camera.mic_volume if mic_volume is None else mic_volume + public.is_motion_detected = is_motion_detected + public.is_smart_currently_detected = is_smart_currently_detected + public.is_person_currently_detected = is_person_currently_detected + public.is_vehicle_currently_detected = is_vehicle_currently_detected + public.is_animal_currently_detected = is_animal_currently_detected + public.is_audio_currently_detected = is_audio_currently_detected + public.is_smoke_currently_detected = is_smoke_currently_detected + public.is_cmonx_currently_detected = is_cmonx_currently_detected + public.is_siren_currently_detected = is_siren_currently_detected + public.is_baby_cry_currently_detected = is_baby_cry_currently_detected + public.is_speaking_currently_detected = is_speaking_currently_detected + public.is_bark_currently_detected = is_bark_currently_detected + public.is_car_alarm_currently_detected = is_car_alarm_currently_detected + public.is_car_horn_currently_detected = is_car_horn_currently_detected + public.is_glass_break_currently_detected = is_glass_break_currently_detected + public.smart_detect_settings = PublicSmartDetectSettings( + object_types=_ALL_OBJECT_TYPES if object_types is None else object_types, + audio_types=_ALL_AUDIO_TYPES if audio_types is None else audio_types, + ) + # A Mock(spec) does not evaluate properties, so mirror the PublicCamera + # parity properties the migrated detection sensors gate on using the + # library's own logic. + for name in ( + "is_person_detection_on", + "is_vehicle_detection_on", + "is_animal_detection_on", + "is_smoke_detection_on", + "is_co_detection_on", + "is_siren_detection_on", + "is_baby_cry_detection_on", + "is_speaking_detection_on", + "is_bark_detection_on", + "is_car_alarm_detection_on", + "is_car_horn_detection_on", + "is_glass_break_detection_on", + ): + setattr(public, name, getattr(PublicCamera, name).fget(public)) public.hdr_type = ( _HDR_DISPLAY_TO_PUBLIC[camera.hdr_mode_display] if hdr_type is None diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 449864e0bf60bc..7e0aec6f0822cb 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -3243,7 +3243,7 @@ async def _setup_numerical_condition( condition_options: dict[str, Any], target_config: dict[str, Any], domain_specs: Mapping[str, DomainSpec] | None = None, - valid_unit: str | None | UndefinedType = UNDEFINED, + valid_unit: str | UndefinedType | None = UNDEFINED, primary_entities_only: bool = True, ) -> condition.ConditionChecker: """Set up a numerical condition via a mock platform and return the test.""" @@ -3506,7 +3506,7 @@ async def test_numerical_condition_attribute_value_source_skips_unit_check( ) async def test_numerical_condition_valid_unit( hass: HomeAssistant, - valid_unit: str | None | UndefinedType, + valid_unit: str | UndefinedType | None, entity_unit: str | None, expected: bool, ) -> None: @@ -6354,3 +6354,18 @@ def _async_check(self, **kwargs: Any) -> bool: unload_hook.assert_called_once() assert checker._unloaded is True + + +async def test_state_condition_empty_state_value(hass: HomeAssistant) -> None: + """Test that async_from_config does not raise an error for an empty state value.""" + hass.states.async_set("sensor.temperature", "100") + + config = { + "condition": "state", + "entity_id": "sensor.temperature", + "state": [], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + assert not test.async_check() diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 97fbb2e9608823..bf562c8c12be7e 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -1044,7 +1044,7 @@ async def test_friendly_name_attr( hass: HomeAssistant, has_entity_name: bool, entity_name: str | None, - device_name: str | None | UndefinedType, + device_name: str | UndefinedType | None, expected_friendly_name: str | None, ) -> None: """Test friendly name when the entity uses _attr_*.""" diff --git a/tests/pylint/test_super_call.py b/tests/pylint/test_super_call.py index b6f406068ff055..651988294e2d77 100644 --- a/tests/pylint/test_super_call.py +++ b/tests/pylint/test_super_call.py @@ -21,7 +21,15 @@ class Entity: async def async_added_to_hass(self) -> None: pass """, - id="no_parent", + id="added_to_no_parent", + ), + pytest.param( + """ + class Entity: + async def async_will_remove_from_hass(self) -> None: + pass + """, + id="will_remove_from_no_parent", ), pytest.param( """ @@ -33,7 +41,19 @@ class Child(Entity): async def async_added_to_hass(self) -> None: x = 2 """, - id="empty_parent_implementation", + id="added_to_empty_parent_implementation", + ), + pytest.param( + """ + class Entity: + async def async_will_remove_from_hass(self) -> None: + \"\"\"Some docstring.\"\"\" + + class Child(Entity): + async def async_will_remove_from_hass(self) -> None: + x = 2 + """, + id="will_remove_from_empty_parent_implementation", ), pytest.param( """ @@ -46,7 +66,20 @@ class Child(Entity): async def async_added_to_hass(self) -> None: x = 2 """, - id="empty_parent_implementation2", + id="added_to_empty_parent_implementation2", + ), + pytest.param( + """ + class Entity: + async def async_will_remove_from_hass(self) -> None: + \"\"\"Some docstring.\"\"\" + pass + + class Child(Entity): + async def async_will_remove_from_hass(self) -> None: + x = 2 + """, + id="will_remove_from_empty_parent_implementation2", ), pytest.param( """ @@ -58,7 +91,19 @@ class Child(Entity): async def async_added_to_hass(self) -> None: await super().async_added_to_hass() """, - id="correct_super_call", + id="added_to_correct_super_call", + ), + pytest.param( + """ + class Entity: + async def async_will_remove_from_hass(self) -> None: + x = 2 + + class Child(Entity): + async def async_will_remove_from_hass(self) -> None: + await super().async_will_remove_from_hass() + """, + id="will_remove_from_correct_super_call", ), pytest.param( """ @@ -70,7 +115,19 @@ class Child(Entity): async def async_added_to_hass(self) -> None: return await super().async_added_to_hass() """, - id="super_call_in_return", + id="added_to_super_call_in_return", + ), + pytest.param( + """ + class Entity: + async def async_will_remove_from_hass(self) -> None: + x = 2 + + class Child(Entity): + async def async_will_remove_from_hass(self) -> None: + return await super().async_will_remove_from_hass() + """, + id="will_remove_from_super_call_in_return", ), pytest.param( """ @@ -82,7 +139,19 @@ class Child(Entity): def added_to_hass(self) -> None: super().added_to_hass() """, - id="super_call_not_async", + id="added_to_super_call_not_async", + ), + pytest.param( + """ + class Entity: + def async_will_remove_from_hass(self) -> None: + x = 2 + + class Child(Entity): + def async_will_remove_from_hass(self) -> None: + super().async_will_remove_from_hass() + """, + id="will_remove_from_super_call_not_async", ), pytest.param( """ @@ -98,14 +167,37 @@ class Child(Entity, Coordinator): async def async_added_to_hass(self) -> None: await super().async_added_to_hass() """, - id="multiple_inheritance", + id="added_to_multiple_inheritance", + ), + pytest.param( + """ + class Entity: + async def async_will_remove_from_hass(self) -> None: + \"\"\"\"\"\" + + class Coordinator: + async def async_will_remove_from_hass(self) -> None: + x = 2 + + class Child(Entity, Coordinator): + async def async_will_remove_from_hass(self) -> None: + await super().async_will_remove_from_hass() + """, + id="will_remove_from_multiple_inheritance", ), pytest.param( """ async def async_added_to_hass() -> None: x = 2 """, - id="not_a_method", + id="added_to_not_a_method", + ), + pytest.param( + """ + async def async_will_remove_from_hass() -> None: + x = 2 + """, + id="will_remove_from_not_a_method", ), ], ) @@ -120,7 +212,12 @@ def test_enforce_super_call( with ( patch( "pylint_home_assistant.checkers.super_call.METHODS", - new={"added_to_hass", "async_added_to_hass"}, + new={ + "added_to_hass", + "async_added_to_hass", + "will_remove_from_hass", + "async_will_remove_from_hass", + }, ), assert_no_messages(linter), ): @@ -141,7 +238,20 @@ def added_to_hass(self) -> None: x = 3 """, 1, - id="no_super_call", + id="added_to_no_super_call", + ), + pytest.param( + """ + class Entity: + def async_will_remove_from_hass(self) -> None: + x = 2 + + class Child(Entity): + def async_will_remove_from_hass(self) -> None: + x = 3 + """, + 1, + id="will_remove_from_no_super_call", ), pytest.param( """ @@ -154,7 +264,20 @@ async def async_added_to_hass(self) -> None: x = 3 """, 1, - id="no_super_call_async", + id="async_added_to_no_super_call", + ), + pytest.param( + """ + class Entity: + async def async_will_remove_from_hass(self) -> None: + x = 2 + + class Child(Entity): + async def async_will_remove_from_hass(self) -> None: + x = 3 + """, + 1, + id="async_will_remove_from_no_super_call", ), pytest.param( """ @@ -167,7 +290,20 @@ async def async_added_to_hass(self) -> None: await Entity.async_added_to_hass() """, 1, - id="explicit_call_to_base_implementation", + id="added_to_explicit_call_to_base_implementation", + ), + pytest.param( + """ + class Entity: + async def async_will_remove_from_hass(self) -> None: + x = 2 + + class Child(Entity): + async def async_will_remove_from_hass(self) -> None: + await Entity.async_will_remove_from_hass() + """, + 1, + id="will_remove_from_explicit_call_to_base_implementation", ), pytest.param( """ @@ -184,7 +320,24 @@ async def async_added_to_hass(self) -> None: x = 3 """, 2, - id="multiple_inheritance", + id="added_to_multiple_inheritance", + ), + pytest.param( + """ + class Entity: + async def async_will_remove_from_hass(self) -> None: + \"\"\"\"\"\" + + class Coordinator: + async def async_will_remove_from_hass(self) -> None: + x = 2 + + class Child(Entity, Coordinator): + async def async_will_remove_from_hass(self) -> None: + x = 3 + """, + 2, + id="will_remove_from_multiple_inheritance", ), ], ) @@ -201,7 +354,12 @@ def test_enforce_super_call_bad( with ( patch( "pylint_home_assistant.checkers.super_call.METHODS", - new={"added_to_hass", "async_added_to_hass"}, + new={ + "added_to_hass", + "async_added_to_hass", + "will_remove_from_hass", + "async_will_remove_from_hass", + }, ), assert_adds_messages( linter,