diff --git a/CODEOWNERS b/CODEOWNERS index ccb837bedb740a..93d8fcdbefdb58 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1967,6 +1967,8 @@ CLAUDE.md @home-assistant/core /tests/components/version/ @ludeeus /homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven /tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven +/homeassistant/components/vibration/ @home-assistant/core +/tests/components/vibration/ @home-assistant/core /homeassistant/components/vicare/ @CFenner @lackas /tests/components/vicare/ @CFenner @lackas /homeassistant/components/victron_ble/ @rajlaud diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 5313392d73a9d7..0c606c38d0802a 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -264,6 +264,7 @@ "occupancy", "power", "temperature", + "vibration", "window", } DEFAULT_INTEGRATIONS_RECOVERY_MODE = { diff --git a/homeassistant/components/anthropic/manifest.json b/homeassistant/components/anthropic/manifest.json index 3153ab89fb3150..398f3ccef50764 100644 --- a/homeassistant/components/anthropic/manifest.json +++ b/homeassistant/components/anthropic/manifest.json @@ -8,6 +8,6 @@ "documentation": "https://www.home-assistant.io/integrations/anthropic", "integration_type": "service", "iot_class": "cloud_polling", - "quality_scale": "gold", + "quality_scale": "platinum", "requirements": ["anthropic==0.108.0"] } diff --git a/homeassistant/components/knx/date.py b/homeassistant/components/knx/date.py index e84c9f2c79413d..ec7c7cb2c2203e 100644 --- a/homeassistant/components/knx/date.py +++ b/homeassistant/components/knx/date.py @@ -79,10 +79,8 @@ async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() if ( - not self._device.remote_value.readable - and (last_state := await self.async_get_last_state()) is not None - and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) - ): + last_state := await self.async_get_last_state() + ) is not None and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): self._device.remote_value.value = XKNXDate.from_date( dt_date.fromisoformat(last_state.state) ) diff --git a/homeassistant/components/knx/datetime.py b/homeassistant/components/knx/datetime.py index 91c81eba8f15f7..04674fa4cd2812 100644 --- a/homeassistant/components/knx/datetime.py +++ b/homeassistant/components/knx/datetime.py @@ -80,10 +80,8 @@ async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() if ( - not self._device.remote_value.readable - and (last_state := await self.async_get_last_state()) is not None - and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) - ): + last_state := await self.async_get_last_state() + ) is not None and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): self._device.remote_value.value = XKNXDateTime.from_datetime( datetime.fromisoformat(last_state.state).astimezone( dt_util.get_default_time_zone() diff --git a/homeassistant/components/knx/number.py b/homeassistant/components/knx/number.py index db59b9527eb56b..b6102c805e86d3 100644 --- a/homeassistant/components/knx/number.py +++ b/homeassistant/components/knx/number.py @@ -82,10 +82,8 @@ class _KnxNumber(RestoreNumber): async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() - if ( - not self._device.sensor_value.readable - and (last_state := await self.async_get_last_state()) - and (last_number_data := await self.async_get_last_number_data()) + if (last_state := await self.async_get_last_state()) and ( + last_number_data := await self.async_get_last_number_data() ): if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): self._device.sensor_value.value = last_number_data.native_value diff --git a/homeassistant/components/knx/select.py b/homeassistant/components/knx/select.py index f67465291dc5ed..b9079ac9ee3059 100644 --- a/homeassistant/components/knx/select.py +++ b/homeassistant/components/knx/select.py @@ -83,9 +83,7 @@ def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() - if not self._device.remote_value.readable and ( - last_state := await self.async_get_last_state() - ): + if last_state := await self.async_get_last_state(): if ( last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) and (option := self._option_payloads.get(last_state.state)) is not None diff --git a/homeassistant/components/knx/text.py b/homeassistant/components/knx/text.py index d96c41dc45ac51..c42e1863e174b7 100644 --- a/homeassistant/components/knx/text.py +++ b/homeassistant/components/knx/text.py @@ -81,9 +81,7 @@ class _KnxText(TextEntity, RestoreEntity): async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() - if not self._device.remote_value.readable and ( - last_state := await self.async_get_last_state() - ): + if last_state := await self.async_get_last_state(): if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): self._device.remote_value.value = last_state.state diff --git a/homeassistant/components/knx/time.py b/homeassistant/components/knx/time.py index 99e16b0a2beb6b..dd42a23cf39707 100644 --- a/homeassistant/components/knx/time.py +++ b/homeassistant/components/knx/time.py @@ -79,10 +79,8 @@ async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() if ( - not self._device.remote_value.readable - and (last_state := await self.async_get_last_state()) is not None - and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) - ): + last_state := await self.async_get_last_state() + ) is not None and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): self._device.remote_value.value = XknxTime.from_time( dt_time.fromisoformat(last_state.state) ) diff --git a/homeassistant/components/moon/const.py b/homeassistant/components/moon/const.py index 3e926b4ff3e87c..f51f80431804ef 100644 --- a/homeassistant/components/moon/const.py +++ b/homeassistant/components/moon/const.py @@ -8,3 +8,5 @@ PLATFORMS: Final = [Platform.SENSOR] DEFAULT_NAME: Final = "Moon" + +CONF_PHASE: Final = "phase" diff --git a/homeassistant/components/moon/helpers.py b/homeassistant/components/moon/helpers.py new file mode 100644 index 00000000000000..dbf3b7907b3df5 --- /dev/null +++ b/homeassistant/components/moon/helpers.py @@ -0,0 +1,48 @@ +"""Helpers for moon phases.""" + +from astral import moon + +from homeassistant.core import callback +from homeassistant.util import dt as dt_util + +STATE_FIRST_QUARTER = "first_quarter" +STATE_FULL_MOON = "full_moon" +STATE_LAST_QUARTER = "last_quarter" +STATE_NEW_MOON = "new_moon" +STATE_WANING_CRESCENT = "waning_crescent" +STATE_WANING_GIBBOUS = "waning_gibbous" +STATE_WAXING_CRESCENT = "waxing_crescent" +STATE_WAXING_GIBBOUS = "waxing_gibbous" + +# The eight moon phases in chronological order (new moon to waning crescent). +MOON_PHASES: tuple[str, ...] = ( + STATE_NEW_MOON, + STATE_WAXING_CRESCENT, + STATE_FIRST_QUARTER, + STATE_WAXING_GIBBOUS, + STATE_FULL_MOON, + STATE_WANING_GIBBOUS, + STATE_LAST_QUARTER, + STATE_WANING_CRESCENT, +) + + +@callback +def moon_phase() -> str: + """Return the current moon phase.""" + value: float = moon.phase(dt_util.now().date()) + if value < 0.5 or value > 27.5: + return STATE_NEW_MOON + if value < 6.5: + return STATE_WAXING_CRESCENT + if value < 7.5: + return STATE_FIRST_QUARTER + if value < 13.5: + return STATE_WAXING_GIBBOUS + if value < 14.5: + return STATE_FULL_MOON + if value < 20.5: + return STATE_WANING_GIBBOUS + if value < 21.5: + return STATE_LAST_QUARTER + return STATE_WANING_CRESCENT diff --git a/homeassistant/components/moon/icons.json b/homeassistant/components/moon/icons.json index 77c578c8f0d87b..288925f28be3ba 100644 --- a/homeassistant/components/moon/icons.json +++ b/homeassistant/components/moon/icons.json @@ -15,5 +15,10 @@ } } } + }, + "triggers": { + "phase_changed": { + "trigger": "mdi:moon-waning-crescent" + } } } diff --git a/homeassistant/components/moon/sensor.py b/homeassistant/components/moon/sensor.py index 3f7f25eb81494d..c20a0a392dc691 100644 --- a/homeassistant/components/moon/sensor.py +++ b/homeassistant/components/moon/sensor.py @@ -1,24 +1,13 @@ """Support for tracking the moon phases.""" -from astral import moon - from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util import dt as dt_util from .const import DOMAIN - -STATE_FIRST_QUARTER = "first_quarter" -STATE_FULL_MOON = "full_moon" -STATE_LAST_QUARTER = "last_quarter" -STATE_NEW_MOON = "new_moon" -STATE_WANING_CRESCENT = "waning_crescent" -STATE_WANING_GIBBOUS = "waning_gibbous" -STATE_WAXING_CRESCENT = "waxing_crescent" -STATE_WAXING_GIBBOUS = "waxing_gibbous" +from .helpers import MOON_PHASES, moon_phase async def async_setup_entry( @@ -35,16 +24,7 @@ class MoonSensorEntity(SensorEntity): _attr_has_entity_name = True _attr_device_class = SensorDeviceClass.ENUM - _attr_options = [ - STATE_NEW_MOON, - STATE_WAXING_CRESCENT, - STATE_FIRST_QUARTER, - STATE_WAXING_GIBBOUS, - STATE_FULL_MOON, - STATE_WANING_GIBBOUS, - STATE_LAST_QUARTER, - STATE_WANING_CRESCENT, - ] + _attr_options = list(MOON_PHASES) _attr_translation_key = "phase" def __init__(self, entry: ConfigEntry) -> None: @@ -58,22 +38,4 @@ def __init__(self, entry: ConfigEntry) -> None: async def async_update(self) -> None: """Get the time and updates the states.""" - today = dt_util.now().date() - state = moon.phase(today) - - if state < 0.5 or state > 27.5: - self._attr_native_value = STATE_NEW_MOON - elif state < 6.5: - self._attr_native_value = STATE_WAXING_CRESCENT - elif state < 7.5: - self._attr_native_value = STATE_FIRST_QUARTER - elif state < 13.5: - self._attr_native_value = STATE_WAXING_GIBBOUS - elif state < 14.5: - self._attr_native_value = STATE_FULL_MOON - elif state < 20.5: - self._attr_native_value = STATE_WANING_GIBBOUS - elif state < 21.5: - self._attr_native_value = STATE_LAST_QUARTER - else: - self._attr_native_value = STATE_WANING_CRESCENT + self._attr_native_value = moon_phase() diff --git a/homeassistant/components/moon/strings.json b/homeassistant/components/moon/strings.json index 8048f344c7b1f7..65baaed8766ac8 100644 --- a/homeassistant/components/moon/strings.json +++ b/homeassistant/components/moon/strings.json @@ -37,5 +37,32 @@ } } }, - "title": "Moon" + "selector": { + "phase": { + "options": { + "any": "Any", + "first_quarter": "[%key:component::moon::entity::sensor::phase::state::first_quarter%]", + "full_moon": "[%key:component::moon::entity::sensor::phase::state::full_moon%]", + "last_quarter": "[%key:component::moon::entity::sensor::phase::state::last_quarter%]", + "new_moon": "[%key:component::moon::entity::sensor::phase::state::new_moon%]", + "waning_crescent": "[%key:component::moon::entity::sensor::phase::state::waning_crescent%]", + "waning_gibbous": "[%key:component::moon::entity::sensor::phase::state::waning_gibbous%]", + "waxing_crescent": "[%key:component::moon::entity::sensor::phase::state::waxing_crescent%]", + "waxing_gibbous": "[%key:component::moon::entity::sensor::phase::state::waxing_gibbous%]" + } + } + }, + "title": "Moon", + "triggers": { + "phase_changed": { + "description": "Triggers when the moon enters a new phase.", + "fields": { + "phase": { + "description": "Limit the trigger to a specific moon phase, or leave as Any to trigger on every phase change.", + "name": "Phase" + } + }, + "name": "Moon phase changed" + } + } } diff --git a/homeassistant/components/moon/trigger.py b/homeassistant/components/moon/trigger.py new file mode 100644 index 00000000000000..174436020f9aba --- /dev/null +++ b/homeassistant/components/moon/trigger.py @@ -0,0 +1,88 @@ +"""Provides triggers for the moon.""" + +from datetime import datetime +from typing import cast, override + +import voluptuous as vol + +from homeassistant.const import CONF_OPTIONS +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.event import async_track_time_change +from homeassistant.helpers.trigger import ( + Trigger, + TriggerActionRunner, + TriggerConfig, + TriggerNotTriggeredReporter, +) +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_PHASE +from .helpers import MOON_PHASES, moon_phase + +PHASE_ANY = "any" + +_PHASE_CHANGED_TRIGGER_SCHEMA = vol.Schema( + { + vol.Required(CONF_OPTIONS, default=dict): { + vol.Optional(CONF_PHASE, default=PHASE_ANY): vol.In( + [PHASE_ANY, *MOON_PHASES] + ), + } + } +) + + +class MoonPhaseChangedTrigger(Trigger): + """Trigger that fires when the moon enters a new phase.""" + + @override + @classmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + return cast(ConfigType, _PHASE_CHANGED_TRIGGER_SCHEMA(config)) + + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + """Initialize the trigger.""" + super().__init__(hass, config) + options = config.options or {} + self._phase: str = options[CONF_PHASE] + + @override + async def async_attach_runner( + self, + run_action: TriggerActionRunner, + did_not_trigger: TriggerNotTriggeredReporter | None = None, + ) -> CALLBACK_TYPE: + """Attach the trigger to an action runner.""" + last_phase = moon_phase() + + @callback + def check_phase(_now: datetime) -> None: + nonlocal last_phase + current_phase = moon_phase() + if current_phase == last_phase: + return + previous_phase = last_phase + last_phase = current_phase + if self._phase in (PHASE_ANY, current_phase): + run_action( + {"phase": current_phase, "previous_phase": previous_phase}, + "moon phase changed", + ) + + # The binned phase can only change when the local date rolls over. + return async_track_time_change( + self._hass, check_phase, hour=0, minute=0, second=0 + ) + + +TRIGGERS: dict[str, type[Trigger]] = { + "phase_changed": MoonPhaseChangedTrigger, +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for the moon.""" + return TRIGGERS diff --git a/homeassistant/components/moon/triggers.yaml b/homeassistant/components/moon/triggers.yaml new file mode 100644 index 00000000000000..7a6457d452e35b --- /dev/null +++ b/homeassistant/components/moon/triggers.yaml @@ -0,0 +1,18 @@ +phase_changed: + fields: + phase: + required: true + default: any + selector: + select: + translation_key: phase + options: + - any + - new_moon + - waxing_crescent + - first_quarter + - waxing_gibbous + - full_moon + - waning_gibbous + - last_quarter + - waning_crescent diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index daf5611f0424f1..faed74a2a16f32 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 +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC @@ -116,6 +116,35 @@ def _log_connection_state(_hub: nobo, connected: bool) -> None: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + @callback + def _cleanup_devices(_hub: nobo) -> None: + """Remove devices for zones and components no longer on the hub.""" + if not hub.connected: + # While disconnected pynobo may hold stale topology; only reconcile + # against a live, fully-synced hub. + return + expected_identifiers = {(DOMAIN, hub.hub_serial)} + expected_identifiers.update( + (DOMAIN, f"{hub.hub_serial}:{zone_id}") for zone_id in hub.zones + ) + expected_identifiers.update((DOMAIN, serial) for serial in hub.components) + # Runs inside pynobo's update-callback dispatch: removing a device + # deregisters its entities' callbacks mid-iteration, which can skip a + # following callback. Safe because a pynobo message carries a single + # topology change, so a removal never coincides with a surviving + # entity's update in the same dispatch. + for device in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + if device.identifiers.isdisjoint(expected_identifiers): + device_registry.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) + + _cleanup_devices(hub) + hub.register_callback(_cleanup_devices) + entry.async_on_unload(lambda: hub.deregister_callback(_cleanup_devices)) + await hub.start() return True diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index 06552658b0ec0c..aa09b8fba97f8a 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -69,6 +69,12 @@ async def async_setup_entry( @callback def _add_zones(_hub: nobo) -> None: """Add climate entities for zones added to the hub.""" + if hub.connected: + # Forget zones no longer on the hub so a removed-then-re-added zone + # (the hub reuses zone ids) is detected as new again. Skip while + # disconnected: a stale/empty snapshot would drop live zones and + # cause duplicate re-adds on reconnect. + known_zones.intersection_update(hub.zones) new_zones = [zone_id for zone_id in hub.zones if zone_id not in known_zones] known_zones.update(new_zones) async_add_entities( diff --git a/homeassistant/components/nobo_hub/quality_scale.yaml b/homeassistant/components/nobo_hub/quality_scale.yaml index 5c5dddba9d9be4..6ad1081c7d7b72 100644 --- a/homeassistant/components/nobo_hub/quality_scale.yaml +++ b/homeassistant/components/nobo_hub/quality_scale.yaml @@ -69,7 +69,7 @@ rules: repair-issues: status: exempt comment: Integration has no repair scenarios. - stale-devices: todo + stale-devices: done # Platinum async-dependency: done diff --git a/homeassistant/components/nobo_hub/select.py b/homeassistant/components/nobo_hub/select.py index 40b85798d42f41..85ad51e78e04b9 100644 --- a/homeassistant/components/nobo_hub/select.py +++ b/homeassistant/components/nobo_hub/select.py @@ -47,6 +47,12 @@ async def async_setup_entry( @callback def _add_profiles(_hub: nobo) -> None: """Add week-profile selectors for zones added to the hub.""" + if hub.connected: + # Forget zones no longer on the hub so a removed-then-re-added zone + # (the hub reuses zone ids) is detected as new again. Skip while + # disconnected: a stale/empty snapshot would drop live zones and + # cause duplicate re-adds on reconnect. + known_zones.intersection_update(hub.zones) new_zones = [zone_id for zone_id in hub.zones if zone_id not in known_zones] known_zones.update(new_zones) async_add_entities( diff --git a/homeassistant/components/nobo_hub/sensor.py b/homeassistant/components/nobo_hub/sensor.py index 88bc76bf15069d..371fa96e682368 100644 --- a/homeassistant/components/nobo_hub/sensor.py +++ b/homeassistant/components/nobo_hub/sensor.py @@ -35,6 +35,12 @@ async def async_setup_entry( @callback def _add_sensors(_hub: nobo) -> None: """Add temperature sensors for components added to the hub.""" + if hub.connected: + # Forget components no longer on the hub so a removed-then-re-added + # component is detected as new again. Skip while disconnected: a + # stale/empty snapshot would drop live components and cause + # duplicate re-adds on reconnect. + known_components.intersection_update(hub.components) new_components = [ serial for serial, component in hub.components.items() diff --git a/homeassistant/components/overkiz/climate/__init__.py b/homeassistant/components/overkiz/climate/__init__.py index 4f56034d03c05d..e68c9d95b68ab9 100644 --- a/homeassistant/components/overkiz/climate/__init__.py +++ b/homeassistant/components/overkiz/climate/__init__.py @@ -57,6 +57,9 @@ class Controllable(StrEnum): UIWidget.EVO_HOME_CONTROLLER: EvoHomeController, UIWidget.SOMFY_HEATING_TEMPERATURE_INTERFACE: SomfyHeatingTemperatureInterface, UIWidget.SOMFY_THERMOSTAT: SomfyThermostat, + UIWidget.THERMOSTAT_HEATING_TEMPERATURE_INTERFACE: ( + ValveHeatingTemperatureInterface + ), UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: ValveHeatingTemperatureInterface, UIWidget.ATLANTIC_PASS_APC_HEAT_PUMP: AtlanticPassAPCHeatPumpMainComponent, } diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py index b0cbe6f9c8a84a..6748b59fa545ae 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -119,6 +119,7 @@ UIWidget.STATELESS_ALARM_CONTROLLER: Platform.SWITCH, UIWidget.STATEFUL_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, UIWidget.STATELESS_EXTERIOR_HEATING: Platform.SWITCH, + UIWidget.THERMOSTAT_HEATING_TEMPERATURE_INTERFACE: Platform.CLIMATE, UIWidget.TSK_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: Platform.CLIMATE, } diff --git a/homeassistant/components/overseerr/const.py b/homeassistant/components/overseerr/const.py index b955d2a50a408d..a48ac7669b4147 100644 --- a/homeassistant/components/overseerr/const.py +++ b/homeassistant/components/overseerr/const.py @@ -9,9 +9,14 @@ REQUESTS = "requests" +ATTR_MEDIA_TYPE = "media_type" +ATTR_QUERY = "query" +ATTR_REQUESTED_BY = "requested_by" +ATTR_SEASONS = "seasons" ATTR_STATUS = "status" ATTR_SORT_ORDER = "sort_order" -ATTR_REQUESTED_BY = "requested_by" +ATTR_MEDIA_ID = "media_id" + EVENT_KEY = f"{DOMAIN}_event" diff --git a/homeassistant/components/overseerr/icons.json b/homeassistant/components/overseerr/icons.json index 9b63943f8989a8..290aa0a976dc15 100644 --- a/homeassistant/components/overseerr/icons.json +++ b/homeassistant/components/overseerr/icons.json @@ -32,6 +32,12 @@ "services": { "get_requests": { "service": "mdi:multimedia" + }, + "request_media": { + "service": "mdi:download" + }, + "search_media": { + "service": "mdi:magnify" } } } diff --git a/homeassistant/components/overseerr/services.py b/homeassistant/components/overseerr/services.py index 5354102472cafa..9405b21ea6dd80 100644 --- a/homeassistant/components/overseerr/services.py +++ b/homeassistant/components/overseerr/services.py @@ -1,7 +1,8 @@ """Define services for the Overseerr integration.""" +import ast from dataclasses import asdict -from typing import Any, cast +from typing import Any, Literal, cast from python_overseerr import OverseerrClient, OverseerrConnectionError import voluptuous as vol @@ -18,10 +19,23 @@ from homeassistant.helpers import service from homeassistant.util.json import JsonValueType -from .const import ATTR_REQUESTED_BY, ATTR_SORT_ORDER, ATTR_STATUS, DOMAIN, LOGGER +from .const import ( + ATTR_MEDIA_ID, + ATTR_MEDIA_TYPE, + ATTR_QUERY, + ATTR_REQUESTED_BY, + ATTR_SEASONS, + ATTR_SORT_ORDER, + ATTR_STATUS, + DOMAIN, + LOGGER, +) from .coordinator import OverseerrConfigEntry SERVICE_GET_REQUESTS = "get_requests" +SERVICE_SEARCH_MEDIA = "search_media" +SERVICE_REQUEST_MEDIA = "request_media" + SERVICE_GET_REQUESTS_SCHEMA = vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY_ID): str, @@ -33,6 +47,29 @@ } ) +SERVICE_SEARCH_MEDIA_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + vol.Required(ATTR_QUERY): str, + } +) + +SERVICE_REQUEST_MEDIA_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + vol.Required(ATTR_MEDIA_TYPE): vol.In(["movie", "tv"]), + vol.Required(ATTR_MEDIA_ID): vol.All( + vol.Coerce(int), + vol.Range(min=1), + ), + vol.Optional(ATTR_SEASONS): vol.Any( + vol.Coerce(int), + [vol.Coerce(int)], + str, + ), + } +) + async def _get_media( client: OverseerrClient, media_type: str, identifier: int @@ -52,7 +89,7 @@ async def _get_media( async def _async_get_requests(call: ServiceCall) -> ServiceResponse: - """Get requests made to Overseerr.""" + """Get requests made to Seerr.""" entry: OverseerrConfigEntry = service.async_get_config_entry( call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] ) @@ -92,9 +129,79 @@ async def _async_get_requests(call: ServiceCall) -> ServiceResponse: return {"requests": cast(list[JsonValueType], result)} +async def _async_search_media(call: ServiceCall) -> ServiceResponse: + """Search for media in Seerr.""" + entry: OverseerrConfigEntry = service.async_get_config_entry( + call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] + ) + client = entry.runtime_data.client + query = call.data[ATTR_QUERY] + + LOGGER.debug("Searching for '%s'", query) + try: + search_results = await client.search(query) + except OverseerrConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + translation_placeholders={"error": str(err)}, + ) from err + + return { + "results": cast( + list[JsonValueType], [asdict(result) for result in search_results] + ) + } + + +async def _async_request_media(call: ServiceCall) -> ServiceResponse: + """Request media in Seerr.""" + entry: OverseerrConfigEntry = service.async_get_config_entry( + call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] + ) + client = entry.runtime_data.client + media_type = call.data[ATTR_MEDIA_TYPE] + media_id = call.data[ATTR_MEDIA_ID] + seasons = parse_seasons_input(call.data.get(ATTR_SEASONS)) + + LOGGER.debug( + "Requesting %s with media ID %s (seasons: %s)", + media_type, + media_id, + seasons or "none", + ) + try: + # We can always pass in the seasons, they will be ignored if the media type isn't TV + request = await client.create_request(media_type, media_id, seasons) + except OverseerrConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + translation_placeholders={"error": str(err)}, + ) from err + + return {"request": cast(JsonValueType, asdict(request))} + + +def parse_seasons_input(seasons_input: Any | None) -> Literal["all"] | list[int]: + """Parse all possible inputs to "all" or a list of integers.""" + seasons_str = str(seasons_input).strip() + if seasons_input is None or seasons_str in ("", "all"): + return "all" + + try: + parsed = ast.literal_eval(seasons_str) + if isinstance(parsed, int): + return [parsed] + return [int(season) for season in parsed] + except ValueError, SyntaxError, TypeError: + LOGGER.error("Unable to cast input to a list '%s'", seasons_input) + return "all" + + @callback def async_setup_services(hass: HomeAssistant) -> None: - """Set up the services for the Overseerr integration.""" + """Set up the services for the Seerr integration.""" hass.services.async_register( DOMAIN, @@ -103,3 +210,19 @@ def async_setup_services(hass: HomeAssistant) -> None: schema=SERVICE_GET_REQUESTS_SCHEMA, supports_response=SupportsResponse.ONLY, ) + + hass.services.async_register( + DOMAIN, + SERVICE_SEARCH_MEDIA, + _async_search_media, + schema=SERVICE_SEARCH_MEDIA_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_REQUEST_MEDIA, + _async_request_media, + schema=SERVICE_REQUEST_MEDIA_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/overseerr/services.yaml b/homeassistant/components/overseerr/services.yaml index c7593fc5aee185..3fcf49ba8f5de7 100644 --- a/homeassistant/components/overseerr/services.yaml +++ b/homeassistant/components/overseerr/services.yaml @@ -28,3 +28,40 @@ get_requests: number: min: 0 mode: box + +search_media: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: overseerr + query: + required: true + selector: + text: + +request_media: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: overseerr + media_type: + required: true + selector: + select: + options: + - movie + - tv + translation_key: request_media_type + media_id: + required: true + selector: + number: + min: 1 + mode: box + seasons: + selector: + text: diff --git a/homeassistant/components/overseerr/strings.json b/homeassistant/components/overseerr/strings.json index 9ddfc6929f6d47..aa139f6cf919d4 100644 --- a/homeassistant/components/overseerr/strings.json +++ b/homeassistant/components/overseerr/strings.json @@ -118,6 +118,12 @@ } }, "selector": { + "request_media_type": { + "options": { + "movie": "Movie", + "tv": "TV" + } + }, "request_sort_order": { "options": { "added": "Added", @@ -157,6 +163,42 @@ } }, "name": "Get requests" + }, + "request_media": { + "description": "Creates a media request in Seerr.", + "fields": { + "config_entry_id": { + "description": "The Seerr instance to create the request on.", + "name": "Seerr instance" + }, + "media_id": { + "description": "The TMDB ID or TVDB ID of the media to request.", + "name": "Media ID" + }, + "media_type": { + "description": "Type of media to request.", + "name": "Media type" + }, + "seasons": { + "description": "For TV requests: seasons to request. Optional list of integers (e.g., [1, 2, 4]). If omitted, all seasons will be requested.", + "name": "Seasons" + } + }, + "name": "Request media" + }, + "search_media": { + "description": "Searches for media in Seerr.", + "fields": { + "config_entry_id": { + "description": "The Seerr instance to search.", + "name": "Seerr instance" + }, + "query": { + "description": "The search query.", + "name": "Query" + } + }, + "name": "Search media" } } } diff --git a/homeassistant/components/picnic/const.py b/homeassistant/components/picnic/const.py index b913092771fea0..98330e34a0b283 100644 --- a/homeassistant/components/picnic/const.py +++ b/homeassistant/components/picnic/const.py @@ -1,5 +1,7 @@ """Constants for the Picnic integration.""" +from datetime import timedelta + DOMAIN = "picnic" SERVICE_ADD_PRODUCT_TO_CART = "add_product" @@ -18,6 +20,11 @@ NEXT_DELIVERY_DATA = "next_delivery_data" LAST_ORDER_DATA = "last_order_data" +DEFAULT_UPDATE_INTERVAL = timedelta(minutes=30) +DELIVERY_UPDATE_INTERVAL = timedelta(minutes=1) +DELIVERY_WINDOW_LEAD_TIME = timedelta(minutes=30) +DELIVERY_WINDOW_LAG_TIME = timedelta(hours=2) + SENSOR_CART_ITEMS_COUNT = "cart_items_count" SENSOR_CART_TOTAL_PRICE = "cart_total_price" SENSOR_SELECTED_SLOT_START = "selected_slot_start" diff --git a/homeassistant/components/picnic/coordinator.py b/homeassistant/components/picnic/coordinator.py index 43aca27b3bf2ef..8cc2b21a5be5d2 100644 --- a/homeassistant/components/picnic/coordinator.py +++ b/homeassistant/components/picnic/coordinator.py @@ -15,8 +15,19 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed - -from .const import ADDRESS, CART_DATA, LAST_ORDER_DATA, NEXT_DELIVERY_DATA, SLOT_DATA +from homeassistant.util import dt as dt_util + +from .const import ( + ADDRESS, + CART_DATA, + DEFAULT_UPDATE_INTERVAL, + DELIVERY_UPDATE_INTERVAL, + DELIVERY_WINDOW_LAG_TIME, + DELIVERY_WINDOW_LEAD_TIME, + LAST_ORDER_DATA, + NEXT_DELIVERY_DATA, + SLOT_DATA, +) type PicnicConfigEntry = ConfigEntry[PicnicUpdateCoordinator] @@ -42,12 +53,18 @@ def __init__( logger, config_entry=config_entry, name="Picnic coordinator", - update_interval=timedelta(minutes=30), + update_interval=DEFAULT_UPDATE_INTERVAL, ) @override async def _async_update_data(self) -> dict: """Fetch data from API endpoint.""" + # Recompute up front so failed refreshes also relax the cadence + if self.data: + self.update_interval = self._get_update_interval( + self.data.get(NEXT_DELIVERY_DATA) + ) + try: async with asyncio.timeout(10): data = await self.hass.async_add_executor_job(self.fetch_data) @@ -63,9 +80,45 @@ async def _async_update_data(self) -> dict: "Timeout while connecting to the Picnic API", retry_after=120 ) from error + self.update_interval = self._get_update_interval(data.get(NEXT_DELIVERY_DATA)) + # Return the fetched data return data + @staticmethod + def _get_update_interval(next_delivery: dict | None) -> timedelta: + """Poll faster around the delivery so the live ETA is picked up in time.""" + if not next_delivery: + return DEFAULT_UPDATE_INTERVAL + + eta = next_delivery.get("eta") + slot = next_delivery.get("slot") + + start = end = None + if eta: + start = dt_util.parse_datetime(str(eta.get("start"))) + end = dt_util.parse_datetime(str(eta.get("end"))) + if (start is None or end is None) and slot: + start = dt_util.parse_datetime(str(slot.get("window_start"))) + end = dt_util.parse_datetime(str(slot.get("window_end"))) + + if start is None or end is None: + return DEFAULT_UPDATE_INTERVAL + + now = dt_util.utcnow() + window_start = start - DELIVERY_WINDOW_LEAD_TIME + + if window_start <= now <= end + DELIVERY_WINDOW_LAG_TIME: + return DELIVERY_UPDATE_INTERVAL + + if now < window_start: + return max( + DELIVERY_UPDATE_INTERVAL, + min(DEFAULT_UPDATE_INTERVAL, window_start - now), + ) + + return DEFAULT_UPDATE_INTERVAL + def fetch_data(self): """Fetch data from the Picnic API. diff --git a/homeassistant/components/ptdevices/__init__.py b/homeassistant/components/ptdevices/__init__.py index 9a557749494e28..00f8c28d8a86e1 100644 --- a/homeassistant/components/ptdevices/__init__.py +++ b/homeassistant/components/ptdevices/__init__.py @@ -11,6 +11,7 @@ from .coordinator import PTDevicesConfigEntry, PTDevicesCoordinator _PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, Platform.SENSOR, ] diff --git a/homeassistant/components/ptdevices/binary_sensor.py b/homeassistant/components/ptdevices/binary_sensor.py new file mode 100644 index 00000000000000..b3858200c1717a --- /dev/null +++ b/homeassistant/components/ptdevices/binary_sensor.py @@ -0,0 +1,121 @@ +"""PTDevices Binary Sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum +from typing import override + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import PTDevicesConfigEntry, PTDevicesCoordinator +from .entity import PTDevicesEntity + +PARALLEL_UPDATES = 0 + + +class PTDevicesBinarySensors(StrEnum): + """Store keys for PTDevices binary sensors.""" + + DEVICE_BATTERY_STATUS = "battery_status" + DEVICE_EXTERNAL_POWER = "external_power" + + +@dataclass(kw_only=True, frozen=True) +class PTDevicesBinarySensorEntityDescription(BinarySensorEntityDescription): + """Description for PTDevices binary sensor entities.""" + + is_on_fn: Callable[[dict[str, StateType]], bool | None] + + +BINARY_SENSOR_DESCRIPTIONS: tuple[PTDevicesBinarySensorEntityDescription, ...] = ( + PTDevicesBinarySensorEntityDescription( + key=PTDevicesBinarySensors.DEVICE_BATTERY_STATUS, + translation_key=PTDevicesBinarySensors.DEVICE_BATTERY_STATUS, + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + is_on_fn=lambda data: ( + None + if data.get(PTDevicesBinarySensors.DEVICE_BATTERY_STATUS) + in (None, "unknown") + else data.get(PTDevicesBinarySensors.DEVICE_BATTERY_STATUS) == "low" + ), + ), + PTDevicesBinarySensorEntityDescription( + key=PTDevicesBinarySensors.DEVICE_EXTERNAL_POWER, + translation_key=PTDevicesBinarySensors.DEVICE_EXTERNAL_POWER, + device_class=BinarySensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + is_on_fn=lambda data: ( + bool(data.get(PTDevicesBinarySensors.DEVICE_EXTERNAL_POWER)) + if data.get(PTDevicesBinarySensors.DEVICE_EXTERNAL_POWER) is not None + else None + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PTDevicesConfigEntry, + async_add_entity: AddConfigEntryEntitiesCallback, +) -> None: + """Setup PTDevices binary sensors based on config entry.""" + coordinator = config_entry.runtime_data + + known_sensors: set[tuple[str, str]] = set() + + def _check_device() -> None: + for device_id in sorted(coordinator.data): + device = coordinator.data[device_id] + new_sensors = [ + sensor + for sensor in BINARY_SENSOR_DESCRIPTIONS + if sensor.key in device and (device_id, sensor.key) not in known_sensors + ] + if not new_sensors: + continue + known_sensors.update((device_id, sensor.key) for sensor in new_sensors) + async_add_entity( + PTDevicesBinarySensorEntity( + config_entry.runtime_data, sensor, device_id + ) + for sensor in new_sensors + ) + + _check_device() + config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) + + +class PTDevicesBinarySensorEntity(PTDevicesEntity, BinarySensorEntity): + """Defines a PTDevices binary sensor.""" + + entity_description: PTDevicesBinarySensorEntityDescription + + def __init__( + self, + coordinator: PTDevicesCoordinator, + description: PTDevicesBinarySensorEntityDescription, + device_id: str, + ) -> None: + """Initialize sensor.""" + super().__init__( + coordinator, + description.key, + device_id, + ) + + self.entity_description = description + + @property + @override + def is_on(self) -> bool | None: + """Return the state of the sensor.""" + return self.entity_description.is_on_fn(self.device) diff --git a/homeassistant/components/ptdevices/strings.json b/homeassistant/components/ptdevices/strings.json index 318c4fd1266de8..9c5def4c87be42 100644 --- a/homeassistant/components/ptdevices/strings.json +++ b/homeassistant/components/ptdevices/strings.json @@ -23,6 +23,11 @@ } }, "entity": { + "binary_sensor": { + "external_power": { + "name": "External power" + } + }, "sensor": { "battery_voltage": { "name": "Battery voltage" diff --git a/homeassistant/components/smtp/config_flow.py b/homeassistant/components/smtp/config_flow.py index 52a7641aa5f2a5..28e43b3c9388f0 100644 --- a/homeassistant/components/smtp/config_flow.py +++ b/homeassistant/components/smtp/config_flow.py @@ -10,6 +10,7 @@ import voluptuous as vol +from homeassistant import data_entry_flow from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.config_entries import ( SOURCE_USER, @@ -59,11 +60,28 @@ DEFAULT_TIMEOUT, DOMAIN, ENCRYPTION_OPTIONS, + SECTION_OPTIONS, SUBENTRY_TYPE_RECIPIENT, ) _LOGGER = logging.getLogger(__name__) +OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.All( + NumberSelector( + NumberSelectorConfig( + min=1, + max=1800, + step=1, + unit_of_measurement=UnitOfTime.SECONDS, + mode=NumberSelectorMode.BOX, + ) + ), + vol.Coerce(int), + ) + } +) STEP_USER_DATA_SCHEMA = vol.Schema( { @@ -115,23 +133,6 @@ } ) -OPTIONS_SCHEMA = vol.Schema( - { - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.All( - NumberSelector( - NumberSelectorConfig( - min=1, - max=1800, - step=1, - unit_of_measurement=UnitOfTime.SECONDS, - mode=NumberSelectorMode.BOX, - ) - ), - vol.Coerce(int), - ) - } -) - class MailConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for SMTP.""" @@ -166,16 +167,29 @@ async def async_step_user( CONF_USERNAME: user_input.get(CONF_USERNAME), } ) - errors = await self.hass.async_add_executor_job(validate_input, user_input) + entry_data = user_input.copy() + options = entry_data.pop(SECTION_OPTIONS) + errors = await self.hass.async_add_executor_job( + validate_input, entry_data, options + ) if not errors: return self.async_create_entry( - title=user_input.get(CONF_SENDER_NAME, user_input[CONF_SENDER]), - data=user_input, + title=entry_data.get(CONF_SENDER_NAME, entry_data[CONF_SENDER]), + data=entry_data, + options=options, ) return self.async_show_form( step_id="user", data_schema=self.add_suggested_values_to_schema( - data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input + data_schema=STEP_USER_DATA_SCHEMA.extend( + { + vol.Required(SECTION_OPTIONS): data_entry_flow.section( + OPTIONS_SCHEMA, + {"collapsed": True}, + ), + } + ), + suggested_values=user_input, ), errors=errors, ) @@ -209,7 +223,9 @@ async def async_step_reconfigure( CONF_USERNAME: user_input.get(CONF_USERNAME), } ) - errors = await self.hass.async_add_executor_job(validate_input, user_input) + errors = await self.hass.async_add_executor_job( + validate_input, user_input, dict(entry.options) + ) if not errors: return self.async_update_and_abort( entry, @@ -240,7 +256,7 @@ async def async_step_reauth_confirm( if user_input is not None: errors = await self.hass.async_add_executor_job( - validate_input, {**entry.data, **user_input} + validate_input, {**entry.data, **user_input}, dict(entry.options) ) if not errors: return self.async_update_and_abort( @@ -263,7 +279,9 @@ async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResu options = {CONF_TIMEOUT: import_info.pop(CONF_TIMEOUT, DEFAULT_TIMEOUT)} self._async_abort_entries_match(import_info) - errors = await self.hass.async_add_executor_job(validate_input, import_info) + errors = await self.hass.async_add_executor_job( + validate_input, import_info, options + ) if not errors: title = ( import_info.get(CONF_NAME) @@ -288,7 +306,9 @@ async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResu return self.async_abort(reason=errors["base"]) -def validate_input(user_input: dict[str, Any]) -> dict[str, str]: +def validate_input( + user_input: dict[str, Any], options: dict[str, Any] +) -> dict[str, str]: """Validate the user input allows us to connect.""" errors: dict[str, str] = {} ssl_context = create_client_context() if user_input[CONF_VERIFY_SSL] else None @@ -298,12 +318,14 @@ def validate_input(user_input: dict[str, Any]) -> dict[str, str]: mail = SMTP_SSL( user_input[CONF_SERVER], user_input[CONF_PORT], - timeout=DEFAULT_TIMEOUT, + timeout=options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), context=ssl_context, ) else: mail = SMTP( - user_input[CONF_SERVER], user_input[CONF_PORT], timeout=DEFAULT_TIMEOUT + user_input[CONF_SERVER], + user_input[CONF_PORT], + timeout=options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), ) mail.ehlo_or_helo_if_needed() if user_input[CONF_ENCRYPTION] == "starttls": diff --git a/homeassistant/components/smtp/const.py b/homeassistant/components/smtp/const.py index dc9fccd3d5ab11..78fb8d99cf2720 100644 --- a/homeassistant/components/smtp/const.py +++ b/homeassistant/components/smtp/const.py @@ -11,6 +11,7 @@ CONF_ENCRYPTION: Final = "encryption" CONF_SERVER: Final = "server" CONF_SENDER_NAME: Final = "sender_name" +SECTION_OPTIONS: Final = "options" DEFAULT_HOST: Final = "localhost" DEFAULT_PORT: Final = 587 diff --git a/homeassistant/components/smtp/strings.json b/homeassistant/components/smtp/strings.json index 9908b7c9f521c3..c48c4798e42924 100644 --- a/homeassistant/components/smtp/strings.json +++ b/homeassistant/components/smtp/strings.json @@ -67,6 +67,17 @@ "server": "Hostname or IP address of the SMTP server. For example, `smtp.example.com`.", "username": "Username used to authenticate with the SMTP server.", "verify_ssl": "Enable certificate verification for secure SSL/TLS connections." + }, + "sections": { + "options": { + "data": { + "timeout": "[%key:component::smtp::options::step::init::data::timeout%]" + }, + "data_description": { + "timeout": "[%key:component::smtp::options::step::init::data_description::timeout%]" + }, + "name": "Additional options" + } } } } diff --git a/homeassistant/components/sonos/button.py b/homeassistant/components/sonos/button.py new file mode 100644 index 00000000000000..c286a9363c9f67 --- /dev/null +++ b/homeassistant/components/sonos/button.py @@ -0,0 +1,50 @@ +"""Button entities for Sonos.""" + +from typing import override + +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import SONOS_CREATE_BUTTON +from .entity import SonosEntity +from .helpers import SonosConfigEntry +from .speaker import SonosSpeaker + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SonosConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Sonos button entities from a config entry.""" + + @callback + def async_create_entities(speaker: SonosSpeaker) -> None: + """Handle device discovery and create button entities.""" + async_add_entities([SonosCancelAnnouncementButton(speaker, config_entry)]) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, SONOS_CREATE_BUTTON, async_create_entities) + ) + + +class SonosCancelAnnouncementButton(SonosEntity, ButtonEntity): + """Button to cancel the current Sonos announcement.""" + + _attr_translation_key = "cancel_announcement" + + def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None: + """Initialize the cancel announcement button.""" + super().__init__(speaker, config_entry) + self._attr_unique_id = f"{self.soco.uid}-cancel_announcement" + + @override + async def _async_fallback_poll(self) -> None: + """No-op: button state does not need polling.""" + + @override + async def async_press(self) -> None: + """Cancel the current announcement audio clip.""" + await self.speaker.async_cancel_announcement() diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 3142e72e685409..07d7d11ea4611d 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -11,6 +11,7 @@ DATA_SONOS_DISCOVERY_MANAGER = "sonos_discovery_manager" PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.MEDIA_PLAYER, Platform.NUMBER, Platform.SELECT, @@ -159,6 +160,7 @@ SONOS_CHECK_ACTIVITY = "sonos_check_activity" SONOS_CREATE_ALARM = "sonos_create_alarm" +SONOS_CREATE_BUTTON = "sonos_create_button" SONOS_CREATE_AUDIO_FORMAT_SENSOR = "sonos_create_audio_format_sensor" SONOS_CREATE_BATTERY = "sonos_create_battery" SONOS_CREATE_FAVORITES_SENSOR = "sonos_create_favorites_sensor" diff --git a/homeassistant/components/sonos/helpers.py b/homeassistant/components/sonos/helpers.py index 2b4df23b4cccb7..db7229c2e15c01 100644 --- a/homeassistant/components/sonos/helpers.py +++ b/homeassistant/components/sonos/helpers.py @@ -16,7 +16,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import dispatcher_send -from .const import SONOS_SPEAKER_ACTIVITY +from .const import DOMAIN, SONOS_SPEAKER_ACTIVITY from .exception import SonosUpdateError if TYPE_CHECKING: @@ -30,6 +30,8 @@ UID_PREFIX = "RINCON_" UID_POSTFIX = "01400" +UPNP_ERROR_COMMAND_FAILED = "800" + _LOGGER = logging.getLogger(__name__) type _SonosEntitiesType = ( @@ -76,8 +78,24 @@ def wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> _R | None: if (target := _find_target_identifier(self, args_soco)) is None: raise RuntimeError("Unexpected use of soco_error") from err - message = f"Error calling {function} on {target}: {err}" - raise SonosUpdateError(message) from err + translation_key = "call_failed" + placeholders = { + "target": target, + "error": str(err), + } + + if error_code is not None: + translation_key = "upnp_call_failed" + placeholders["error_code"] = str(error_code) + + if str(error_code) == UPNP_ERROR_COMMAND_FAILED: + translation_key = "upnp_call_failed_music_service_unavailable" + + raise SonosUpdateError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders=placeholders, + ) from err dispatch_soco = args_soco or self.soco # type: ignore[union-attr] dispatcher_send( diff --git a/homeassistant/components/sonos/icons.json b/homeassistant/components/sonos/icons.json index e28e4c305a9908..2c16c854be9a80 100644 --- a/homeassistant/components/sonos/icons.json +++ b/homeassistant/components/sonos/icons.json @@ -5,6 +5,11 @@ "default": "mdi:microphone" } }, + "button": { + "cancel_announcement": { + "default": "mdi:cancel" + } + }, "sensor": { "audio_input_format": { "default": "mdi:import" diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index d1f5fb1b2cc430..94de448dc6ae84 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -17,6 +17,7 @@ from soco.data_structures import DidlFavorite, DidlMusicTrack from soco.exceptions import SoCoException from soco.ms_data_structures import MusicServiceItem +from sonos_websocket import CLIP_ID_KEY from sonos_websocket.exception import SonosWebsocketError from homeassistant.components import media_source, spotify @@ -528,8 +529,9 @@ async def async_play_media( ) _LOGGER.debug("Playing %s using websocket audioclip", media_id) try: + self.speaker.last_announce_id = None assert self.speaker.websocket - response, _ = await self.speaker.websocket.play_clip( + response, data = await self.speaker.websocket.play_clip( async_process_play_media_url(self.hass, media_id), volume=volume, ) @@ -538,6 +540,8 @@ async def async_play_media( f"Error when calling Sonos websocket: {exc}" ) from exc if response.get("success"): + if data: + self.speaker.last_announce_id = data.get(CLIP_ID_KEY) return if response.get("type") in ANNOUNCE_NOT_SUPPORTED_ERRORS: # If the speaker does not support announce do not raise and diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index f5520449115692..24cd43bbcb3e8d 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -17,10 +17,11 @@ from soco.plugins.sharelink import ShareLinkPlugin from soco.snapshot import Snapshot from sonos_websocket import SonosWebsocket +from sonos_websocket.exception import SonosWebsocketError from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import ( @@ -44,6 +45,7 @@ SONOS_CREATE_ALARM, SONOS_CREATE_AUDIO_FORMAT_SENSOR, SONOS_CREATE_BATTERY, + SONOS_CREATE_BUTTON, SONOS_CREATE_LEVELS, SONOS_CREATE_MEDIA_PLAYER, SONOS_CREATE_MIC_SENSOR, @@ -186,6 +188,9 @@ def __init__( self.snapshot_group: list[SonosSpeaker] = [] self._group_members_missing: set[str] = set() + # Announcement tracking + self.last_announce_id: str | None = None + async def async_setup( self, entry: SonosConfigEntry, @@ -261,6 +266,7 @@ def setup(self, entry: SonosConfigEntry) -> None: dispatches.append((SONOS_CREATE_SELECTS, self)) dispatches.append((SONOS_CREATE_SWITCHES, self)) + dispatches.append((SONOS_CREATE_BUTTON, self)) dispatches.append((SONOS_CREATE_MEDIA_PLAYER, self)) dispatches.append((SONOS_SPEAKER_ADDED, self.soco.uid)) @@ -1294,6 +1300,35 @@ def _test_groups(groups: list[list[SonosSpeaker]]) -> bool: any_speaker = next(iter(config_entry.runtime_data.discovered.values())) any_speaker.soco.zone_group_state.clear_cache() + async def async_cancel_announcement(self) -> None: + """Cancel the current announcement audio clip.""" + if self.last_announce_id is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="cancel_announcement_no_id", + ) + if not self.websocket: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="announcement_connection_error", + translation_placeholders={"error": "websocket not available"}, + ) + try: + response, _ = await self.websocket.cancel_clip(self.last_announce_id) + except SonosWebsocketError as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="announcement_connection_error", + translation_placeholders={"error": str(exc)}, + ) from exc + if not response.get("success"): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cancel_announcement_error", + translation_placeholders={"response": str(response)}, + ) + self.last_announce_id = None + # # Media and playback state handlers # diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index f2e01da70fa3ac..82c99a7e1d2336 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -18,6 +18,11 @@ "name": "Microphone" } }, + "button": { + "cancel_announcement": { + "name": "Cancel announcement" + } + }, "number": { "audio_delay": { "name": "Audio delay" @@ -109,6 +114,18 @@ "announce_media_error": { "message": "Announcing clip {media_id} failed {response}" }, + "announcement_connection_error": { + "message": "Failed to reach Sonos speaker for announcement: {error}" + }, + "call_failed": { + "message": "Error on {target}: {error}" + }, + "cancel_announcement_error": { + "message": "Cancelling announcement failed: {response}" + }, + "cancel_announcement_no_id": { + "message": "No active announcement to cancel" + }, "entity_not_found": { "message": "Entity {entity_id} not found." }, @@ -141,6 +158,12 @@ }, "toggle_failed": { "message": "Could not toggle {entity_id}." + }, + "upnp_call_failed": { + "message": "Error on {target} (UPnP error code {error_code}): {error}" + }, + "upnp_call_failed_music_service_unavailable": { + "message": "Error on {target} (UPnP error code {error_code}): {error}. This may indicate the selected music service is not available on the speaker." } }, "issues": { diff --git a/homeassistant/components/starline/config_flow.py b/homeassistant/components/starline/config_flow.py index 80fa4d9f8af2df..f7cda744db7f5e 100644 --- a/homeassistant/components/starline/config_flow.py +++ b/homeassistant/components/starline/config_flow.py @@ -1,6 +1,6 @@ """Config flow to configure StarLine component.""" -from typing import override +from typing import TYPE_CHECKING, override from starline import StarlineAuth import voluptuous as vol @@ -192,13 +192,18 @@ async def _async_authenticate_app( ) -> ConfigFlowResult: """Authenticate application.""" try: - self._app_code = await self.hass.async_add_executor_job( - self._auth.get_app_code, self._app_id, self._app_secret - ) - # pylint: disable-next=home-assistant-sequential-executor-jobs - self._app_token = await self.hass.async_add_executor_job( - self._auth.get_app_token, self._app_id, self._app_secret, self._app_code - ) + + def _get_app_token() -> str: + if TYPE_CHECKING: + assert self._app_id is not None + assert self._app_secret is not None + + app_code = self._auth.get_app_code(self._app_id, self._app_secret) + return self._auth.get_app_token( + self._app_id, self._app_secret, app_code + ) + + self._app_token = await self.hass.async_add_executor_job(_get_app_token) return self._async_form_auth_user(error) except Exception as err: # noqa: BLE001 _LOGGER.error("Error auth StarLine: %s", err) diff --git a/homeassistant/components/subaru/button.py b/homeassistant/components/subaru/button.py index 24ea65eb465d19..2e77211698ce25 100644 --- a/homeassistant/components/subaru/button.py +++ b/homeassistant/components/subaru/button.py @@ -10,15 +10,14 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import get_device_info from .const import ( SERVICE_REMOTE_START, SERVICE_REMOTE_STOP, VEHICLE_HAS_EV, VEHICLE_HAS_REMOTE_START, - VEHICLE_VIN, ) from .coordinator import SubaruConfigEntry, SubaruDataUpdateCoordinator +from .entity import SubaruEntity from .remote_service import async_call_remote_service @@ -59,10 +58,9 @@ async def async_setup_entry( ) -class SubaruButton(ButtonEntity): +class SubaruButton(SubaruEntity, ButtonEntity): """Class for a Subaru button.""" - _attr_has_entity_name = True entity_description: SubaruButtonEntityDescription def __init__( @@ -73,13 +71,10 @@ def __init__( description: SubaruButtonEntityDescription, ) -> None: """Initialize the button for the vehicle.""" + super().__init__(vehicle_info, description.key) self.controller = controller self.coordinator = coordinator - self.vehicle_info = vehicle_info self.entity_description = description - vin = vehicle_info[VEHICLE_VIN] - self._attr_unique_id = f"{vin}_{description.key}" - self._attr_device_info = get_device_info(vehicle_info) @override async def async_press(self) -> None: diff --git a/homeassistant/components/subaru/device_tracker.py b/homeassistant/components/subaru/device_tracker.py index 9ea7929b5dcfbf..f31ac633893bb2 100644 --- a/homeassistant/components/subaru/device_tracker.py +++ b/homeassistant/components/subaru/device_tracker.py @@ -7,11 +7,10 @@ from homeassistant.components.device_tracker import TrackerEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import get_device_info -from .const import VEHICLE_HAS_REMOTE_SERVICE, VEHICLE_STATUS, VEHICLE_VIN +from .const import VEHICLE_HAS_REMOTE_SERVICE, VEHICLE_STATUS from .coordinator import SubaruConfigEntry, SubaruDataUpdateCoordinator +from .entity import SubaruCoordinatorEntity async def async_setup_entry( @@ -29,23 +28,17 @@ async def async_setup_entry( ) -class SubaruDeviceTracker( - CoordinatorEntity[SubaruDataUpdateCoordinator], TrackerEntity -): +class SubaruDeviceTracker(SubaruCoordinatorEntity, TrackerEntity): """Class for Subaru device tracker.""" _attr_translation_key = "location" - _attr_has_entity_name = True _attr_name = None def __init__( self, vehicle_info: dict, coordinator: SubaruDataUpdateCoordinator ) -> None: """Initialize the device tracker.""" - super().__init__(coordinator) - self.vin = vehicle_info[VEHICLE_VIN] - self._attr_device_info = get_device_info(vehicle_info) - self._attr_unique_id = f"{self.vin}_location" + super().__init__(vehicle_info, coordinator, "location") @property @override @@ -72,8 +65,8 @@ def longitude(self) -> float | None: @property @override def available(self) -> bool: - """Return if entity is available.""" - if vehicle_data := self.coordinator.data.get(self.vin): - if status := vehicle_data.get(VEHICLE_STATUS): - return status.keys() & {LATITUDE, LONGITUDE, TIMESTAMP} - return False + """Return if available; not gated on last_update_success, only on the relevant status keys being present.""" + if not (vehicle_data := (self.coordinator.data or {}).get(self.vin)): + return False + status = vehicle_data.get(VEHICLE_STATUS) or {} + return bool(status.keys() & {LATITUDE, LONGITUDE, TIMESTAMP}) diff --git a/homeassistant/components/subaru/entity.py b/homeassistant/components/subaru/entity.py new file mode 100644 index 00000000000000..a9e4ff1546158f --- /dev/null +++ b/homeassistant/components/subaru/entity.py @@ -0,0 +1,45 @@ +"""Base entities for the Subaru integration.""" + +from typing import Any, override + +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import get_device_info +from .const import VEHICLE_VIN +from .coordinator import SubaruDataUpdateCoordinator + + +class SubaruEntity(Entity): + """Base class for Subaru entities: device_info, unique_id, has_entity_name.""" + + _attr_has_entity_name = True + + def __init__(self, vehicle_info: dict[str, Any], unique_id_suffix: str) -> None: + """Initialize the entity from the vehicle_info dict.""" + self.vehicle_info = vehicle_info + self.vin: str = vehicle_info[VEHICLE_VIN] + self._attr_device_info = get_device_info(vehicle_info) + self._attr_unique_id = f"{self.vin}_{unique_id_suffix}" + + +class SubaruCoordinatorEntity( + CoordinatorEntity[SubaruDataUpdateCoordinator], SubaruEntity +): + """Base class for coordinator-backed Subaru entities.""" + + def __init__( + self, + vehicle_info: dict[str, Any], + coordinator: SubaruDataUpdateCoordinator, + unique_id_suffix: str, + ) -> None: + """Initialize the coordinator-backed entity.""" + super().__init__(coordinator) + SubaruEntity.__init__(self, vehicle_info, unique_id_suffix) + + @property + @override + def available(self) -> bool: + """Return if available; also gates on data for this vehicle being present.""" + return super().available and self.vin in self.coordinator.data diff --git a/homeassistant/components/subaru/lock.py b/homeassistant/components/subaru/lock.py index 62547ee51e5b45..362e3ebe4c3de6 100644 --- a/homeassistant/components/subaru/lock.py +++ b/homeassistant/components/subaru/lock.py @@ -11,7 +11,6 @@ from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import get_device_info from .const import ( ATTR_DOOR, SERVICE_UNLOCK_SPECIFIC_DOOR, @@ -19,9 +18,9 @@ UNLOCK_VALID_DOORS, VEHICLE_HAS_REMOTE_SERVICE, VEHICLE_NAME, - VEHICLE_VIN, ) from .coordinator import SubaruConfigEntry +from .entity import SubaruEntity from .remote_service import async_call_remote_service _LOGGER = logging.getLogger(__name__) @@ -50,7 +49,7 @@ async def async_setup_entry( ) -class SubaruLock(LockEntity): +class SubaruLock(SubaruEntity, LockEntity): """Representation of a Subaru door lock. Note that the Subaru API currently does not support @@ -58,17 +57,13 @@ class SubaruLock(LockEntity): always unknown. """ - _attr_has_entity_name = True _attr_translation_key = "door_locks" def __init__(self, vehicle_info, controller): """Initialize the locks for the vehicle.""" + super().__init__(vehicle_info, "door_locks") self.controller = controller - self.vehicle_info = vehicle_info - vin = vehicle_info[VEHICLE_VIN] self.car_name = vehicle_info[VEHICLE_NAME] - self._attr_unique_id = f"{vin}_door_locks" - self._attr_device_info = get_device_info(vehicle_info) @override async def async_lock(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/subaru/sensor.py b/homeassistant/components/subaru/sensor.py index 1a49fbba510dab..4fff8efb10baeb 100644 --- a/homeassistant/components/subaru/sensor.py +++ b/homeassistant/components/subaru/sensor.py @@ -27,11 +27,9 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.unit_conversion import DistanceConverter, VolumeConverter from homeassistant.util.unit_system import METRIC_SYSTEM -from . import get_device_info from .const import ( API_GEN_2, API_GEN_3, @@ -42,9 +40,9 @@ VEHICLE_HAS_EV, VEHICLE_HEALTH, VEHICLE_STATUS, - VEHICLE_VIN, ) from .coordinator import SubaruConfigEntry, SubaruDataUpdateCoordinator +from .entity import SubaruCoordinatorEntity _LOGGER = logging.getLogger(__name__) @@ -260,10 +258,9 @@ def create_vehicle_sensors( ] -class SubaruSensor(CoordinatorEntity[SubaruDataUpdateCoordinator], SensorEntity): +class SubaruSensor(SubaruCoordinatorEntity, SensorEntity): """Class for Subaru sensors.""" - _attr_has_entity_name = True entity_description: SubaruSensorEntityDescription def __init__( @@ -273,11 +270,8 @@ def __init__( description: SubaruSensorEntityDescription, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator) - self.vin = vehicle_info[VEHICLE_VIN] + super().__init__(vehicle_info, coordinator, description.key) self.entity_description = description - self._attr_device_info = get_device_info(vehicle_info) - self._attr_unique_id = f"{self.vin}_{description.key}" @property @override @@ -312,15 +306,6 @@ def native_unit_of_measurement(self) -> str | None: return FUEL_CONSUMPTION_LITERS_PER_HUNDRED_KILOMETERS return self.entity_description.native_unit_of_measurement - @property - @override - def available(self) -> bool: - """Return if entity is available.""" - last_update_success = super().available - if last_update_success and self.vin not in self.coordinator.data: - return False - return last_update_success - async def _async_migrate_entries( hass: HomeAssistant, config_entry: ConfigEntry diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index b4bacac0192df3..9b66204c64ea64 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -302,18 +302,18 @@ class ProtectBinaryEventEntityDescription( ProtectBinaryEntityDescription( key="dark", translation_key="is_dark", - ufp_value="is_dark", + ufp_public_value="is_dark", ), ProtectBinaryEntityDescription( key="motion", device_class=BinarySensorDeviceClass.MOTION, - ufp_value="is_pir_motion_detected", + ufp_public_value="is_pir_motion_detected", ), ProtectBinaryEntityDescription( key="light", translation_key="flood_light", entity_category=EntityCategory.DIAGNOSTIC, - ufp_value="is_light_on", + ufp_public_value="is_light_on", ufp_perm=PermRequired.NO_WRITE, ), ProtectBinaryEntityDescription( @@ -328,7 +328,7 @@ class ProtectBinaryEventEntityDescription( key="status_light", translation_key="status_light", entity_category=EntityCategory.DIAGNOSTIC, - ufp_value="light_device_settings.is_indicator_enabled", + ufp_public_value="light_device_settings.is_indicator_enabled", ufp_perm=PermRequired.NO_WRITE, ), ) diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index 1dd90b65079d09..e5e42b5bb65ef1 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -1,10 +1,11 @@ """Component providing Lights for UniFi Protect.""" import logging -from typing import Any, override +from typing import Any, cast, override from uiprotect.data import Light, ModelType, ProtectAdoptableDeviceModel from uiprotect.data.devices import LightDeviceSettings +from uiprotect.data.public_devices import PublicLight from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.core import HomeAssistant, callback @@ -61,14 +62,29 @@ class ProtectLight(ProtectDeviceEntity, LightEntity): _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() + @callback @override def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: super()._async_update_device_from_protect(device) - updated_device = self.device - self._attr_is_on = updated_device.is_light_on - self._attr_brightness = unifi_brightness_to_hass( - updated_device.light_device_settings.led_level + if (public := self._ufp_public_obj) is None: + return + light = cast(PublicLight, public) + self._attr_is_on = light.is_light_on + led_level = light.light_device_settings.led_level + self._attr_brightness = ( + None if led_level is None else unifi_brightness_to_hass(led_level) ) @async_ufp_instance_command diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index fd95c888b16ed2..2f7cf224e209f5 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -173,8 +173,8 @@ async def _set_chime_volume(obj: Chime, value: float) -> None: ufp_min=0, ufp_max=100, ufp_step=1, - ufp_value="light_device_settings.pir_sensitivity", - ufp_set_method="set_sensitivity", + ufp_public_value="light_device_settings.pir_sensitivity", + ufp_set_method="set_sensitivity_public", ufp_perm=PermRequired.WRITE, ), ProtectNumberEntityDescription[Light]( diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 7789a3face2ff5..888267d346a523 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -51,7 +51,7 @@ async_all_device_entities, async_remove_unsupported_sense_entities, ) -from .utils import async_get_light_motion_current, async_ufp_instance_command +from .utils import async_get_light_motion_current_public, async_ufp_instance_command _LOGGER = logging.getLogger(__name__) _KEY_LIGHT_MOTION = "light_motion" @@ -173,7 +173,7 @@ def _get_doorbell_current(obj: Camera) -> str | None: async def _set_light_mode(obj: Light, mode: str) -> None: lightmode, timing = LIGHT_MODE_TO_SETTINGS[mode] - await obj.set_light_settings( + await obj.set_light_mode_public( LightModeType(lightmode), enable_at=None if timing is None else LightModeEnableType(timing), ) @@ -308,7 +308,7 @@ async def _set_hdr_mode(obj: Camera, mode: str) -> None: translation_key="light_mode", entity_category=EntityCategory.CONFIG, ufp_options=MOTION_MODE_TO_LIGHT_MODE, - ufp_value_fn=async_get_light_motion_current, + ufp_public_value_fn=async_get_light_motion_current_public, ufp_set_method_fn=_set_light_mode, ufp_perm=PermRequired.WRITE, ), diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 49d1e8a3fd32ea..cb3896cbd2c4ea 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -5,7 +5,7 @@ from datetime import datetime from functools import partial import logging -from typing import Any, override +from typing import Any, cast, override from uiprotect.data import ( NVR, @@ -16,7 +16,12 @@ ProtectDeviceModel, Sensor, ) -from uiprotect.data.public_devices import SensorFeatureCapability +from uiprotect.data.public_devices import ( + PublicDeviceModel, + PublicLight, + SensorFeatureCapability, +) +from uiprotect.utils import convert_to_datetime from homeassistant.components.sensor import ( SensorDeviceClass, @@ -52,7 +57,7 @@ async_all_device_entities, async_remove_unsupported_sense_entities, ) -from .utils import async_get_light_motion_current +from .utils import async_get_light_motion_current_public _LOGGER = logging.getLogger(__name__) OBJECT_TYPE_NONE = "none" @@ -90,6 +95,11 @@ class ProtectSensorEventEntityDescription( """Describes UniFi Protect Sensor entity.""" +def _get_last_motion_public(obj: PublicDeviceModel) -> datetime | None: + # Public API reports last motion as a JS epoch (ms); private side a datetime. + return convert_to_datetime(cast(PublicLight, obj).last_motion) + + def _get_uptime(obj: ProtectDeviceModel) -> datetime | None: if obj.up_since is None: return None @@ -508,7 +518,7 @@ def _get_alarm_sound(obj: Sensor) -> str: key="motion_last_trip_time", translation_key="last_motion_detected", device_class=SensorDeviceClass.TIMESTAMP, - ufp_value="last_motion", + ufp_public_value_fn=_get_last_motion_public, entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( @@ -516,14 +526,14 @@ def _get_alarm_sound(obj: Sensor) -> str: translation_key="motion_sensitivity", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, - ufp_value="light_device_settings.pir_sensitivity", + ufp_public_value="light_device_settings.pir_sensitivity", ufp_perm=PermRequired.NO_WRITE, ), ProtectSensorEntityDescription[Light]( key="light_motion", translation_key="light_mode", entity_category=EntityCategory.DIAGNOSTIC, - ufp_value_fn=async_get_light_motion_current, + ufp_public_value_fn=async_get_light_motion_current_public, ufp_perm=PermRequired.NO_WRITE, ), ProtectSensorEntityDescription( diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index f7f664c9d92cab..54812f75882ea9 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -387,8 +387,8 @@ async def _set_hdr(obj: Camera, value: bool) -> None: key="status_light", translation_key="status_light", entity_category=EntityCategory.CONFIG, - ufp_value="light_device_settings.is_indicator_enabled", - ufp_set_method="set_status_light", + ufp_public_value="light_device_settings.is_indicator_enabled", + ufp_set_method="set_status_light_public", ufp_perm=PermRequired.WRITE, ), ) diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index 933c0f9b6e8d1d..fc411102f6073e 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -5,18 +5,18 @@ from functools import wraps from pathlib import Path import socket -from typing import TYPE_CHECKING, Any, Concatenate +from typing import TYPE_CHECKING, Any, Concatenate, cast from aiohttp import CookieJar from uiprotect import ProtectApiClient from uiprotect.data import ( Bootstrap, ChannelQuality, - Light, LightModeEnableType, LightModeType, ProtectAdoptableDeviceModel, ) +from uiprotect.data.public_devices import PublicDeviceModel, PublicLight from uiprotect.exceptions import ClientError, NotAuthorized from homeassistant.const import ( @@ -95,15 +95,14 @@ def async_get_devices( @callback -def async_get_light_motion_current(obj: Light) -> str: - """Get light motion mode for Flood Light.""" - - if ( - obj.light_mode_settings.mode is LightModeType.MOTION - and obj.light_mode_settings.enable_at is LightModeEnableType.DARK - ): +def async_get_light_motion_current_public(obj: PublicDeviceModel) -> str | None: + """Get light motion mode for a Flood Light from the public API.""" + settings = cast(PublicLight, obj).light_mode_settings + if (mode := settings.mode) is None: + return None + if mode is LightModeType.MOTION and settings.enable_at is LightModeEnableType.DARK: return f"{LightModeType.MOTION.value}_dark" - return obj.light_mode_settings.mode.value + return mode.value @callback diff --git a/homeassistant/components/vibration/__init__.py b/homeassistant/components/vibration/__init__.py new file mode 100644 index 00000000000000..b361746282f37d --- /dev/null +++ b/homeassistant/components/vibration/__init__.py @@ -0,0 +1,15 @@ +"""Integration for vibration triggers.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +DOMAIN = "vibration" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + +__all__ = [] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the component.""" + return True diff --git a/homeassistant/components/vibration/icons.json b/homeassistant/components/vibration/icons.json new file mode 100644 index 00000000000000..009711fd1655a2 --- /dev/null +++ b/homeassistant/components/vibration/icons.json @@ -0,0 +1,10 @@ +{ + "triggers": { + "cleared": { + "trigger": "mdi:vibrate-off" + }, + "detected": { + "trigger": "mdi:vibrate" + } + } +} diff --git a/homeassistant/components/vibration/manifest.json b/homeassistant/components/vibration/manifest.json new file mode 100644 index 00000000000000..e875b7c6c583b3 --- /dev/null +++ b/homeassistant/components/vibration/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "vibration", + "name": "Vibration", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/vibration", + "integration_type": "system", + "quality_scale": "internal" +} diff --git a/homeassistant/components/vibration/strings.json b/homeassistant/components/vibration/strings.json new file mode 100644 index 00000000000000..3e7d47b8dbfd55 --- /dev/null +++ b/homeassistant/components/vibration/strings.json @@ -0,0 +1,33 @@ +{ + "common": { + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" + }, + "title": "Vibration", + "triggers": { + "cleared": { + "description": "Triggers when one or more vibration sensors stop detecting vibration.", + "fields": { + "behavior": { + "name": "[%key:component::vibration::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::vibration::common::trigger_for_name%]" + } + }, + "name": "Vibration cleared" + }, + "detected": { + "description": "Triggers when one or more vibration sensors start detecting vibration.", + "fields": { + "behavior": { + "name": "[%key:component::vibration::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::vibration::common::trigger_for_name%]" + } + }, + "name": "Vibration detected" + } + } +} diff --git a/homeassistant/components/vibration/trigger.py b/homeassistant/components/vibration/trigger.py new file mode 100644 index 00000000000000..a23a6240166005 --- /dev/null +++ b/homeassistant/components/vibration/trigger.py @@ -0,0 +1,24 @@ +"""Provides triggers for vibration.""" + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger + +VIBRATION_DOMAIN_SPECS: dict[str, DomainSpec] = { + BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.VIBRATION), +} + +TRIGGERS: dict[str, type[Trigger]] = { + "detected": make_entity_target_state_trigger(VIBRATION_DOMAIN_SPECS, STATE_ON), + "cleared": make_entity_target_state_trigger(VIBRATION_DOMAIN_SPECS, STATE_OFF), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for vibration.""" + return TRIGGERS diff --git a/homeassistant/components/vibration/triggers.yaml b/homeassistant/components/vibration/triggers.yaml new file mode 100644 index 00000000000000..0957393172c3a9 --- /dev/null +++ b/homeassistant/components/vibration/triggers.yaml @@ -0,0 +1,26 @@ +.trigger_common_fields: &trigger_common_fields + behavior: + required: true + default: each + selector: + automation_behavior: + mode: trigger + for: + required: true + default: 00:00:00 + selector: + duration: + +detected: + fields: *trigger_common_fields + target: + entity: + - domain: binary_sensor + device_class: vibration + +cleared: + fields: *trigger_common_fields + target: + entity: + - domain: binary_sensor + device_class: vibration diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index e7df831bb7d75d..9bc5b73e7e9cd9 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -332,29 +332,29 @@ def native_value(self) -> StateType | str: return self.entity_description.value_fn(self._appliance) -class WasherDryerTimeSensorBase(WhirlpoolEntity, RestoreSensor, ABC): - """Abstract base class for Whirlpool washer/dryer time sensors.""" +class WhirlpoolTimeSensorBase(WhirlpoolEntity, RestoreSensor, ABC): + """Abstract base class for Whirlpool end-time timestamp sensors.""" _attr_should_poll = True - _appliance: Washer | Dryer - def __init__( - self, appliance: Washer | Dryer, description: SensorEntityDescription - ) -> None: - """Initialize the washer/dryer sensor.""" - super().__init__(appliance, unique_id_suffix=f"-{description.key}") - self.entity_description = description + def __init__(self, appliance: Appliance, unique_id_suffix: str) -> None: + """Initialize the time sensor.""" + super().__init__(appliance, unique_id_suffix=unique_id_suffix) self._running: bool | None = None self._value: datetime | None = None @abstractmethod - def _is_machine_state_finished(self) -> bool: - """Return true if the machine is in a finished state.""" + def _is_finished(self) -> bool: + """Return true if the timer/cycle is in a finished state.""" + + @abstractmethod + def _is_running(self) -> bool: + """Return true if the timer/cycle is in a running state.""" @abstractmethod - def _is_machine_state_running(self) -> bool: - """Return true if the machine is in a running state.""" + def _get_seconds_remaining(self) -> int: + """Return the number of seconds remaining.""" @override async def async_added_to_hass(self) -> None: @@ -368,21 +368,19 @@ async def async_update(self) -> None: """Update status of Whirlpool.""" await self._appliance.fetch_data() - @override @property + @override def native_value(self) -> datetime | None: """Calculate the time stamp for completion.""" now = utcnow() - if self._is_machine_state_finished() and self._running: + if self._is_finished() and self._running: self._running = False self._value = now - if self._is_machine_state_running(): + if self._is_running(): self._running = True - new_timestamp = now + timedelta( - seconds=self._appliance.get_time_remaining() - ) + new_timestamp = now + timedelta(seconds=self._get_seconds_remaining()) if self._value is None or ( isinstance(self._value, datetime) and abs(new_timestamp - self._value) > timedelta(seconds=60) @@ -391,45 +389,59 @@ def native_value(self) -> datetime | None: return self._value -class WasherTimeSensor(WasherDryerTimeSensorBase): +class WasherTimeSensor(WhirlpoolTimeSensorBase): """A timestamp class for Whirlpool washers.""" _appliance: Washer + def __init__(self, appliance: Washer, description: SensorEntityDescription) -> None: + """Initialize the washer sensor.""" + super().__init__(appliance, unique_id_suffix=f"-{description.key}") + self.entity_description = description + @override - def _is_machine_state_finished(self) -> bool: - """Return true if the machine is in a finished state.""" + def _is_finished(self) -> bool: return self._appliance.get_machine_state() in { WasherMachineState.Complete, WasherMachineState.Standby, } @override - def _is_machine_state_running(self) -> bool: - """Return true if the machine is in a running state.""" + def _is_running(self) -> bool: return ( self._appliance.get_machine_state() is WasherMachineState.RunningMainCycle ) + @override + def _get_seconds_remaining(self) -> int: + return self._appliance.get_time_remaining() -class DryerTimeSensor(WasherDryerTimeSensorBase): + +class DryerTimeSensor(WhirlpoolTimeSensorBase): """A timestamp class for Whirlpool dryers.""" _appliance: Dryer + def __init__(self, appliance: Dryer, description: SensorEntityDescription) -> None: + """Initialize the dryer sensor.""" + super().__init__(appliance, unique_id_suffix=f"-{description.key}") + self.entity_description = description + @override - def _is_machine_state_finished(self) -> bool: - """Return true if the machine is in a finished state.""" + def _is_finished(self) -> bool: return self._appliance.get_machine_state() in { DryerMachineState.Complete, DryerMachineState.Standby, } @override - def _is_machine_state_running(self) -> bool: - """Return true if the machine is in a running state.""" + def _is_running(self) -> bool: return self._appliance.get_machine_state() is DryerMachineState.RunningMainCycle + @override + def _get_seconds_remaining(self) -> int: + return self._appliance.get_time_remaining() + class WhirlpoolOvenCavitySensor(WhirlpoolOvenEntity, SensorEntity): """A class for Whirlpool oven cavity sensors.""" diff --git a/requirements_test.txt b/requirements_test.txt index d6cabd86c687f7..797ef3a7fa8580 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -38,7 +38,7 @@ pytest==9.0.3 requests==2.34.2 requests-mock==1.12.1 respx==0.23.1 -syrupy==5.5.1 +syrupy==5.5.2 tqdm==4.67.1 types-aiofiles==24.1.0.20250822 types-atomicwrites==1.4.5.1 diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 9ca4987719f698..fb23e8b3c9577b 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -126,6 +126,7 @@ class NonScaledQualityScaleTiers(StrEnum): "temperature", "timer", "trace", + "vibration", "web_rtc", "webhook", "websocket_api", diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 0a872ddbb8d546..b04a7c801e1250 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -2076,6 +2076,7 @@ class Rule: "timer", "trace", "usage_prediction", + "vibration", "web_rtc", "webhook", "websocket_api", diff --git a/tests/components/knx/test_date.py b/tests/components/knx/test_date.py index 98e35d16db0f0d..5e25c7f1651c54 100644 --- a/tests/components/knx/test_date.py +++ b/tests/components/knx/test_date.py @@ -5,7 +5,11 @@ DOMAIN as DATE_DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS +from homeassistant.components.knx.const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + KNX_ADDRESS, +) from homeassistant.components.knx.schema import DateSchema from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant, State @@ -92,6 +96,33 @@ async def test_date_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) -> assert state.state == "2024-02-24" +async def test_date_state_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX date with state_address restores state until bus read completes.""" + test_address = "1/1/1" + test_state_address = "2/2/2" + fake_state = State("date.test", "2023-07-24") + mock_restore_cache(hass, (fake_state,)) + + await knx.setup_integration( + { + DateSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_state_address, + } + } + ) + # StateUpdater initialize state - restored value is used before response is received + await knx.assert_read(test_state_address) + state = hass.states.get("date.test") + assert state.state == "2023-07-24" + + # bus reports a different value than restored - state updates to the real value + await knx.receive_response(test_state_address, (0x18, 0x02, 0x18)) + state = hass.states.get("date.test") + assert state.state == "2024-02-24" + + async def test_date_ui_create( hass: HomeAssistant, knx: KNXTestKit, diff --git a/tests/components/knx/test_datetime.py b/tests/components/knx/test_datetime.py index b79e8abe8a6311..e8107b068ef8b2 100644 --- a/tests/components/knx/test_datetime.py +++ b/tests/components/knx/test_datetime.py @@ -5,7 +5,11 @@ DOMAIN as DATETIME_DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS +from homeassistant.components.knx.const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + KNX_ADDRESS, +) from homeassistant.components.knx.schema import DateTimeSchema from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant, State @@ -96,6 +100,36 @@ async def test_date_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) -> assert state.state == "2020-01-01T18:04:05+00:00" +async def test_datetime_state_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX datetime with state_address restores state until bus read completes.""" + await hass.config.async_set_time_zone("Europe/Vienna") + test_address = "1/1/1" + test_state_address = "2/2/2" + fake_state = State("datetime.test", "2022-03-03T03:04:05+00:00") + mock_restore_cache(hass, (fake_state,)) + + await knx.setup_integration( + { + DateTimeSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_state_address, + } + } + ) + # StateUpdater initialize state - restored value is used before response is received + await knx.assert_read(test_state_address) + state = hass.states.get("datetime.test") + assert state.state == "2022-03-03T03:04:05+00:00" + + # bus reports a different value than restored - state updates to the real value + await knx.receive_response( + test_state_address, (0x78, 0x01, 0x01, 0x73, 0x04, 0x05, 0x20, 0x80) + ) + state = hass.states.get("datetime.test") + assert state.state == "2020-01-01T18:04:05+00:00" + + async def test_datetime_ui_create( hass: HomeAssistant, knx: KNXTestKit, diff --git a/tests/components/knx/test_number.py b/tests/components/knx/test_number.py index f4b8856cabe415..e00e3dfe4c44d3 100644 --- a/tests/components/knx/test_number.py +++ b/tests/components/knx/test_number.py @@ -5,7 +5,11 @@ import pytest -from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS +from homeassistant.components.knx.const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + KNX_ADDRESS, +) from homeassistant.components.knx.schema import NumberSchema from homeassistant.const import CONF_NAME, CONF_TYPE, Platform from homeassistant.core import HomeAssistant, State @@ -112,6 +116,43 @@ async def test_number_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) assert state.state == "9000.96" +async def test_number_state_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX number with state_address restores state until bus read completes.""" + test_address = "1/1/1" + test_state_address = "2/2/2" + + RESTORE_DATA = { + "native_max_value": None, # Ignored by KNX number + "native_min_value": None, # Ignored by KNX number + "native_step": None, # Ignored by KNX number + "native_unit_of_measurement": None, # Ignored by KNX number + "native_value": 160.0, + } + mock_restore_cache_with_extra_data( + hass, ((State("number.test", "abc"), RESTORE_DATA),) + ) + + await knx.setup_integration( + { + NumberSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_state_address, + CONF_TYPE: "illuminance", + } + } + ) + # StateUpdater initialize state - restored value is used before response is received + await knx.assert_read(test_state_address) + state = hass.states.get("number.test") + assert state.state == "160.0" + + # bus reports a different value than restored - state updates to the real value + await knx.receive_response(test_state_address, (0x4E, 0xDE)) + state = hass.states.get("number.test") + assert state.state == "9000.96" + + @pytest.mark.parametrize( "attribute_config", [ diff --git a/tests/components/knx/test_select.py b/tests/components/knx/test_select.py index b53dfae2658b8b..3bec54abed1bd9 100644 --- a/tests/components/knx/test_select.py +++ b/tests/components/knx/test_select.py @@ -125,6 +125,40 @@ async def test_select_dpt_2_restore(hass: HomeAssistant, knx: KNXTestKit) -> Non await knx.assert_no_telegram() +async def test_select_state_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX select with state_address restores state until bus read completes.""" + _options = [ + {CONF_PAYLOAD: 0b00, SelectSchema.CONF_OPTION: "No control"}, + {CONF_PAYLOAD: 0b10, SelectSchema.CONF_OPTION: "Control - Off"}, + {CONF_PAYLOAD: 0b11, SelectSchema.CONF_OPTION: "Control - On"}, + ] + test_address = "1/1/1" + test_state_address = "2/2/2" + fake_state = State("select.test", "Control - On") + mock_restore_cache(hass, (fake_state,)) + + await knx.setup_integration( + { + SelectSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_state_address, + CONF_PAYLOAD_LENGTH: 0, + SelectSchema.CONF_OPTIONS: _options, + } + } + ) + # StateUpdater initialize state - restored value is used before response is received + await knx.assert_read(test_state_address) + state = hass.states.get("select.test") + assert state.state == "Control - On" + + # bus reports a different value than restored - state updates to the real value + await knx.receive_response(test_state_address, 0b10) + state = hass.states.get("select.test") + assert state.state == "Control - Off" + + async def test_select_dpt_20_103_all_options( hass: HomeAssistant, knx: KNXTestKit ) -> None: diff --git a/tests/components/knx/test_text.py b/tests/components/knx/test_text.py index b2222ff025b88a..7eb25399db5fcd 100644 --- a/tests/components/knx/test_text.py +++ b/tests/components/knx/test_text.py @@ -1,6 +1,10 @@ """Test KNX number.""" -from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS +from homeassistant.components.knx.const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + KNX_ADDRESS, +) from homeassistant.components.knx.schema import TextSchema from homeassistant.components.text import TextMode from homeassistant.const import CONF_NAME, Platform @@ -103,6 +107,36 @@ async def test_text_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) -> assert state.state == "hallo" +async def test_text_state_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX text with state_address restores state until bus read completes.""" + test_address = "1/1/1" + test_state_address = "2/2/2" + fake_state = State("text.test", "test test") + mock_restore_cache(hass, (fake_state,)) + + await knx.setup_integration( + { + TextSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_state_address, + } + } + ) + # StateUpdater initialize state - restored value is used before response is received + await knx.assert_read(test_state_address) + state = hass.states.get("text.test") + assert state.state == "test test" + + # bus reports a different value than restored - state updates to the real value + await knx.receive_response( + test_state_address, + (0x68, 0x61, 0x6C, 0x6C, 0x6F, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0), + ) + state = hass.states.get("text.test") + assert state.state == "hallo" + + async def test_text_ui_create( hass: HomeAssistant, knx: KNXTestKit, diff --git a/tests/components/knx/test_time.py b/tests/components/knx/test_time.py index 08a4edff70f46b..19e069706d3d86 100644 --- a/tests/components/knx/test_time.py +++ b/tests/components/knx/test_time.py @@ -1,6 +1,10 @@ """Test KNX time.""" -from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS +from homeassistant.components.knx.const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + KNX_ADDRESS, +) from homeassistant.components.knx.schema import TimeSchema from homeassistant.components.time import ( ATTR_TIME, @@ -92,6 +96,33 @@ async def test_time_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) -> assert state.state == "12:00:00" +async def test_time_state_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX time with state_address restores state until bus read completes.""" + test_address = "1/1/1" + test_state_address = "2/2/2" + fake_state = State("time.test", "01:02:03") + mock_restore_cache(hass, (fake_state,)) + + await knx.setup_integration( + { + TimeSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_state_address, + } + } + ) + # StateUpdater initialize state - restored value is used before response is received + await knx.assert_read(test_state_address) + state = hass.states.get("time.test") + assert state.state == "01:02:03" + + # bus reports a different value than restored - state updates to the real value + await knx.receive_response(test_state_address, (0x0C, 0x00, 0x00)) + state = hass.states.get("time.test") + assert state.state == "12:00:00" + + async def test_time_ui_create( hass: HomeAssistant, knx: KNXTestKit, diff --git a/tests/components/moon/test_sensor.py b/tests/components/moon/test_sensor.py index 2a353bb60ba59a..149e423be91de2 100644 --- a/tests/components/moon/test_sensor.py +++ b/tests/components/moon/test_sensor.py @@ -4,7 +4,7 @@ import pytest -from homeassistant.components.moon.sensor import ( +from homeassistant.components.moon.helpers import ( STATE_FIRST_QUARTER, STATE_FULL_MOON, STATE_LAST_QUARTER, @@ -47,7 +47,7 @@ async def test_moon_day( mock_config_entry.add_to_hass(hass) with patch( - "homeassistant.components.moon.sensor.moon.phase", return_value=moon_value + "homeassistant.components.moon.helpers.moon.phase", return_value=moon_value ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/moon/test_trigger.py b/tests/components/moon/test_trigger.py new file mode 100644 index 00000000000000..266fb2dbfa34a9 --- /dev/null +++ b/tests/components/moon/test_trigger.py @@ -0,0 +1,103 @@ +"""Tests for the moon triggers.""" + +from datetime import datetime, timedelta +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components import automation +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed + +_PHASE = "homeassistant.components.moon.helpers.moon.phase" + + +@pytest.fixture(autouse=True) +async def setup_moon(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: + """Set up the moon integration so its trigger platform is available.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + +async def _arm(hass: HomeAssistant, options: dict[str, Any] | None = None) -> None: + """Set up an automation with the moon phase_changed trigger.""" + trigger: dict[str, Any] = {"platform": "moon.phase_changed"} + if options is not None: + trigger["options"] = options + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": trigger, + "action": { + "service": "test.automation", + "data_template": { + "phase": "{{ trigger.phase }}", + "previous_phase": "{{ trigger.previous_phase }}", + }, + }, + } + }, + ) + await hass.async_block_till_done() + + +def _next_local_midnight() -> datetime: + """Return the next local midnight, when the phase trigger re-evaluates.""" + return dt_util.start_of_local_day() + timedelta(days=1) + + +async def test_phase_changed_fires_on_any_change( + hass: HomeAssistant, service_calls: list[ServiceCall] +) -> None: + """Test the trigger fires on every phase change when unfiltered.""" + with patch(_PHASE, return_value=0.0): + await _arm(hass) + assert len(service_calls) == 0 + + with patch(_PHASE, return_value=14.0): + async_fire_time_changed(hass, _next_local_midnight()) + await hass.async_block_till_done() + + assert len(service_calls) == 1 + assert service_calls[0].data["phase"] == "full_moon" + assert service_calls[0].data["previous_phase"] == "new_moon" + + +async def test_phase_changed_ignores_same_phase( + hass: HomeAssistant, service_calls: list[ServiceCall] +) -> None: + """Test the trigger does not fire when the phase is unchanged.""" + with patch(_PHASE, return_value=14.0): + await _arm(hass) + async_fire_time_changed(hass, _next_local_midnight()) + await hass.async_block_till_done() + + assert len(service_calls) == 0 + + +@pytest.mark.parametrize( + ("new_value", "expected_calls"), + [(14.0, 1), (5.0, 0)], +) +async def test_phase_changed_with_phase_filter( + hass: HomeAssistant, + service_calls: list[ServiceCall], + new_value: float, + expected_calls: int, +) -> None: + """Test the trigger only fires for the configured phase.""" + with patch(_PHASE, return_value=0.0): + await _arm(hass, options={"phase": "full_moon"}) + + with patch(_PHASE, return_value=new_value): + async_fire_time_changed(hass, _next_local_midnight()) + await hass.async_block_till_done() + + assert len(service_calls) == expected_calls diff --git a/tests/components/nobo_hub/__init__.py b/tests/components/nobo_hub/__init__.py index 48e57be118be42..4f3be1f6f48bfe 100644 --- a/tests/components/nobo_hub/__init__.py +++ b/tests/components/nobo_hub/__init__.py @@ -3,7 +3,17 @@ from unittest.mock import MagicMock from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er + + +def device_identifiers( + device_registry: dr.DeviceRegistry, entry_id: str +) -> set[tuple[str, str]]: + """Return the identifiers of all devices for the config entry.""" + identifiers: set[tuple[str, str]] = set() + for device in dr.async_entries_for_config_entry(device_registry, entry_id): + identifiers |= device.identifiers + return identifiers def entity_unique_ids(entity_registry: er.EntityRegistry, entry_id: str) -> set[str]: @@ -14,10 +24,19 @@ def entity_unique_ids(entity_registry: er.EntityRegistry, entry_id: str) -> set[ } -async def fire_hub_update(hass: HomeAssistant, hub: MagicMock) -> None: - """Fire the hub's registered push-update callbacks and wait for state to settle.""" +def dispatch_hub_update(hub: MagicMock) -> None: + """Fire the hub's registered push-update callbacks without awaiting. + + Mirrors pynobo dispatching a single message: call this twice in a row to + reproduce buffered messages processed with no event-loop yield between them. + """ for call in hub.register_callback.call_args_list: call.args[0](hub) + + +async def fire_hub_update(hass: HomeAssistant, hub: MagicMock) -> None: + """Fire the hub's registered push-update callbacks and wait for state to settle.""" + dispatch_hub_update(hub) await hass.async_block_till_done() diff --git a/tests/components/nobo_hub/test_climate.py b/tests/components/nobo_hub/test_climate.py index 2ea1baf77b3f5c..a2ec77f978ea9e 100644 --- a/tests/components/nobo_hub/test_climate.py +++ b/tests/components/nobo_hub/test_climate.py @@ -27,7 +27,7 @@ DOMAIN, OVERRIDE_TYPE_NOW, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -197,14 +197,14 @@ async def test_set_preset_with_override_type_now( @pytest.mark.usefixtures("init_integration") -async def test_zone_removed_marks_unavailable( +async def test_zone_removed_removes_entity( hass: HomeAssistant, mock_nobo_hub: MagicMock, ) -> None: - """A zone removed via the Nobø app must not crash and goes unavailable.""" + """Removing a zone via the Nobø app must not crash and removes the entity.""" mock_nobo_hub.zones.pop("1") await fire_hub_update(hass, mock_nobo_hub) - assert hass.states.get(CLIMATE_ENTITY).state == STATE_UNAVAILABLE + assert hass.states.get(CLIMATE_ENTITY) is None @pytest.mark.usefixtures("init_integration") @@ -289,3 +289,26 @@ async def test_new_zone_adds_entity( await fire_hub_update(hass, mock_nobo_hub) assert f"{SERIAL}:2" in entity_unique_ids(entity_registry, entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_readded_zone_reappears( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """A zone removed and re-added under the same id (the hub reuses ids) reappears.""" + entry_id = mock_config_entry.entry_id + + mock_nobo_hub.zones["2"] = BEDROOM_ZONE + await fire_hub_update(hass, mock_nobo_hub) + assert f"{SERIAL}:2" in entity_unique_ids(entity_registry, entry_id) + + del mock_nobo_hub.zones["2"] + await fire_hub_update(hass, mock_nobo_hub) + assert f"{SERIAL}:2" not in entity_unique_ids(entity_registry, entry_id) + + mock_nobo_hub.zones["2"] = BEDROOM_ZONE + await fire_hub_update(hass, mock_nobo_hub) + assert f"{SERIAL}:2" in entity_unique_ids(entity_registry, entry_id) diff --git a/tests/components/nobo_hub/test_init.py b/tests/components/nobo_hub/test_init.py index 880aa49dd74f1a..9a8d8c005b875b 100644 --- a/tests/components/nobo_hub/test_init.py +++ b/tests/components/nobo_hub/test_init.py @@ -14,9 +14,15 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_IP_ADDRESS, CONF_MAC, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr - -from . import fire_hub_connection +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import ( + device_identifiers, + dispatch_hub_update, + entity_unique_ids, + fire_hub_connection, + fire_hub_update, +) from .conftest import SERIAL, STORED_IP from tests.common import MockConfigEntry @@ -324,3 +330,152 @@ async def test_zone_removed_during_disconnect_stays_unavailable_on_reconnect( await fire_hub_connection(hass, mock_nobo_hub, True) assert hass.states.get(entity).state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("init_integration") +async def test_removed_zone_removes_device( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Removing a zone on the hub removes its device but keeps the hub device.""" + entry_id = mock_config_entry.entry_id + assert (DOMAIN, f"{SERIAL}:1") in device_identifiers(device_registry, entry_id) + + del mock_nobo_hub.zones["1"] + await fire_hub_update(hass, mock_nobo_hub) + + identifiers = device_identifiers(device_registry, entry_id) + assert (DOMAIN, f"{SERIAL}:1") not in identifiers + assert (DOMAIN, SERIAL) in identifiers + + +@pytest.mark.parametrize("platforms", [[Platform.SENSOR]], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_removed_component_removes_device( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Removing a temperature-sensor component on the hub removes its device.""" + entry_id = mock_config_entry.entry_id + assert (DOMAIN, "200000059091") in device_identifiers(device_registry, entry_id) + + del mock_nobo_hub.components["200000059091"] + await fire_hub_update(hass, mock_nobo_hub) + + assert (DOMAIN, "200000059091") not in device_identifiers(device_registry, entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_disconnected_hub_does_not_remove_devices( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Devices are retained when topology looks empty because the hub is disconnected.""" + entry_id = mock_config_entry.entry_id + before = device_identifiers(device_registry, entry_id) + + mock_nobo_hub.connected = False + mock_nobo_hub.zones.clear() + mock_nobo_hub.components.clear() + await fire_hub_update(hass, mock_nobo_hub) + + assert device_identifiers(device_registry, entry_id) == before + + +@pytest.mark.parametrize( + "platforms", + [[Platform.CLIMATE, Platform.SELECT, Platform.SENSOR]], + indirect=True, +) +@pytest.mark.usefixtures("init_integration") +async def test_disconnect_does_not_readd_entities_on_reconnect( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A stale empty topology while disconnected must not forget known ids. + + Otherwise the reconcile would clear the known-id sets and re-add every + entity on reconnect, colliding with the still-registered unique ids. + """ + saved_zones = dict(mock_nobo_hub.zones) + saved_components = dict(mock_nobo_hub.components) + + mock_nobo_hub.connected = False + mock_nobo_hub.zones.clear() + mock_nobo_hub.components.clear() + await fire_hub_update(hass, mock_nobo_hub) + + mock_nobo_hub.connected = True + mock_nobo_hub.zones.update(saved_zones) + mock_nobo_hub.components.update(saved_components) + await fire_hub_update(hass, mock_nobo_hub) + + assert "already exists" not in caplog.text + + +@pytest.mark.parametrize("platforms", [[Platform.CLIMATE]], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_buffered_remove_then_readd_same_id( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """A buffered remove + same-id re-add (no event-loop yield between) re-registers cleanly. + + pynobo can process a delete and an id-reusing add back-to-back before the + loop yields (buffered messages), so synchronous device removal must fully + deregister the old entity before the re-add, or the add collides with the + still-registered unique id. + """ + entry_id = mock_config_entry.entry_id + zone = { + "zone_id": "2", + "name": "Bedroom", + "week_profile_id": "0", + "temp_comfort_c": "22", + "temp_eco_c": "18", + } + mock_nobo_hub.zones["2"] = zone + await fire_hub_update(hass, mock_nobo_hub) + assert f"{SERIAL}:2" in entity_unique_ids(entity_registry, entry_id) + + # Remove then re-add the same id with no await (no event-loop yield) between. + del mock_nobo_hub.zones["2"] + dispatch_hub_update(mock_nobo_hub) + mock_nobo_hub.zones["2"] = zone + dispatch_hub_update(mock_nobo_hub) + await hass.async_block_till_done() + + assert f"{SERIAL}:2" in entity_unique_ids(entity_registry, entry_id) + assert "already exists" not in caplog.text + + +@pytest.mark.usefixtures("mock_nobo_class") +async def test_stale_device_pruned_at_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """A device for a zone removed while Home Assistant was down is pruned at setup.""" + mock_config_entry.add_to_hass(hass) + stale_device = (DOMAIN, f"{SERIAL}:99") + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={stale_device}, + ) + entry_id = mock_config_entry.entry_id + assert stale_device in device_identifiers(device_registry, entry_id) + + assert await hass.config_entries.async_setup(entry_id) + await hass.async_block_till_done() + + assert stale_device not in device_identifiers(device_registry, entry_id) diff --git a/tests/components/nobo_hub/test_select.py b/tests/components/nobo_hub/test_select.py index e9a6eaa7c58fa6..31c401543cd972 100644 --- a/tests/components/nobo_hub/test_select.py +++ b/tests/components/nobo_hub/test_select.py @@ -12,7 +12,7 @@ DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -154,14 +154,44 @@ async def test_week_profile_push_update( @pytest.mark.usefixtures("init_integration") -async def test_zone_removed_marks_week_profile_unavailable( +async def test_zone_removed_removes_week_profile_entity( hass: HomeAssistant, mock_nobo_hub: MagicMock, ) -> None: - """A zone removed via the Nobø app must not crash and goes unavailable.""" + """Removing a zone via the Nobø app must not crash and removes the entity.""" mock_nobo_hub.zones.pop("1") await fire_hub_update(hass, mock_nobo_hub) - assert hass.states.get(PROFILE_ENTITY).state == STATE_UNAVAILABLE + assert hass.states.get(PROFILE_ENTITY) is None + + +@pytest.mark.usefixtures("init_integration") +async def test_readded_zone_reappears_profile_selector( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """A zone removed and re-added under the same id (the hub reuses ids) restores its selector.""" + entry_id = mock_config_entry.entry_id + zone = { + "zone_id": "2", + "name": "Bedroom", + "week_profile_id": "0", + "temp_comfort_c": "22", + "temp_eco_c": "18", + } + + mock_nobo_hub.zones["2"] = zone + await fire_hub_update(hass, mock_nobo_hub) + assert f"{SERIAL}:2:profile" in entity_unique_ids(entity_registry, entry_id) + + del mock_nobo_hub.zones["2"] + await fire_hub_update(hass, mock_nobo_hub) + assert f"{SERIAL}:2:profile" not in entity_unique_ids(entity_registry, entry_id) + + mock_nobo_hub.zones["2"] = zone + await fire_hub_update(hass, mock_nobo_hub) + assert f"{SERIAL}:2:profile" in entity_unique_ids(entity_registry, entry_id) @pytest.mark.usefixtures("init_integration") diff --git a/tests/components/nobo_hub/test_sensor.py b/tests/components/nobo_hub/test_sensor.py index 8a8c3e3404ba6e..12fad4c74a7b00 100644 --- a/tests/components/nobo_hub/test_sensor.py +++ b/tests/components/nobo_hub/test_sensor.py @@ -5,7 +5,7 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -58,14 +58,47 @@ async def test_temperature_push_update( @pytest.mark.usefixtures("init_integration") -async def test_component_removed_marks_unavailable( +async def test_component_removed_removes_entity( hass: HomeAssistant, mock_nobo_hub: MagicMock, ) -> None: - """A component removed via the Nobø app must not crash and goes unavailable.""" + """Removing a component via the Nobø app must not crash and removes the entity.""" mock_nobo_hub.components.pop("200000059091") await fire_hub_update(hass, mock_nobo_hub) - assert hass.states.get(TEMPERATURE_ENTITY).state == STATE_UNAVAILABLE + assert hass.states.get(TEMPERATURE_ENTITY) is None + + +@pytest.mark.usefixtures("init_integration") +async def test_readded_component_reappears( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """A component removed and re-added under the same serial (the hub reuses serials) reappears.""" + entry_id = mock_config_entry.entry_id + serial = "200000059092" + model = MagicMock() + model.name = "Panel heater" + model.has_temp_sensor = True + component = { + "serial": serial, + "name": "Bedroom sensor", + "zone_id": "1", + "model": model, + } + + mock_nobo_hub.components[serial] = component + await fire_hub_update(hass, mock_nobo_hub) + assert serial in entity_unique_ids(entity_registry, entry_id) + + del mock_nobo_hub.components[serial] + await fire_hub_update(hass, mock_nobo_hub) + assert serial not in entity_unique_ids(entity_registry, entry_id) + + mock_nobo_hub.components[serial] = component + await fire_hub_update(hass, mock_nobo_hub) + assert serial in entity_unique_ids(entity_registry, entry_id) @pytest.mark.parametrize( diff --git a/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_switch_sc_europe.json b/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_switch_sc_europe.json index 4c127ecb54d546..5582c6aff29351 100644 --- a/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_switch_sc_europe.json +++ b/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_switch_sc_europe.json @@ -3483,6 +3483,1116 @@ "widget": "ZigbeeStack", "oid": "08fa5c0f-95ba-410a-8a66-4cb55c0d508c", "uiClass": "ProtocolGateway" + }, + { + "label": "Thermostat", + "uiClass": "HeatingSystem", + "deviceURL": "io://1234-5678-5010/386310#1", + "shortcut": false, + "controllableName": "io:HeatingThermostatIOComponent", + "creationTime": 1759678031000, + "lastUpdateTime": 1759678031000, + "definition": { + "commands": [ + { + "commandName": "addLockLevel", + "nparams": 2 + }, + { + "commandName": "advancedRefresh", + "nparams": 1 + }, + { + "commandName": "delayedStopIdentify", + "nparams": 1 + }, + { + "commandName": "getName", + "nparams": 0 + }, + { + "commandName": "identify", + "nparams": 0 + }, + { + "commandName": "removeLockLevel", + "nparams": 1 + }, + { + "commandName": "resetLockLevels", + "nparams": 0 + }, + { + "commandName": "setName", + "nparams": 1 + }, + { + "commandName": "setTimeProgramById", + "nparams": 2 + }, + { + "commandName": "startIdentify", + "nparams": 0 + }, + { + "commandName": "stopIdentify", + "nparams": 0 + }, + { + "commandName": "wink", + "nparams": 1 + }, + { + "commandName": "exitDerogation", + "nparams": 0 + }, + { + "commandName": "setAllModeTemperatures", + "nparams": 4 + }, + { + "commandName": "setDerogation", + "nparams": 2 + }, + { + "commandName": "setThermostatSettings", + "nparams": 1 + } + ], + "states": [ + { + "type": "DataState", + "qualifiedName": "core:ActiveTimeProgramState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:BatteryLevelState" + }, + { + "type": "DiscreteState", + "values": ["full", "low", "normal", "verylow"], + "qualifiedName": "core:BatteryState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:ComfortRoomTemperatureState" + }, + { + "eventBased": true, + "type": "DataState", + "qualifiedName": "core:CommandLockLevelsState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:DerogatedTargetTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "core:DerogationEndDateTimeState" + }, + { + "type": "DataState", + "qualifiedName": "core:DerogationStartDateTimeState" + }, + { + "type": "DiscreteState", + "values": ["good", "low", "normal", "verylow"], + "qualifiedName": "core:DiscreteRSSILevelState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:EcoTargetTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "core:ErrorsState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:FrostProtectionRoomTemperatureState" + }, + { + "type": "DiscreteState", + "values": ["enable", "disable"], + "qualifiedName": "core:HeatingAnticipationState" + }, + { + "type": "DataState", + "qualifiedName": "core:MaxSetpointState" + }, + { + "type": "DataState", + "qualifiedName": "core:MinSetpointState" + }, + { + "type": "DataState", + "qualifiedName": "core:NameState" + }, + { + "type": "DiscreteState", + "values": ["closed", "open"], + "qualifiedName": "core:OpenClosedValveState" + }, + { + "type": "DiscreteState", + "values": ["active", "inactive"], + "qualifiedName": "core:OpenWindowDetectionActivationState" + }, + { + "type": "DiscreteState", + "values": [ + "antifreeze", + "auto", + "away", + "eco", + "frostprotection", + "manual", + "max", + "normal", + "off", + "on", + "prog", + "program", + "boost" + ], + "qualifiedName": "core:OperatingModeState" + }, + { + "type": "DiscreteState", + "values": ["enable", "disable"], + "qualifiedName": "core:PermanentDisplayState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:RSSILevelState" + }, + { + "type": "DiscreteState", + "values": ["dead", "lowBattery", "maintenanceRequired", "noDefect"], + "qualifiedName": "core:SensorDefectState" + }, + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:StatusState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:TargetRoomTemperatureState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:TargetTemperatureHysteresisState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:TargetTemperatureState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:TemperatureOffsetConfigurationState" + }, + { + "type": "DiscreteState", + "values": ["cooling", "heating", "heatingAndCooling"], + "qualifiedName": "core:ThermalConfigurationState" + }, + { + "type": "DataState", + "qualifiedName": "core:TimeProgram1State" + }, + { + "type": "DataState", + "qualifiedName": "core:TimeProgram2State" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:AwayModeTargetTemperatureState" + }, + { + "type": "DiscreteState", + "values": [ + "awayMode", + "comfort", + "eco", + "frostprotection", + "geofencingMode", + "manual", + "suddenDropMode" + ], + "qualifiedName": "io:CurrentHeatingModeState" + }, + { + "type": "DiscreteState", + "values": [ + "awayMode", + "comfort", + "eco", + "frostprotection", + "geofencingMode", + "manual", + "suddenDropMode" + ], + "qualifiedName": "io:DerogationHeatingModeState" + }, + { + "type": "DiscreteState", + "values": ["date", "furtherNotice", "nextMode"], + "qualifiedName": "io:DerogationTypeState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:GeofencingModeTargetTemperatureState" + }, + { + "type": "DiscreteState", + "values": ["disabled", "enabled"], + "qualifiedName": "io:LockKeyActivationState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:ManualModeTargetTemperatureState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:OpenWindowDetectedTargetTemperatureState" + }, + { + "type": "DiscreteState", + "values": [ + "adjustment", + "finished", + "full_closed", + "full_open", + "pairing", + "reset" + ], + "qualifiedName": "io:ValveInstallationModeState" + } + ], + "dataProperties": [ + { + "value": "500", + "qualifiedName": "core:identifyInterval" + } + ], + "widgetName": "ThermostatHeatingTemperatureInterface", + "uiProfiles": ["ThermostatTargetReader"], + "uiClass": "HeatingSystem", + "uiClassifiers": ["emitter"], + "qualifiedName": "io:HeatingThermostatIOComponent", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "core:StatusState", + "type": 3, + "value": "available" + }, + { + "name": "core:DiscreteRSSILevelState", + "type": 3, + "value": "good" + }, + { + "name": "core:RSSILevelState", + "type": 2, + "value": 96.0 + }, + { + "name": "io:DerogationTypeState", + "type": 3, + "value": "further_notice" + }, + { + "name": "io:DerogationHeatingModeState", + "type": 3, + "value": "manual" + }, + { + "name": "core:DerogatedTargetTemperatureState", + "type": 2, + "value": 16.5 + }, + { + "name": "io:ManualModeTargetTemperatureState", + "type": 2, + "value": 16.5 + }, + { + "name": "core:DerogationStartDateTimeState", + "type": 5, + "value": 1779390409000 + }, + { + "name": "core:DerogationEndDateTimeState", + "type": 5, + "value": 4294967295000 + }, + { + "name": "core:ComfortRoomTemperatureState", + "type": 2, + "value": 21.0 + }, + { + "name": "io:AwayModeTargetTemperatureState", + "type": 2, + "value": 17.0 + }, + { + "name": "core:EcoTargetTemperatureState", + "type": 2, + "value": 19.0 + }, + { + "name": "io:GeofencingModeTargetTemperatureState", + "type": 2, + "value": 20.0 + }, + { + "name": "core:FrostProtectionRoomTemperatureState", + "type": 2, + "value": 8.0 + }, + { + "name": "io:OpenWindowDetectedTargetTemperatureState", + "type": 2, + "value": 17.0 + }, + { + "name": "io:ValveInstallationModeState", + "type": 3, + "value": "finished" + }, + { + "name": "core:BatteryLevelState", + "type": 2, + "value": 100.0 + }, + { + "name": "core:TimeProgram1State", + "type": 11, + "value": { + "sunday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "saturday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "tuesday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "wednesday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "friday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "thursday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "monday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + } + } + }, + { + "name": "core:TimeProgram2State", + "type": 11, + "value": { + "sunday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "saturday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "tuesday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "wednesday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "friday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "thursday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "monday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + } + } + }, + { + "name": "core:OpenClosedValveState", + "type": 3, + "value": "open" + }, + { + "name": "core:OperatingModeState", + "type": 3, + "value": "manual" + }, + { + "name": "io:CurrentHeatingModeState", + "type": 3, + "value": "manual" + }, + { + "name": "core:TargetRoomTemperatureState", + "type": 2, + "value": 16.5 + }, + { + "name": "core:TargetTemperatureState", + "type": 2, + "value": 16.5 + }, + { + "name": "core:OpenWindowDetectionActivationState", + "type": 3, + "value": "active" + }, + { + "name": "io:LockKeyActivationState", + "type": 3, + "value": "disable" + }, + { + "name": "core:PermanentDisplayState", + "type": 3, + "value": "enable" + }, + { + "name": "core:ThermalConfigurationState", + "type": 3, + "value": "heating" + }, + { + "name": "core:HeatingAnticipationState", + "type": 3, + "value": "disable" + }, + { + "name": "core:ActiveTimeProgramState", + "type": 3, + "value": "none" + }, + { + "name": "core:MaxSetpointState", + "type": 2, + "value": 26.0 + }, + { + "name": "core:MinSetpointState", + "type": 2, + "value": 5.0 + }, + { + "name": "core:TemperatureOffsetConfigurationState", + "type": 2, + "value": 0.0 + }, + { + "name": "core:TargetTemperatureHysteresisState", + "type": 2, + "value": 0.3 + } + ], + "available": true, + "enabled": true, + "placeOID": "8ba89c86-a590-4a3c-b352-4b95e906e9c9", + "oid": "d241a2c8-713a-428a-9911-0f8226af676e", + "widget": "ThermostatHeatingTemperatureInterface", + "type": 1 + }, + { + "label": "Thermostat Temperature", + "uiClass": "TemperatureSensor", + "deviceURL": "io://1234-5678-5010/386310#2", + "shortcut": false, + "controllableName": "io:TemperatureIOSystemSensor", + "creationTime": 1759678031000, + "lastUpdateTime": 1759678031000, + "definition": { + "commands": [ + { + "commandName": "advancedRefresh", + "nparams": 1 + } + ], + "states": [ + { + "type": "DiscreteState", + "values": ["full", "low", "normal", "verylow"], + "qualifiedName": "core:BatteryState" + }, + { + "type": "DiscreteState", + "values": ["good", "low", "normal", "verylow"], + "qualifiedName": "core:DiscreteRSSILevelState" + }, + { + "type": "DataState", + "qualifiedName": "core:ErrorsState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:RSSILevelState" + }, + { + "type": "DiscreteState", + "values": ["dead", "lowBattery", "maintenanceRequired", "noDefect"], + "qualifiedName": "core:SensorDefectState" + }, + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:StatusState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:TemperatureState" + } + ], + "dataProperties": [], + "widgetName": "TemperatureSensor", + "uiProfiles": ["Temperature"], + "uiClass": "TemperatureSensor", + "qualifiedName": "io:TemperatureIOSystemSensor", + "type": "SENSOR" + }, + "states": [ + { + "name": "core:StatusState", + "type": 3, + "value": "available" + }, + { + "name": "core:DiscreteRSSILevelState", + "type": 3, + "value": "good" + }, + { + "name": "core:RSSILevelState", + "type": 2, + "value": 96.0 + }, + { + "name": "core:TemperatureState", + "type": 2, + "value": 26.6 + } + ], + "attributes": [ + { + "name": "core:FirmwareRevision", + "type": 3, + "value": "5155003A14" + }, + { + "name": "core:MinSensedValue", + "type": 1, + "value": 0 + }, + { + "name": "core:Manufacturer", + "type": 3, + "value": "Somfy" + }, + { + "name": "core:MaxSensedValue", + "type": 2, + "value": 655.35 + }, + { + "name": "core:PowerSourceType", + "type": 3, + "value": "battery" + } + ], + "available": true, + "enabled": true, + "placeOID": "8ba89c86-a590-4a3c-b352-4b95e906e9c9", + "oid": "c32eb2cd-06de-4827-95fb-51ae49acf467", + "widget": "TemperatureSensor", + "type": 2 } ], "zones": [], diff --git a/tests/components/overkiz/snapshots/test_climate.ambr b/tests/components/overkiz/snapshots/test_climate.ambr index cef1c9b4466209..91e4b14aa5d93d 100644 --- a/tests/components/overkiz/snapshots/test_climate.ambr +++ b/tests/components/overkiz/snapshots/test_climate.ambr @@ -656,3 +656,85 @@ 'state': 'heat_cool', }) # --- +# name: test_climate_entities_snapshot[cloud_somfy_tahoma_switch_sc_europe.json][climate.study_thermostat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + ]), + : 26.0, + : 5.0, + : list([ + 'none', + 'away', + 'comfort', + 'eco', + 'frost_protection', + 'manual', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.study_thermostat', + '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': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'overkiz', + 'unique_id': 'io://1234-5678-5010/386310#1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities_snapshot[cloud_somfy_tahoma_switch_sc_europe.json][climate.study_thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 26.6, + : 'Thermostat', + : , + : list([ + , + ]), + : 26.0, + : 5.0, + : 'manual', + : list([ + 'none', + 'away', + 'comfort', + 'eco', + 'frost_protection', + 'manual', + ]), + : , + : 16.5, + }), + 'context': , + 'entity_id': 'climate.study_thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/overkiz/test_climate.py b/tests/components/overkiz/test_climate.py index 74a47ca41ed0b3..8d11067804c80f 100644 --- a/tests/components/overkiz/test_climate.py +++ b/tests/components/overkiz/test_climate.py @@ -13,6 +13,7 @@ from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, ATTR_HVAC_ACTION, + ATTR_PRESET_MODE, HVACAction, HVACMode, ) @@ -22,6 +23,7 @@ from .conftest import FixtureDevice, MockOverkizClient, SetupOverkizIntegration from .helpers import ( + assert_command_call, async_deliver_events, device_available_event, device_removed_event, @@ -55,11 +57,18 @@ "modbus://1234-5678-2284/5416194/1#3", "climate.somfy_tahoma_switch_yutaki_zone_2", ) +# io:HeatingThermostatIOComponent +THERMOSTAT_HEATING = FixtureDevice( + "setup/cloud_somfy_tahoma_switch_sc_europe.json", + "io://1234-5678-5010/386310#1", + "climate.study_thermostat", +) SNAPSHOT_FIXTURES = [ VALVE, COZYTOUCH, YUTAKI_ZONE_1, + THERMOSTAT_HEATING, ] @@ -178,3 +187,61 @@ async def test_hitachi_air_to_water_heating_zone_2( assert zone_2.state == HVACMode.AUTO assert zone_2.attributes[ATTR_CURRENT_TEMPERATURE] == 20.5 assert zone_2.attributes[ATTR_TEMPERATURE] == 21.0 + + +async def test_thermostat_heating_set_temperature( + hass: HomeAssistant, + mock_client: MockOverkizClient, + setup_overkiz_integration: SetupOverkizIntegration, +) -> None: + """Test setting a temperature issues setDerogation, not setComfortTemperature.""" + await setup_overkiz_integration(fixture=THERMOSTAT_HEATING.fixture) + + await hass.services.async_call( + "climate", + "set_temperature", + {"entity_id": THERMOSTAT_HEATING.entity_id, ATTR_TEMPERATURE: 20.0}, + blocking=True, + ) + + assert_command_call( + mock_client, + device_url=THERMOSTAT_HEATING.device_url, + command_name="setDerogation", + parameters=[20.0, "further_notice"], + ) + + +@pytest.mark.parametrize( + ("preset_mode", "parameters"), + [ + pytest.param("away", ["away", "further_notice"], id="away"), + pytest.param("comfort", ["comfort", "further_notice"], id="comfort"), + pytest.param("eco", ["eco", "further_notice"], id="eco"), + # Manual re-sends the current temperature to enter the derogation + pytest.param("manual", [26.6, "further_notice"], id="manual"), + ], +) +async def test_thermostat_heating_set_preset_mode( + hass: HomeAssistant, + mock_client: MockOverkizClient, + setup_overkiz_integration: SetupOverkizIntegration, + preset_mode: str, + parameters: list[str | float], +) -> None: + """Test selecting a preset issues setDerogation with the mapped parameter.""" + await setup_overkiz_integration(fixture=THERMOSTAT_HEATING.fixture) + + await hass.services.async_call( + "climate", + "set_preset_mode", + {"entity_id": THERMOSTAT_HEATING.entity_id, ATTR_PRESET_MODE: preset_mode}, + blocking=True, + ) + + assert_command_call( + mock_client, + device_url=THERMOSTAT_HEATING.device_url, + command_name="setDerogation", + parameters=parameters, + ) diff --git a/tests/components/overseerr/conftest.py b/tests/components/overseerr/conftest.py index 5435aff659c5cc..8c9d45c99f58dc 100644 --- a/tests/components/overseerr/conftest.py +++ b/tests/components/overseerr/conftest.py @@ -67,6 +67,7 @@ def mock_overseerr_client() -> Generator[AsyncMock]: client.get_tv_details.return_value = TVDetails.from_json( load_fixture("tv.json", DOMAIN) ) + client.search.return_value = [] yield client diff --git a/tests/components/overseerr/test_services.py b/tests/components/overseerr/test_services.py index 39df5760693d22..07279dff3efd82 100644 --- a/tests/components/overseerr/test_services.py +++ b/tests/components/overseerr/test_services.py @@ -1,18 +1,29 @@ """Tests for the Overseerr services.""" +import dataclasses from unittest.mock import AsyncMock import pytest from python_overseerr import OverseerrConnectionError +from python_overseerr.models import MediaType from syrupy.assertion import SnapshotAssertion from homeassistant.components.overseerr.const import ( + ATTR_MEDIA_ID, + ATTR_MEDIA_TYPE, + ATTR_QUERY, ATTR_REQUESTED_BY, + ATTR_SEASONS, ATTR_SORT_ORDER, ATTR_STATUS, DOMAIN, ) -from homeassistant.components.overseerr.services import SERVICE_GET_REQUESTS +from homeassistant.components.overseerr.services import ( + SERVICE_GET_REQUESTS, + SERVICE_REQUEST_MEDIA, + SERVICE_SEARCH_MEDIA, + parse_seasons_input, +) from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -75,6 +86,65 @@ async def test_service_get_requests_no_meta( assert request["media"] == {} +async def test_service_search_media( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the search_media service.""" + # Mock the search method + mock_overseerr_client.search.return_value = [] + + await setup_integration(hass, mock_config_entry) + + # Test with a query containing spaces + response = await hass.services.async_call( + DOMAIN, + SERVICE_SEARCH_MEDIA, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_QUERY: "test query with spaces", + }, + blocking=True, + return_response=True, + ) + assert response == {"results": []} + mock_overseerr_client.search.assert_called_once_with("test query with spaces") + + +async def test_service_request_media( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the request_media service.""" + + # Mock the create request method + @dataclasses.dataclass + class RequestWithMediaMock: + tmdb_id: str = "123456789" + media_type: MediaType = MediaType.TV + + mock_overseerr_client.create_request.return_value = RequestWithMediaMock() + + await setup_integration(hass, mock_config_entry) + + response = await hass.services.async_call( + DOMAIN, + SERVICE_REQUEST_MEDIA, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_MEDIA_TYPE: "tv", + ATTR_MEDIA_ID: "123456789", + ATTR_SEASONS: "1", + }, + blocking=True, + return_response=True, + ) + + assert response == {"request": {"media_type": MediaType.TV, "tmdb_id": "123456789"}} + + @pytest.mark.parametrize( ("service", "payload", "function", "exception", "raised_exception", "message"), [ @@ -85,7 +155,23 @@ async def test_service_get_requests_no_meta( OverseerrConnectionError("Timeout"), HomeAssistantError, "Error connecting to the Seerr instance: Timeout", - ) + ), + ( + SERVICE_SEARCH_MEDIA, + {ATTR_QUERY: "test"}, + "search", + OverseerrConnectionError("Timeout"), + HomeAssistantError, + "Error connecting to the Seerr instance: Timeout", + ), + ( + SERVICE_REQUEST_MEDIA, + {ATTR_MEDIA_TYPE: "tv", ATTR_MEDIA_ID: "123456789", ATTR_SEASONS: "1"}, + "create_request", + OverseerrConnectionError("Timeout"), + HomeAssistantError, + "Error connecting to the Seerr instance: Timeout", + ), ], ) async def test_services_connection_error( @@ -119,6 +205,11 @@ async def test_services_connection_error( ("service", "payload"), [ (SERVICE_GET_REQUESTS, {}), + (SERVICE_SEARCH_MEDIA, {ATTR_QUERY: "test"}), + ( + SERVICE_REQUEST_MEDIA, + {ATTR_MEDIA_TYPE: "tv", ATTR_MEDIA_ID: "123456789", ATTR_SEASONS: "1"}, + ), ], ) async def test_service_entry_availability( @@ -154,3 +245,29 @@ async def test_service_entry_availability( return_response=True, ) assert err.value.translation_key == "service_config_entry_not_found" + + +@pytest.mark.parametrize( + ("seasons_input", "expected_seasons"), + [ + ("1", [1]), + ("1,", [1]), + ("1,2,3", [1, 2, 3]), + ("1, 2, 3", [1, 2, 3]), + (" 1 , 2, 3 ", [1, 2, 3]), + ("[1]", [1]), + ("[1,2,3]", [1, 2, 3]), + ("[ 1 , 2 , 3]", [1, 2, 3]), + ("", "all"), + (" ", "all"), + (None, "all"), + ("all", "all"), + ("Not a valid input", "all"), + ("-", "all"), + ], +) +def test_parse_seasons_input( + seasons_input: str | None, expected_seasons: list[int] | str +) -> None: + """Test that all inputs are parsed correctly.""" + assert expected_seasons == parse_seasons_input(seasons_input) diff --git a/tests/components/picnic/conftest.py b/tests/components/picnic/conftest.py index 569d65df38723a..fac10ec491bf51 100644 --- a/tests/components/picnic/conftest.py +++ b/tests/components/picnic/conftest.py @@ -1,6 +1,7 @@ """Conftest for Picnic tests.""" from collections.abc import Awaitable, Callable +from datetime import timedelta import json from unittest.mock import MagicMock, patch @@ -9,12 +10,18 @@ from homeassistant.components.picnic import CONF_COUNTRY_CODE, DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, load_fixture from tests.typing import WebSocketGenerator ENTITY_ID = "todo.mock_title_shopping_cart" +SetupDeliveryFixture = Callable[ + [str, tuple[timedelta, timedelta] | None, tuple[timedelta, timedelta]], + Awaitable[dict], +] + @pytest.fixture def mock_config_entry() -> MockConfigEntry: @@ -37,13 +44,48 @@ def mock_picnic_api(): client.session.auth_token = "3q29fpwhulzes" client.get_cart.return_value = json.loads(load_fixture("picnic/cart.json")) client.get_user.return_value = json.loads(load_fixture("picnic/user.json")) - client.get_deliveries.return_value = json.loads( - load_fixture("picnic/delivery.json") - ) + client.get_deliveries.return_value = [ + json.loads(load_fixture("picnic/delivery.json")) + ] client.get_delivery_position.return_value = {} yield client +@pytest.fixture +def setup_delivery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_picnic_api: MagicMock, +) -> SetupDeliveryFixture: + """Return a factory to set up the integration with the delivery in a given state.""" + + async def _setup( + status: str, + eta2: tuple[timedelta, timedelta] | None, + slot_window: tuple[timedelta, timedelta], + ) -> dict: + delivery = mock_picnic_api.get_deliveries.return_value[0] + delivery["status"] = status + delivery["delivery_time"] = None + # eta2 is the API's field name for the route-planning ETA + delivery["eta2"] = eta2 and { + "start": (dt_util.utcnow() + eta2[0]).isoformat(), + "end": (dt_util.utcnow() + eta2[1]).isoformat(), + } + delivery["slot"]["window_start"] = ( + dt_util.utcnow() + slot_window[0] + ).isoformat() + delivery["slot"]["window_end"] = (dt_util.utcnow() + slot_window[1]).isoformat() + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return delivery + + return _setup + + @pytest.fixture async def init_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_picnic_api: MagicMock diff --git a/tests/components/picnic/test_coordinator.py b/tests/components/picnic/test_coordinator.py index 9279ec07b4976c..209fcedd29f87c 100644 --- a/tests/components/picnic/test_coordinator.py +++ b/tests/components/picnic/test_coordinator.py @@ -1,11 +1,22 @@ """Tests for the Picnic coordinator.""" +from datetime import timedelta from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.picnic.const import ( + DEFAULT_UPDATE_INTERVAL, + DELIVERY_UPDATE_INTERVAL, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from .conftest import SetupDeliveryFixture -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_timeout_failed_with_retry( @@ -21,3 +32,149 @@ async def test_timeout_failed_with_retry( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + ("status", "eta2", "slot_window", "expected_interval"), + [ + pytest.param( + "COMPLETED", + None, + (timedelta(hours=-2), timedelta(hours=-1)), + DEFAULT_UPDATE_INTERVAL, + id="no_undelivered_order", + ), + pytest.param( + "CURRENT", + (timedelta(days=2), timedelta(days=2, hours=1)), + (timedelta(days=2), timedelta(days=2, hours=1)), + DEFAULT_UPDATE_INTERVAL, + id="delivery_days_away", + ), + pytest.param( + "CURRENT", + (timedelta(minutes=10), timedelta(minutes=30)), + (timedelta(minutes=-15), timedelta(minutes=45)), + DELIVERY_UPDATE_INTERVAL, + id="delivery_under_way", + ), + pytest.param( + "CURRENT", + (timedelta(minutes=40), timedelta(minutes=60)), + (timedelta(minutes=40), timedelta(minutes=60)), + timedelta(minutes=10), + id="next_poll_capped_at_window_start", + ), + pytest.param( + "CURRENT", + (timedelta(minutes=30, seconds=30), timedelta(minutes=50)), + (timedelta(minutes=30, seconds=30), timedelta(minutes=50)), + DELIVERY_UPDATE_INTERVAL, + id="next_poll_never_sooner_than_delivery_interval", + ), + pytest.param( + "CURRENT", + (timedelta(hours=-4), timedelta(hours=-3)), + (timedelta(hours=-4), timedelta(hours=-3)), + DEFAULT_UPDATE_INTERVAL, + id="long_past_window_still_current", + ), + pytest.param( + "CURRENT", + None, + (timedelta(minutes=10), timedelta(minutes=70)), + DELIVERY_UPDATE_INTERVAL, + id="slot_window_fallback_without_eta", + ), + ], +) +@pytest.mark.usefixtures("freezer") +async def test_update_interval( + mock_config_entry: MockConfigEntry, + setup_delivery: SetupDeliveryFixture, + status: str, + eta2: tuple[timedelta, timedelta] | None, + slot_window: tuple[timedelta, timedelta], + expected_interval: timedelta, +) -> None: + """Test the update interval for the various delivery states.""" + await setup_delivery(status, eta2, slot_window) + + coordinator = mock_config_entry.runtime_data + assert coordinator.update_interval == expected_interval + + +@pytest.mark.usefixtures("freezer") +async def test_update_interval_with_malformed_eta( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_picnic_api: MagicMock, +) -> None: + """Test that a malformed ETA falls back to the slot window.""" + delivery = mock_picnic_api.get_deliveries.return_value[0] + delivery["status"] = "CURRENT" + delivery["delivery_time"] = None + delivery["eta2"] = {"start": "malformed", "end": "malformed"} + delivery["slot"]["window_start"] = ( + dt_util.utcnow() + timedelta(minutes=10) + ).isoformat() + delivery["slot"]["window_end"] = ( + dt_util.utcnow() + timedelta(minutes=70) + ).isoformat() + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + coordinator = mock_config_entry.runtime_data + assert coordinator.update_interval == DELIVERY_UPDATE_INTERVAL + + +async def test_update_interval_relaxes_after_delivery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + setup_delivery: SetupDeliveryFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that the update interval returns to the default once delivered.""" + delivery = await setup_delivery( + "CURRENT", + (timedelta(minutes=10), timedelta(minutes=30)), + (timedelta(minutes=-15), timedelta(minutes=45)), + ) + + coordinator = mock_config_entry.runtime_data + assert coordinator.update_interval == DELIVERY_UPDATE_INTERVAL + + delivery["status"] = "COMPLETED" + freezer.tick(DELIVERY_UPDATE_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert coordinator.update_interval == DEFAULT_UPDATE_INTERVAL + + +async def test_update_interval_relaxes_when_refresh_fails( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_picnic_api: MagicMock, + setup_delivery: SetupDeliveryFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that failed refreshes still relax the interval past the window.""" + await setup_delivery( + "CURRENT", + (timedelta(minutes=10), timedelta(minutes=30)), + (timedelta(minutes=-15), timedelta(minutes=45)), + ) + + coordinator = mock_config_entry.runtime_data + assert coordinator.update_interval == DELIVERY_UPDATE_INTERVAL + + mock_picnic_api.get_cart.return_value = None + freezer.tick(timedelta(hours=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert coordinator.last_update_success is False + assert coordinator.update_interval == DEFAULT_UPDATE_INTERVAL diff --git a/tests/components/ptdevices/fixtures/ptdevices_level.json b/tests/components/ptdevices/fixtures/ptdevices_level.json index c69e7049696d19..402992d60a3985 100644 --- a/tests/components/ptdevices/fixtures/ptdevices_level.json +++ b/tests/components/ptdevices/fixtures/ptdevices_level.json @@ -27,6 +27,7 @@ "battery_voltage": 5.69, "battery_status": "good", "battery_status_number": 1, + "external_power": 1, "volume_level": 2387.837753, "volume_level_oz": 80742.4, "max_volume": 1269, diff --git a/tests/components/ptdevices/snapshots/test_binary_sensor.ambr b/tests/components/ptdevices/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000000..354725850c9c52 --- /dev/null +++ b/tests/components/ptdevices/snapshots/test_binary_sensor.ambr @@ -0,0 +1,103 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.home_battery-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.home_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': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEEC0FFEE_battery_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.home_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'battery', + : 'Home Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.home_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.home_external_power-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.home_external_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'External power', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'External power', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEEC0FFEE_external_power', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.home_external_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Home External power', + }), + 'context': , + 'entity_id': 'binary_sensor.home_external_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/ptdevices/test_binary_sensor.py b/tests/components/ptdevices/test_binary_sensor.py new file mode 100644 index 00000000000000..d6ddeeb16e7e06 --- /dev/null +++ b/tests/components/ptdevices/test_binary_sensor.py @@ -0,0 +1,95 @@ +"""Test for PTDevices binary sensors.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.ptdevices.coordinator import UPDATE_INTERVAL +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_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_ptdevices_interface: AsyncMock, + mock_ptdevices_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.ptdevices._PLATFORMS", [Platform.BINARY_SENSOR] + ): + await setup_integration(hass, mock_ptdevices_config_entry) + + await snapshot_platform( + hass, entity_registry, snapshot, mock_ptdevices_config_entry.entry_id + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_battery_status_sensor_states( + hass: HomeAssistant, + mock_ptdevices_interface: AsyncMock, + mock_ptdevices_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test battery status binary sensor state recognition.""" + await hass.config.async_set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + await setup_integration(hass, mock_ptdevices_config_entry) + + # Make sure the battery status is "normal" + assert (state := hass.states.get("binary_sensor.home_battery")) + assert state.state == STATE_OFF + + # Set the new battery status to low + mock_ptdevices_interface.get_data.return_value["body"]["C0FFEEC0FFEE"][ + "battery_status" + ] = "low" + + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Make sure the battery status is on (low) + assert (state := hass.states.get("binary_sensor.home_battery")) + assert state.state == STATE_ON + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_add_remove_binary_sensor( + hass: HomeAssistant, + mock_ptdevices_interface: AsyncMock, + mock_ptdevices_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test handling of missing and new binary sensors.""" + await hass.config.async_set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + await setup_integration(hass, mock_ptdevices_config_entry) + + # Make sure the battery status exists + assert (state := hass.states.get("binary_sensor.home_battery")) + assert state.state != STATE_UNKNOWN + + # Remove the battery_status + mock_ptdevices_interface.get_data.return_value["body"]["C0FFEEC0FFEE"].pop( + "battery_status" + ) + + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Make sure the battery_status is no longer present + assert (state := hass.states.get("binary_sensor.home_battery")) + assert state.state == STATE_UNKNOWN diff --git a/tests/components/ptdevices/test_sensor.py b/tests/components/ptdevices/test_sensor.py index 494fc632e55589..97fc9ab63439c4 100644 --- a/tests/components/ptdevices/test_sensor.py +++ b/tests/components/ptdevices/test_sensor.py @@ -2,16 +2,18 @@ from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.components.ptdevices.coordinator import UPDATE_INTERVAL +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import setup_integration -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -29,3 +31,31 @@ async def test_all_entities( await snapshot_platform( hass, entity_registry, snapshot, mock_ptdevices_config_entry.entry_id ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_add_remove_sensor( + hass: HomeAssistant, + mock_ptdevices_interface: AsyncMock, + mock_ptdevices_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test handling of missing and new sensors.""" + await hass.config.async_set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + await setup_integration(hass, mock_ptdevices_config_entry) + + # Make sure the status exists + assert (state := hass.states.get("sensor.home_status")) + assert state.state != STATE_UNKNOWN + + # Remove the status + mock_ptdevices_interface.get_data.return_value["body"]["C0FFEEC0FFEE"].pop("status") + + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Make sure the status is no longer present + assert (state := hass.states.get("sensor.home_status")) + assert state.state == STATE_UNKNOWN diff --git a/tests/components/smtp/conftest.py b/tests/components/smtp/conftest.py index 4af1b2e703e5e8..27336b6a7cb61d 100644 --- a/tests/components/smtp/conftest.py +++ b/tests/components/smtp/conftest.py @@ -51,11 +51,13 @@ def mock_smtp() -> Generator[MagicMock]: with ( patch( - "homeassistant.components.smtp.helpers.smtplib.SMTP", autospec=True + "homeassistant.components.smtp.config_flow.SMTP_SSL", autospec=True ) as mock_client, + patch("homeassistant.components.smtp.helpers.smtplib.SMTP", new=mock_client), patch("homeassistant.components.smtp.config_flow.SMTP", new=mock_client), ): client = mock_client.return_value + client.cls = mock_client yield client @@ -70,17 +72,6 @@ def mock_make_msgid() -> Generator[None]: yield -@pytest.fixture(name="smtp_ssl") -def mock_smtp_ssl() -> Generator[MagicMock]: - """Mock SMTP.""" - - with patch( - "homeassistant.components.smtp.config_flow.SMTP_SSL", autospec=True - ) as mock_client: - client = mock_client.return_value - yield client - - @pytest.fixture(name="config_entry") def mock_config_entry() -> MockConfigEntry: """Mock smtp configuration entry.""" @@ -89,7 +80,7 @@ def mock_config_entry() -> MockConfigEntry: title="Home Assistant", data=USER_INPUT, options={ - CONF_TIMEOUT: 5, + CONF_TIMEOUT: 1312, }, entry_id="123456789", subentries_data=[ diff --git a/tests/components/smtp/test_config_flow.py b/tests/components/smtp/test_config_flow.py index fd8f82d13e211b..2708daee3e9022 100644 --- a/tests/components/smtp/test_config_flow.py +++ b/tests/components/smtp/test_config_flow.py @@ -11,6 +11,7 @@ CONF_ENCRYPTION, CONF_SENDER_NAME, DOMAIN, + SECTION_OPTIONS, SUBENTRY_TYPE_RECIPIENT, ) from homeassistant.config_entries import ( @@ -37,10 +38,9 @@ from tests.common import MockConfigEntry -@pytest.mark.usefixtures("smtp", "smtp_ssl") @pytest.mark.parametrize("encryption", ["tls", "starttls"]) async def test_form( - hass: HomeAssistant, mock_setup_entry: AsyncMock, encryption: str + hass: HomeAssistant, mock_setup_entry: AsyncMock, encryption: str, smtp: MagicMock ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -54,6 +54,7 @@ async def test_form( { **USER_INPUT, CONF_ENCRYPTION: encryption, + SECTION_OPTIONS: {CONF_TIMEOUT: 60}, }, ) await hass.async_block_till_done() @@ -64,6 +65,7 @@ async def test_form( **USER_INPUT, CONF_ENCRYPTION: encryption, } + assert result["options"] == {CONF_TIMEOUT: 60} assert len(mock_setup_entry.mock_calls) == 1 await hass.async_block_till_done(wait_background_tasks=True) @@ -79,6 +81,8 @@ async def test_form( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Recipient" assert result["unique_id"] == "recipient@example.com" + assert smtp.cls.call_args[0] == ("mail.example.com", 587) + assert smtp.cls.call_args[1]["timeout"] == 60 @pytest.mark.usefixtures("smtp") @@ -98,7 +102,10 @@ async def test_form_already_configured( result = await hass.config_entries.flow.async_configure( result["flow_id"], - USER_INPUT, + { + **USER_INPUT, + SECTION_OPTIONS: {CONF_TIMEOUT: 60}, + }, ) await hass.async_block_till_done() @@ -134,7 +141,10 @@ async def test_form_errors( result = await hass.config_entries.flow.async_configure( result["flow_id"], - USER_INPUT, + { + **USER_INPUT, + SECTION_OPTIONS: {CONF_TIMEOUT: 60}, + }, ) assert result["type"] is FlowResultType.FORM @@ -144,13 +154,17 @@ async def test_form_errors( result = await hass.config_entries.flow.async_configure( result["flow_id"], - USER_INPUT, + { + **USER_INPUT, + SECTION_OPTIONS: {CONF_TIMEOUT: 60}, + }, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Home Assistant" assert result["data"] == USER_INPUT + assert result["options"] == {CONF_TIMEOUT: 60} assert len(mock_setup_entry.mock_calls) == 1 @@ -215,9 +229,8 @@ async def test_options_flow( } -@pytest.mark.usefixtures("smtp") async def test_form_reconfigure( - hass: HomeAssistant, config_entry: MockConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry, smtp: MagicMock ) -> None: """Test reconfigure flow.""" @@ -250,6 +263,7 @@ async def test_form_reconfigure( } assert len(hass.config_entries.async_entries()) == 1 + smtp.cls.assert_called_with("mail.example.com", 587, timeout=1312) @pytest.mark.usefixtures("smtp") @@ -358,8 +372,9 @@ async def test_form_reconfigure_errors( assert len(hass.config_entries.async_entries()) == 1 -@pytest.mark.usefixtures("smtp") -async def test_form_reauth(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: +async def test_form_reauth( + hass: HomeAssistant, config_entry: MockConfigEntry, smtp: MagicMock +) -> None: """Test reauth flow.""" config_entry.add_to_hass(hass) @@ -388,6 +403,7 @@ async def test_form_reauth(hass: HomeAssistant, config_entry: MockConfigEntry) - } assert len(hass.config_entries.async_entries()) == 1 + smtp.cls.assert_called_with("mail.example.com", 587, timeout=1312) @pytest.mark.parametrize( diff --git a/tests/components/sonos/snapshots/test_diagnostics.ambr b/tests/components/sonos/snapshots/test_diagnostics.ambr index 9e3dfcb47e7983..a4b1500de962c6 100644 --- a/tests/components/sonos/snapshots/test_diagnostics.ambr +++ b/tests/components/sonos/snapshots/test_diagnostics.ambr @@ -20,6 +20,7 @@ 'enabled_entities': list([ 'binary_sensor.zone_a_charging', 'binary_sensor.zone_a_microphone', + 'button.zone_a_cancel_announcement', 'media_player.zone_a', 'number.zone_a_audio_delay', 'number.zone_a_balance', @@ -112,6 +113,7 @@ 'enabled_entities': list([ 'binary_sensor.zone_a_charging', 'binary_sensor.zone_a_microphone', + 'button.zone_a_cancel_announcement', 'media_player.zone_a', 'number.zone_a_audio_delay', 'number.zone_a_balance', diff --git a/tests/components/sonos/test_button.py b/tests/components/sonos/test_button.py new file mode 100644 index 00000000000000..60a2617bcd8cf3 --- /dev/null +++ b/tests/components/sonos/test_button.py @@ -0,0 +1,143 @@ +"""Tests for the Sonos button platform.""" + +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from sonos_websocket import CLIP_ID_KEY +from sonos_websocket.exception import SonosWebsocketError + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.media_player import ( + ATTR_MEDIA_ANNOUNCE, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + DOMAIN as MP_DOMAIN, + SERVICE_PLAY_MEDIA, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +CANCEL_ANNOUNCEMENT_BUTTON = "button.zone_a_cancel_announcement" + + +async def _announce_clip(hass: HomeAssistant, content_id: str) -> None: + """Play an announcement clip to set the active clip id.""" + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.zone_a", + ATTR_MEDIA_CONTENT_TYPE: "music", + ATTR_MEDIA_CONTENT_ID: content_id, + ATTR_MEDIA_ANNOUNCE: True, + }, + blocking=True, + ) + + +async def test_cancel_announcement_no_prior( + hass: HomeAssistant, + async_autosetup_sonos, +) -> None: + """Test cancelling when no announcement has been played.""" + with pytest.raises( + ServiceValidationError, match="No active announcement to cancel" + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: CANCEL_ANNOUNCEMENT_BUTTON}, + blocking=True, + ) + + +async def test_cancel_announcement( + hass: HomeAssistant, + async_autosetup_sonos, + sonos_websocket, +) -> None: + """Test cancelling a currently playing announcement.""" + content_id = "http://10.0.0.1:8123/local/sounds/doorbell.mp3" + sonos_websocket.play_clip.return_value = [ + {"success": 1}, + {CLIP_ID_KEY: "clip-123"}, + ] + await _announce_clip(hass, content_id) + + sonos_websocket.cancel_clip = AsyncMock(return_value=[{"success": 1}, {}]) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: CANCEL_ANNOUNCEMENT_BUTTON}, + blocking=True, + ) + sonos_websocket.cancel_clip.assert_called_once_with("clip-123") + + +async def test_cancel_announcement_no_clip_id_from_announce_response( + hass: HomeAssistant, + async_autosetup_sonos, + sonos_websocket, +) -> None: + """Test cancelling fails when the announce response has no clip ID.""" + content_id = "http://10.0.0.1:8123/local/sounds/doorbell.mp3" + sonos_websocket.play_clip.return_value = [{"success": 1}, None] + await _announce_clip(hass, content_id) + + with pytest.raises( + ServiceValidationError, match="No active announcement to cancel" + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: CANCEL_ANNOUNCEMENT_BUTTON}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("cancel_clip_side_effect", "cancel_clip_return", "error_match"), + [ + pytest.param( + SonosWebsocketError("Connection lost"), + None, + "Failed to reach Sonos speaker for announcement: Connection lost", + id="websocket_error", + ), + pytest.param( + None, + [{"success": 0}, {}], + "Cancelling announcement failed", + id="non_success_response", + ), + ], +) +async def test_cancel_announcement_errors( + hass: HomeAssistant, + async_autosetup_sonos, + sonos_websocket, + cancel_clip_side_effect: SonosWebsocketError | None, + cancel_clip_return: list[dict[str, Any]] | None, + error_match: str, +) -> None: + """Test error handling when cancelling an announcement.""" + content_id = "http://10.0.0.1:8123/local/sounds/doorbell.mp3" + sonos_websocket.play_clip.return_value = [ + {"success": 1}, + {CLIP_ID_KEY: "clip-123"}, + ] + await _announce_clip(hass, content_id) + + sonos_websocket.cancel_clip = AsyncMock( + side_effect=cancel_clip_side_effect, + return_value=cancel_clip_return, + ) + with pytest.raises(HomeAssistantError, match=error_match): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: CANCEL_ANNOUNCEMENT_BUTTON}, + blocking=True, + ) diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index 4dac450653474d..18db04c55ca373 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -13,6 +13,7 @@ DidlPlaylistContainer, SearchResult, ) +from soco.exceptions import SoCoUPnPException from sonos_websocket.exception import SonosWebsocketError from syrupy.assertion import SnapshotAssertion @@ -326,6 +327,69 @@ async def test_play_media_library_content_error( ) +@pytest.mark.parametrize( + ("error", "translation_key", "translation_placeholders"), + [ + pytest.param( + OSError("Network down"), + "call_failed", + { + "target": "media_player.zone_a", + "error": "Network down", + }, + id="generic-error", + ), + pytest.param( + SoCoUPnPException("UPnP Error 701 received", "701", ""), + "upnp_call_failed", + { + "target": "media_player.zone_a", + "error": "UPnP Error 701 received", + "error_code": "701", + }, + id="upnp-error", + ), + pytest.param( + SoCoUPnPException("UPnP Error 800 received", "800", ""), + "upnp_call_failed_music_service_unavailable", + { + "target": "media_player.zone_a", + "error": "UPnP Error 800 received", + "error_code": "800", + }, + id="upnp-error-800-music-service-unavailable", + ), + ], +) +async def test_play_media_error_translation( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, + error: Exception, + translation_key: str, + translation_placeholders: dict[str, str], +) -> None: + """Test play_media surfaces translated error details for failures.""" + soco_mock = soco_factory.mock_list.get("192.168.42.2") + soco_mock.play_uri.side_effect = error + + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.zone_a", + ATTR_MEDIA_CONTENT_TYPE: "track", + ATTR_MEDIA_CONTENT_ID: _track_url, + ATTR_MEDIA_ENQUEUE: MediaPlayerEnqueue.REPLACE, + }, + blocking=True, + ) + + assert err.value.translation_key == translation_key + assert err.value.translation_placeholders == translation_placeholders + + _track_url = "S://192.168.42.100/music/iTunes/The%20Beatles/A%20Hard%20Day%2fs%I%20Should%20Have%20Known%20Better.mp3" diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 6a82aa938201af..4496af6cc7f2d2 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -15,7 +15,6 @@ Sensor, SmartDetectObjectType, ) -from uiprotect.data.nvr import EventMetadata from uiprotect.data.public_devices import SensorFeatureCapability from uiprotect.websocket import WebsocketState @@ -51,9 +50,11 @@ assert_entity_counts, ids_from_device_description, init_entry, + make_public_light, make_public_sensor, public_device_ws_message, remove_entities, + setup_public_light, setup_public_sensor, ) @@ -118,6 +119,7 @@ async def test_binary_sensor_setup_light( ) -> None: """Test binary_sensor entity setup for light devices.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 8) @@ -729,47 +731,38 @@ async def test_binary_sensor_update_motion( async def test_binary_sensor_update_light_motion( - hass: HomeAssistant, ufp: MockUFPFixture, light: Light, fixed_now: datetime + hass: HomeAssistant, ufp: MockUFPFixture, light: Light ) -> None: - """Test binary_sensor motion entity.""" + """Test the light motion binary_sensor reads PIR motion from the public API.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 8) _, entity_id = await ids_from_device_description( hass, Platform.BINARY_SENSOR, light, LIGHT_SENSOR_WRITE[1] ) + assert hass.states.get(entity_id).state == STATE_OFF - event_metadata = EventMetadata(light_id=light.id) - event = Event( - model=ModelType.EVENT, - id="test_event_id", - type=EventType.MOTION_LIGHT, - start=fixed_now - timedelta(seconds=1), - end=None, - score=100, - smart_detect_types=[], - smart_detect_event_ids=[], - metadata=event_metadata, - api=ufp.api, - ) + public = make_public_light(light, is_pir_motion_detected=True) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() - new_light = light.model_copy() - new_light.is_pir_motion_detected = True - new_light.last_motion_event_id = event.id + assert hass.states.get(entity_id).state == STATE_ON - mock_msg = Mock() - mock_msg.changed_data = {} - mock_msg.new_obj = event - ufp.api.bootstrap.lights = {new_light.id: new_light} - ufp.api.bootstrap.events = {event.id: event} - ufp.ws_msg(mock_msg) - await hass.async_block_till_done() +async def test_binary_sensor_light_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """The migrated light binary_sensors are unavailable without a public object.""" - state = hass.states.get(entity_id) - assert state - assert state.state == STATE_ON + await init_entry(hass, ufp, [light]) + + for description in LIGHT_SENSOR_WRITE: + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, light, description + ) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE async def test_binary_sensor_update_mount_type_window( diff --git a/tests/components/unifiprotect/test_light.py b/tests/components/unifiprotect/test_light.py index ee094d61f422b0..b224e9ada64d64 100644 --- a/tests/components/unifiprotect/test_light.py +++ b/tests/components/unifiprotect/test_light.py @@ -1,9 +1,8 @@ """Test the UniFi Protect light platform.""" -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock -from uiprotect.data import Light -from uiprotect.data.types import LEDLevel +from uiprotect.data import DeviceState, Light from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION @@ -12,6 +11,7 @@ ATTR_ENTITY_ID, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, Platform, ) from homeassistant.core import HomeAssistant @@ -22,7 +22,10 @@ adopt_devices, assert_entity_counts, init_entry, + make_public_light, + public_device_ws_message, remove_entities, + setup_public_light, ) @@ -48,6 +51,7 @@ async def test_light_setup( ) -> None: """Test light entity setup.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) @@ -67,27 +71,71 @@ async def test_light_setup( async def test_light_update( hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light ) -> None: - """Test light entity update.""" + """Test the light reads on/off and brightness from a public WS update.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) - new_light = light.model_copy() - new_light.is_light_on = True - new_light.light_device_settings.led_level = LEDLevel(3) + # Divergent public values (on, led_level 3 -> 128) prove the read path. + public = make_public_light(light, is_light_on=True, led_level=3) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() - mock_msg = Mock() - mock_msg.changed_data = {} - mock_msg.new_obj = new_light + state = hass.states.get("light.test_light") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_BRIGHTNESS] == 128 + + +async def test_light_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light +) -> None: + """The light is unavailable without a public object.""" - ufp.api.bootstrap.lights = {new_light.id: new_light} - ufp.ws_msg(mock_msg) + await init_entry(hass, ufp, [light, unadopted_light]) + assert_entity_counts(hass, Platform.LIGHT, 1, 1) + + state = hass.states.get("light.test_light") + assert state + assert state.state == STATE_UNAVAILABLE + + +async def test_light_unavailable_on_public_disconnect( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light +) -> None: + """Light availability follows the public object's connection state.""" + + setup_public_light(ufp) + await init_entry(hass, ufp, [light, unadopted_light]) + + entity_id = "light.test_light" + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + public = make_public_light(light, state=DeviceState.DISCONNECTED) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + +async def test_light_brightness_none( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light +) -> None: + """A light without a public LED level reports no brightness.""" + + setup_public_light(ufp) + await init_entry(hass, ufp, [light, unadopted_light]) + + public = make_public_light(light, is_light_on=True) + public.light_device_settings.led_level = None + ufp.devices_ws_subscription(public_device_ws_message(public)) await hass.async_block_till_done() state = hass.states.get("light.test_light") assert state assert state.state == STATE_ON - assert state.attributes[ATTR_BRIGHTNESS] == 128 + assert state.attributes[ATTR_BRIGHTNESS] is None async def test_light_turn_on( @@ -98,6 +146,7 @@ async def test_light_turn_on( light._api = ufp.api light.api.update_light_public = AsyncMock() + setup_public_light(ufp) await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) @@ -120,6 +169,7 @@ async def test_light_turn_on_with_brightness( light._api = ufp.api light.api.update_light_public = AsyncMock() + setup_public_light(ufp) await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) @@ -146,6 +196,7 @@ async def test_light_turn_off( light._api = ufp.api light.api.update_light_public = AsyncMock() + setup_public_light(ufp) await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) diff --git a/tests/components/unifiprotect/test_number.py b/tests/components/unifiprotect/test_number.py index b1b8464d07f48e..358413c4d995b1 100644 --- a/tests/components/unifiprotect/test_number.py +++ b/tests/components/unifiprotect/test_number.py @@ -167,8 +167,9 @@ async def test_number_setup_camera_missing_attr( async def test_number_light_sensitivity( hass: HomeAssistant, ufp: MockUFPFixture, light: Light ) -> None: - """Test sensitivity number entity for lights.""" + """Test sensitivity number entity for lights (public API).""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.NUMBER, 2, 2) @@ -180,7 +181,7 @@ async def test_number_light_sensitivity( ) with patch_ufp_method( - light, "set_sensitivity", new_callable=AsyncMock + light, "set_sensitivity_public", new_callable=AsyncMock ) as mock_method: await hass.services.async_call( "number", @@ -192,6 +193,39 @@ async def test_number_light_sensitivity( mock_method.assert_called_once_with(15.0) +async def test_number_light_sensitivity_public_value( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """Sensitivity reads from the public object and refreshes on a public WS update.""" + + setup_public_light(ufp) + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, light, LIGHT_NUMBERS[0] + ) + + # A value the private fixture (45) would not produce proves the public source. + public = make_public_light(light, pir_sensitivity=30) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "30" + + +async def test_number_light_sensitivity_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """The migrated sensitivity number is unavailable without a public object.""" + + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, light, LIGHT_NUMBERS[0] + ) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + async def test_number_light_duration( hass: HomeAssistant, ufp: MockUFPFixture, light: Light ) -> None: diff --git a/tests/components/unifiprotect/test_select.py b/tests/components/unifiprotect/test_select.py index d270c1e38242b7..bb221d448cee0b 100644 --- a/tests/components/unifiprotect/test_select.py +++ b/tests/components/unifiprotect/test_select.py @@ -42,6 +42,7 @@ ATTR_ENTITY_ID, ATTR_OPTION, STATE_UNAVAILABLE, + STATE_UNKNOWN, Platform, ) from homeassistant.core import HomeAssistant @@ -56,9 +57,11 @@ ids_from_device_description, init_entry, make_public_camera, + make_public_light, public_device_ws_message, remove_entities, setup_public_camera, + setup_public_light, ) @@ -113,6 +116,7 @@ async def test_select_setup_light( """Test select entity setup for light devices.""" light.light_mode_settings.enable_at = LightModeEnableType.DARK + setup_public_light(ufp) await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.SELECT, 2, 2) @@ -415,8 +419,9 @@ async def test_select_update_doorbell_message( async def test_select_set_option_light_motion( hass: HomeAssistant, ufp: MockUFPFixture, light: Light ) -> None: - """Test Light Mode select.""" + """Test Light Mode select (public API).""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.SELECT, 2, 2) @@ -425,7 +430,7 @@ async def test_select_set_option_light_motion( ) with patch_ufp_method( - light, "set_light_settings", new_callable=AsyncMock + light, "set_light_mode_public", new_callable=AsyncMock ) as mock_method: await hass.services.async_call( "select", @@ -437,6 +442,64 @@ async def test_select_set_option_light_motion( mock_method.assert_called_once_with(LightModeType.MANUAL, enable_at=None) +async def test_select_light_motion_public_value( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """Light Mode select reads from the public object and refreshes on a WS update.""" + + setup_public_light(ufp) + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, light, LIGHT_SELECTS[0] + ) + assert hass.states.get(entity_id).state == "motion" + + # The private fixture is full-time motion; when_dark proves the public source. + public = make_public_light( + light, + light_mode=LightModeType.WHEN_DARK, + light_mode_enable_at=LightModeEnableType.DARK, + ) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "when_dark" + + +async def test_select_light_motion_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """The migrated light motion select is unavailable without a public object.""" + + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, light, LIGHT_SELECTS[0] + ) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + +async def test_select_light_motion_none( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """A light that does not report a public mode leaves the select unknown.""" + + setup_public_light(ufp) + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, light, LIGHT_SELECTS[0] + ) + + public = make_public_light(light) + public.light_mode_settings.mode = None + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNKNOWN + + async def test_select_set_option_light_camera( hass: HomeAssistant, ufp: MockUFPFixture, light: Light, camera: Camera ) -> None: diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index 499e66ff68c7df..ab8e9c9fcc3f9b 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -11,11 +11,13 @@ DeviceState, Event, EventType, + Light, ModelType, Sensor, ) from uiprotect.data.nvr import EventMetadata from uiprotect.data.public_devices import SensorFeatureCapability +from uiprotect.utils import convert_to_datetime from uiprotect.websocket import WebsocketState from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION @@ -23,6 +25,7 @@ ALL_DEVICES_SENSORS, CAMERA_DISABLED_SENSORS, CAMERA_SENSORS, + LIGHT_SENSORS, MOTION_TRIP_SENSORS, NVR_DISABLED_SENSORS, NVR_SENSORS, @@ -45,10 +48,12 @@ enable_entity, ids_from_device_description, init_entry, + make_public_light, make_public_sensor, public_device_ws_message, remove_entities, reset_objects, + setup_public_light, setup_public_sensor, time_changed, ) @@ -704,6 +709,13 @@ async def test_aiport_no_sensor_entities( entities = er.async_entries_for_config_entry(entity_registry, ufp.entry.entry_id) assert not [e for e in entities if e.unique_id.startswith(f"{aiport.mac}_")] + # Check no camera-specific sensors like motion detection exist + for entity in entities: + if entity.domain == Platform.SENSOR: + # Camera-specific sensors should not exist for AI Port + assert "detected_object" not in entity.unique_id + assert "last_motion" not in entity.unique_id + async def test_aiport_no_sensor_entities_on_runtime_adopt( hass: HomeAssistant, @@ -721,3 +733,43 @@ async def test_aiport_no_sensor_entities_on_runtime_adopt( entities = er.async_entries_for_config_entry(entity_registry, ufp.entry.entry_id) assert not [e for e in entities if e.unique_id.startswith(f"{aiport.mac}_")] + + +async def test_sensor_light_last_motion_public( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """The light's last-motion timestamp reads from the public API.""" + + setup_public_light(ufp) + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, light, LIGHT_SENSORS[0] + ) + await enable_entity(hass, ufp.entry.entry_id, entity_id) + + # A value the private fixture would not produce proves the public source. + last_motion_ms = 1700000000000 + public = make_public_light(light, last_motion_ms=last_motion_ms) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert ( + hass.states.get(entity_id).state + == convert_to_datetime(last_motion_ms).isoformat() + ) + + +async def test_sensor_light_last_motion_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """The migrated last-motion sensor is unavailable without a public object.""" + + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, light, LIGHT_SENSORS[0] + ) + await enable_entity(hass, ufp.entry.entry_id, entity_id) + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index 3d2ca313c0cd24..a0b3f5a371d8f7 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -24,7 +24,14 @@ PRIVACY_MODE_SWITCH, ProtectSwitchEntityDescription, ) -from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, STATE_OFF, Platform +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -37,7 +44,10 @@ enable_entity, ids_from_device_description, init_entry, + make_public_light, + public_device_ws_message, remove_entities, + setup_public_light, ) CAMERA_SWITCHES_BASIC = [ @@ -139,6 +149,7 @@ async def test_switch_setup_light( ) -> None: """Test switch entity setup for light devices.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.SWITCH, 4, 3) @@ -269,6 +280,7 @@ async def test_switch_light_status( ) -> None: """Tests status light switch for lights.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.SWITCH, 4, 3) @@ -279,7 +291,7 @@ async def test_switch_light_status( ) with patch_ufp_method( - light, "set_status_light", new_callable=AsyncMock + light, "set_status_light_public", new_callable=AsyncMock ) as mock_method: await hass.services.async_call( "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -294,6 +306,40 @@ async def test_switch_light_status( mock_method.assert_called_with(False) +async def test_switch_light_status_public_value( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """Status light switch reads from the public object and refreshes on a WS update.""" + + setup_public_light(ufp) + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, light, LIGHT_SWITCHES[1] + ) + assert hass.states.get(entity_id).state == STATE_OFF + + # The private fixture has the indicator disabled; the public ON proves the source. + public = make_public_light(light, is_indicator_enabled=True) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_ON + + +async def test_switch_light_status_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """The migrated status light switch is unavailable without a public object.""" + + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, light, LIGHT_SWITCHES[1] + ) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + async def test_switch_camera_ssh( hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ) -> None: @@ -569,6 +615,7 @@ async def test_switch_turn_on_client_error( ) -> None: """Test switch turn on with ClientError raises HomeAssistantError.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) description = LIGHT_SWITCHES[1] @@ -580,7 +627,7 @@ async def test_switch_turn_on_client_error( with ( patch_ufp_method( light, - "set_status_light", + "set_status_light_public", new_callable=AsyncMock, side_effect=ClientError("Test error"), ), @@ -596,6 +643,7 @@ async def test_switch_turn_on_not_authorized( ) -> None: """Test switch turn on with NotAuthorized raises HomeAssistantError.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) description = LIGHT_SWITCHES[1] @@ -607,7 +655,7 @@ async def test_switch_turn_on_not_authorized( with ( patch_ufp_method( light, - "set_status_light", + "set_status_light_public", new_callable=AsyncMock, side_effect=NotAuthorized("Not authorized"), ), diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 218f6958dee58b..4ae15eef9acf29 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -15,6 +15,8 @@ Event, EventType, Light, + LightModeEnableType, + LightModeType, ModelType, MountType, ProtectAdoptableDeviceModel, @@ -29,6 +31,7 @@ PublicHdrMode, PublicLight, PublicLightDeviceSettings, + PublicLightModeSettings, PublicSensor, PublicSensorLeakSettings, PublicSensorMotionSettingsRead, @@ -335,29 +338,65 @@ def make_public_light( light: Light, *, state: DeviceState | None = None, + is_light_on: bool | None = None, + is_dark: bool | None = None, + is_pir_motion_detected: bool | None = None, + last_motion_ms: int | None = None, + led_level: int | None = None, pir_duration_ms: int | None = None, + pir_sensitivity: int | None = None, + is_indicator_enabled: bool | None = None, + light_mode: LightModeType | None = None, + light_mode_enable_at: LightModeEnableType | None = None, ) -> Mock: - """Build a public-API light for the migrated PIR auto-shutoff duration number. + """Build a public-API light mirroring the private fixture's migrated fields. - ``light_device_settings`` mirrors the private fixture (the public API reports - ``pir_duration`` in milliseconds); ``pir_duration_ms`` overrides it so a test - can assert a value the private object would not produce. + Every field the FloodLight entities read over the public API is mirrored from + the private light; each ``*`` override lets a test set a value the private + object would not produce, proving the entity reads the public source. The + public API reports ``pir_duration`` and ``last_motion`` in milliseconds. """ lds = light.light_device_settings + lms = light.light_mode_settings public = Mock(spec=PublicLight) public.id = light.id public.mac = light.mac public.model = ModelType.LIGHT public.state = DeviceState[light.state.name] if state is None else state + public.is_light_on = light.is_light_on if is_light_on is None else is_light_on + public.is_dark = light.is_dark if is_dark is None else is_dark + public.is_pir_motion_detected = ( + light.is_pir_motion_detected + if is_pir_motion_detected is None + else is_pir_motion_detected + ) + if last_motion_ms is not None: + public.last_motion = last_motion_ms + elif light.last_motion is not None: + public.last_motion = round(light.last_motion.timestamp() * 1000) + else: + public.last_motion = None + public.light_mode_settings = PublicLightModeSettings( + mode=lms.mode if light_mode is None else light_mode, + enable_at=( + lms.enable_at if light_mode_enable_at is None else light_mode_enable_at + ), + ) public.light_device_settings = PublicLightDeviceSettings( - is_indicator_enabled=lds.is_indicator_enabled, - led_level=lds.led_level, + is_indicator_enabled=( + lds.is_indicator_enabled + if is_indicator_enabled is None + else is_indicator_enabled + ), + led_level=lds.led_level if led_level is None else led_level, pir_duration=( round(lds.pir_duration.total_seconds() * 1000) if pir_duration_ms is None else pir_duration_ms ), - pir_sensitivity=lds.pir_sensitivity, + pir_sensitivity=( + lds.pir_sensitivity if pir_sensitivity is None else pir_sensitivity + ), ) return public diff --git a/tests/components/vibration/__init__.py b/tests/components/vibration/__init__.py new file mode 100644 index 00000000000000..7bb5d6ed877e3f --- /dev/null +++ b/tests/components/vibration/__init__.py @@ -0,0 +1 @@ +"""Tests for the vibration integration.""" diff --git a/tests/components/vibration/test_trigger.py b/tests/components/vibration/test_trigger.py new file mode 100644 index 00000000000000..98e355952dec43 --- /dev/null +++ b/tests/components/vibration/test_trigger.py @@ -0,0 +1,200 @@ +"""Test vibration trigger.""" + +from typing import Any + +import pytest + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from tests.components.common import ( + TriggerStateDescription, + assert_trigger_behavior_all, + assert_trigger_behavior_each, + assert_trigger_behavior_first, + assert_trigger_options_supported, + parametrize_target_entities, + parametrize_trigger_states, + target_entities, +) + + +@pytest.fixture +async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple binary sensor entities associated with different targets.""" + return await target_entities(hass, "binary_sensor") + + +@pytest.mark.parametrize( + ("trigger_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("vibration.detected", {}, True, True), + ("vibration.cleared", {}, True, True), + ], +) +async def test_vibration_trigger_options_validation( + hass: HomeAssistant, + trigger_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that vibration triggers support the expected options.""" + await assert_trigger_options_supported( + hass, + trigger_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="vibration.detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.VIBRATION + }, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="vibration.cleared", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.VIBRATION + }, + trigger_from_none=False, + ), + ], +) +async def test_vibration_trigger_binary_sensor_behavior_each( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test vibration trigger fires for binary_sensor entities with device_class vibration.""" + await assert_trigger_behavior_each( + hass, + target_entities=target_binary_sensors, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="vibration.detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.VIBRATION + }, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="vibration.cleared", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.VIBRATION + }, + trigger_from_none=False, + ), + ], +) +async def test_vibration_trigger_binary_sensor_behavior_first( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test vibration trigger fires on the first binary_sensor state change.""" + await assert_trigger_behavior_first( + hass, + target_entities=target_binary_sensors, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="vibration.detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.VIBRATION + }, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="vibration.cleared", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.VIBRATION + }, + trigger_from_none=False, + ), + ], +) +async def test_vibration_trigger_binary_sensor_behavior_all( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test vibration trigger fires when all binary_sensors have changed state.""" + await assert_trigger_behavior_all( + hass, + target_entities=target_binary_sensors, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) diff --git a/tests/snapshots/test_bootstrap.ambr b/tests/snapshots/test_bootstrap.ambr index 561c4a060845ca..9b296dffe0df21 100644 --- a/tests/snapshots/test_bootstrap.ambr +++ b/tests/snapshots/test_bootstrap.ambr @@ -100,6 +100,7 @@ 'update', 'vacuum', 'valve', + 'vibration', 'wake_word', 'water_heater', 'weather', @@ -209,6 +210,7 @@ 'update', 'vacuum', 'valve', + 'vibration', 'wake_word', 'water_heater', 'weather',