diff --git a/CODEOWNERS b/CODEOWNERS index b627d73220ba03..43e14c41d8bfe3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1556,6 +1556,8 @@ CLAUDE.md @home-assistant/core /tests/components/refoss/ @ashionky /homeassistant/components/rehlko/ @bdraco @peterager /tests/components/rehlko/ @bdraco @peterager +/homeassistant/components/rejseplanen/ @Jawar19 +/tests/components/rejseplanen/ @Jawar19 /homeassistant/components/remember_the_milk/ @MartinHjelmare /tests/components/remember_the_milk/ @MartinHjelmare /homeassistant/components/remote/ @home-assistant/core diff --git a/homeassistant/components/alexa_devices/services.py b/homeassistant/components/alexa_devices/services.py index 2abf2563772d31..a755e61fe73627 100644 --- a/homeassistant/components/alexa_devices/services.py +++ b/homeassistant/components/alexa_devices/services.py @@ -46,8 +46,9 @@ def async_get_entry_id_for_service_call( ) -> tuple[dr.DeviceEntry, AmazonConfigEntry]: """Get the entry ID related to a service call (by device ID).""" config_entry: AmazonConfigEntry + # Callers read the device's serial number, which only a main device has device, config_entry = service.async_get_device_and_config_entry( - call.hass, DOMAIN, call.data[ATTR_DEVICE_ID] + call.hass, DOMAIN, call.data[ATTR_DEVICE_ID], include_child_devices=False ) return (device, config_entry) diff --git a/homeassistant/components/bsblan/services.py b/homeassistant/components/bsblan/services.py index 622c615ce2c888..2685cad5d60739 100644 --- a/homeassistant/components/bsblan/services.py +++ b/homeassistant/components/bsblan/services.py @@ -127,7 +127,7 @@ def _build_weekly_schedule_days( def _resolve_config_entry( service_call: ServiceCall, -) -> tuple[BSBLanConfigEntry, dr.DeviceEntry]: +) -> tuple[BSBLanConfigEntry, dr.AnyDeviceEntry]: """Resolve device_id from a service call into a loaded BSBLAN config entry.""" config_entry: BSBLanConfigEntry device, config_entry = service.async_get_device_and_config_entry( @@ -136,12 +136,12 @@ def _resolve_config_entry( return config_entry, device -def _device_name(device_entry: dr.DeviceEntry) -> str: +def _device_name(device_entry: dr.AnyDeviceEntry) -> str: """Return the best available display name for a device.""" return device_entry.name_by_user or device_entry.name or device_entry.id -def _ensure_water_heater_device(device_entry: dr.DeviceEntry) -> None: +def _ensure_water_heater_device(device_entry: dr.AnyDeviceEntry) -> None: """Validate the service targets the water heater sub-device.""" for domain, identifier in device_entry.identifiers: if domain == DOMAIN and identifier.endswith("-water-heater"): diff --git a/homeassistant/components/device_automation/helpers.py b/homeassistant/components/device_automation/helpers.py index 6b91465a7e42da..b34b50df0f6d53 100644 --- a/homeassistant/components/device_automation/helpers.py +++ b/homeassistant/components/device_automation/helpers.py @@ -130,24 +130,17 @@ async def async_validate_device_automation_config( ) # Find a config entry with the same domain as the device automation - device_config_entry = None - for entry_id in device.config_entries: - if ( - not (entry := hass.config_entries.async_get_entry(entry_id)) - or entry.domain != validated_config[CONF_DOMAIN] - ): - continue - device_config_entry = entry - break - - if not device_config_entry: + _, config_entry = dr.async_get_device_and_config_entry_for_domain( + hass, device.id, domain=validated_config[CONF_DOMAIN] + ) + if not config_entry: # There's no config entry with the same domain as the device automation raise InvalidDeviceAutomationConfig( f"Device '{validated_config[CONF_DEVICE_ID]}' has no config entry from " f"domain '{validated_config[CONF_DOMAIN]}'" ) - if not await hass.config_entries.async_wait_component(device_config_entry): + if not await hass.config_entries.async_wait_component(config_entry): # The component could not be loaded, skip the dynamic validation return validated_config diff --git a/homeassistant/components/energyzero/services.py b/homeassistant/components/energyzero/services.py index f7f5a338c05ac2..bd191bbff908c5 100644 --- a/homeassistant/components/energyzero/services.py +++ b/homeassistant/components/energyzero/services.py @@ -86,6 +86,8 @@ def __serialize_prices( { "price": price, "timestamp": str(time_range.start_including), + "start": str(time_range.start_including), + "end": str(time_range.end_excluding), } for price_data in prices for time_range, price in price_data.prices.items() @@ -111,12 +113,20 @@ async def __get_prices( local_tz = ZoneInfo(call.hass.config.time_zone) start_date, start_datetime = __get_date(call.data.get(ATTR_START), local_tz) end_date, end_datetime = __get_date(call.data.get(ATTR_END), local_tz) + filter_start = start_datetime or dt_util.as_utc( dt_util.start_of_local_day(start_date) ) - filter_end = end_datetime or dt_util.as_utc( - dt_util.start_of_local_day(end_date + timedelta(days=1)) - ) + end_start = end_datetime or dt_util.as_utc(dt_util.start_of_local_day(end_date)) + + if filter_start == end_start: + day = filter_start.astimezone(local_tz).date() + filter_start = dt_util.as_utc(dt_util.start_of_local_day(day)) + filter_end = dt_util.as_utc(dt_util.start_of_local_day(day + timedelta(days=1))) + else: + filter_end = end_datetime or dt_util.as_utc( + dt_util.start_of_local_day(end_date + timedelta(days=1)) + ) if filter_end <= filter_start: raise ServiceValidationError( diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 948edc114ec438..9e6e53093e0c8e 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["pyenphase"], "quality_scale": "platinum", - "requirements": ["pyenphase==4.0.1"], + "requirements": ["pyenphase==4.0.3"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/file_upload/__init__.py b/homeassistant/components/file_upload/__init__.py index 9562b77e337a59..e884d0362e3ce9 100644 --- a/homeassistant/components/file_upload/__init__.py +++ b/homeassistant/components/file_upload/__init__.py @@ -118,6 +118,7 @@ async def _receive_file_field( queue: SimpleQueue[tuple[bytes, asyncio.Future[None] | None] | None] = SimpleQueue() def _sync_queue_consumer() -> None: + file_path.parent.mkdir() with file_path.open("wb") as file_handle: while True: if (_chunk_future := queue.get()) is None: @@ -128,6 +129,7 @@ def _sync_queue_consumer() -> None: file_handle.write(_chunk) fut: asyncio.Future[None] | None = None + cancelled: asyncio.CancelledError | None = None try: fut = hass.async_add_executor_job(_sync_queue_consumer) chunks_sent = 0 @@ -143,23 +145,31 @@ def _sync_queue_consumer() -> None: if fut.done(): # The executor job failed break + except asyncio.CancelledError as err: + # Remember a cancellation from the streaming loop so the join below re-raises + # it instead of a later writer error. + cancelled = err + raise finally: # Always terminate the queue consumer, also if the stream raised or the task # was cancelled, otherwise awaiting the consumer future deadlocks. queue.put_nowait(None) if fut is not None: # The executor thread can't be cancelled and is guaranteed to finish once - # it reads the sentinel queued above. Await it even if this task is - # cancelled: awaiting only through a shield keeps the executor future - # itself uncancelled, so the thread is fully done (file written and closed) - # before the caller cleans up. Re-raise any cancellation after. - cancelled: asyncio.CancelledError | None = None + # it reads the sentinel queued above. Wait for it even if this task is + # cancelled: asyncio.wait neither cancels the future nor raises its + # exception, so the thread is fully done (file written and closed) before + # the caller cleans up. The loop re-waits through repeated cancellations. while not fut.done(): try: - await asyncio.shield(fut) + await asyncio.wait({fut}) except asyncio.CancelledError as err: cancelled = err if cancelled is not None: + # A cancellation takes precedence over a writer error; retrieve the + # writer result so its exception isn't flagged as never-retrieved. + if not fut.cancelled(): + fut.exception() raise cancelled fut.result() @@ -216,12 +226,12 @@ async def _upload_file(self, request: web.Request) -> web.Response: file_dir = file_upload_data.file_dir(file_id) try: - await hass.async_add_executor_job(file_dir.mkdir) await _receive_file_field(hass, file_field_reader, file_dir / filename) except Exception, asyncio.CancelledError: - # Upload failed: _receive_file_field has joined the writer and closed the - # file, so removing the directory now cannot race the writer. ignore_errors - # covers a failure that happened before the directory was created. + # Upload failed: _receive_file_field has joined the writer, which created + # the directory and closed the file, so removing the directory now cannot + # race the writer. ignore_errors covers a failure that happened before the + # directory was created. await hass.async_add_executor_job( lambda: shutil.rmtree(file_dir, ignore_errors=True) ) diff --git a/homeassistant/components/flexit/__init__.py b/homeassistant/components/flexit/__init__.py index dd8ba1ca7ae4bb..bf848b74b24704 100644 --- a/homeassistant/components/flexit/__init__.py +++ b/homeassistant/components/flexit/__init__.py @@ -12,7 +12,11 @@ from .const import CONF_BAUDRATE, CONF_UNIT, DEFAULT_PORT, TYPE_SERIAL from .coordinator import FlexitConfigEntry, FlexitDataCoordinator -_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.CLIMATE] +_PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.SENSOR, +] def create_modbus_params( diff --git a/homeassistant/components/flexit/sensor.py b/homeassistant/components/flexit/sensor.py new file mode 100644 index 00000000000000..89256f051f6458 --- /dev/null +++ b/homeassistant/components/flexit/sensor.py @@ -0,0 +1,117 @@ +"""Sensor platform for the Flexit integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import override + +from flexit_modbus import Measurements + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import FlexitConfigEntry, FlexitDataCoordinator +from .entity import FlexitEntity + + +@dataclass(kw_only=True, frozen=True) +class FlexitSensorEntityDescription(SensorEntityDescription): + """Describe a Flexit sensor entity.""" + + value_fn: Callable[[Measurements], StateType] + + +SENSORS: tuple[FlexitSensorEntityDescription, ...] = ( + FlexitSensorEntityDescription( + key="air_filter_operating_time", + translation_key="air_filter_operating_time", + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTime.HOURS, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, + value_fn=lambda measurements: measurements.filter_running_hours, + ), + FlexitSensorEntityDescription( + key="heat_exchanger_regulation", + translation_key="heat_exchanger_regulation", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda measurements: measurements.heat_exchanger_regulation, + ), + FlexitSensorEntityDescription( + key="electric_heater_regulation", + translation_key="electric_heater_regulation", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda measurements: measurements.electric_heater_regulation, + ), + FlexitSensorEntityDescription( + key="cooling_regulation", + translation_key="cooling_regulation", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda measurements: measurements.cooling_regulation, + ), + FlexitSensorEntityDescription( + key="outdoor_air_temperature", + translation_key="outdoor_air_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda measurements: measurements.outdoor_air_temperature, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: FlexitConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Flexit sensor entities.""" + coordinator = entry.runtime_data + async_add_entities( + FlexitSensor(coordinator, description) for description in SENSORS + ) + + +class FlexitSensor(FlexitEntity, SensorEntity): + """Representation of a Flexit sensor.""" + + entity_description: FlexitSensorEntityDescription + + def __init__( + self, + coordinator: FlexitDataCoordinator, + entity_description: FlexitSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + assert coordinator.config_entry is not None + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}-{entity_description.key}" + ) + + @property + @override + def native_value(self) -> StateType: + """Return the sensor value.""" + return self.entity_description.value_fn(self.coordinator.device.measurements) diff --git a/homeassistant/components/flexit/strings.json b/homeassistant/components/flexit/strings.json index 732ccbeeda0520..bc570060c234f1 100644 --- a/homeassistant/components/flexit/strings.json +++ b/homeassistant/components/flexit/strings.json @@ -64,6 +64,23 @@ "filter_alarm": { "name": "Filter alarm" } + }, + "sensor": { + "air_filter_operating_time": { + "name": "Air filter operating time" + }, + "cooling_regulation": { + "name": "Cooling regulation" + }, + "electric_heater_regulation": { + "name": "Electric heater regulation" + }, + "heat_exchanger_regulation": { + "name": "Heat exchanger regulation" + }, + "outdoor_air_temperature": { + "name": "Outdoor air temperature" + } } }, "issues": { diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 94d3ce5a8018c6..875f31deef8aea 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -22,5 +22,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260826.6"] + "requirements": ["home-assistant-frontend==20260826.7"] } diff --git a/homeassistant/components/hassio/services.py b/homeassistant/components/hassio/services.py index c029052be2a6f0..079e63454caf6f 100644 --- a/homeassistant/components/hassio/services.py +++ b/homeassistant/components/hassio/services.py @@ -452,8 +452,9 @@ def async_register_network_storage_services( async def async_mount_reload(service: ServiceCall) -> None: """Handle service calls for Hass.io.""" + # A mount is always a main device, and the check below reads its model device, _ = async_get_device_and_config_entry( - hass, DOMAIN, service.data[ATTR_DEVICE_ID] + hass, DOMAIN, service.data[ATTR_DEVICE_ID], include_child_devices=False ) if device.name is None or device.model != SupervisorEntityModel.MOUNT: diff --git a/homeassistant/components/hive/config_flow.py b/homeassistant/components/hive/config_flow.py index e5b6fe88b63f66..60a394cc0197d0 100644 --- a/homeassistant/components/hive/config_flow.py +++ b/homeassistant/components/hive/config_flow.py @@ -50,12 +50,14 @@ async def async_step_user( # Login to Hive with user data. if user_input is not None: self.data.update(user_input) + username = self.data[CONF_USERNAME].lower() self.hive_auth = Auth( - username=self.data[CONF_USERNAME], password=self.data[CONF_PASSWORD] + username=username, + password=self.data[CONF_PASSWORD], ) # Get user from existing entry and abort if already setup - await self.async_set_unique_id(self.data[CONF_USERNAME]) + await self.async_set_unique_id(username) if self.context["source"] != SOURCE_REAUTH: self._abort_if_unique_id_configured() diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index b1a41e39516958..78fcc7ffdcd72b 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -329,6 +329,28 @@ def move_cover(self, value: int) -> None: params = {ATTR_ENTITY_ID: self.entity_id, ATTR_POSITION: value} self.async_call_service(COVER_DOMAIN, SERVICE_SET_COVER_POSITION, params, value) + @callback + def _async_update_target_position_while_moving( + self, state: str, current_position: int + ) -> None: + """Aim the target position at the end of travel while moving. + + The Home app reads the direction of travel from the target position + relative to the current one, so a target the cover has already passed + makes it report the opposite direction. Only a passed target is + replaced: one the cover is still travelling towards, or has just + reached, is what HomeKit asked for. + """ + if not self.features & CoverEntityFeature.SET_POSITION: + # Tilt-only covers lock the target position to closed. + return + target_position = self.char_target_position.value + if state == CoverState.OPENING: + if target_position < current_position: + self.char_target_position.set_value(100) + elif state == CoverState.CLOSING and target_position > current_position: + self.char_target_position.set_value(0) + @callback @override def async_update_state(self, new_state: State) -> None: @@ -339,9 +361,11 @@ def async_update_state(self, new_state: State) -> None: if isinstance(current_position, (float, int)): current_position = int(current_position) self.char_current_position.set_value(current_position) - # Writing target_position on a moving cover - # will break the moving state in HK. - if new_state.state not in MOVING_STATES: + if new_state.state in MOVING_STATES: + self._async_update_target_position_while_moving( + new_state.state, current_position + ) + else: self.char_target_position.set_value(current_position) position_state = _hass_state_to_position_start(new_state.state) diff --git a/homeassistant/components/hue/device_trigger.py b/homeassistant/components/hue/device_trigger.py index 100467d77256a1..80282972b3a89e 100644 --- a/homeassistant/components/hue/device_trigger.py +++ b/homeassistant/components/hue/device_trigger.py @@ -47,8 +47,9 @@ async def async_validate_trigger_config( ) is None: raise InvalidDeviceAutomationConfig(f"Device ID {device_id} is not valid") + entry_ids = _device_config_entry_ids(device_entry) for entry in entries: - if entry.entry_id not in device_entry.config_entries: + if entry.entry_id not in entry_ids: continue bridge = entry.runtime_data if bridge.api_version == 1: @@ -72,11 +73,12 @@ async def async_attach_trigger( ) is None: raise InvalidDeviceAutomationConfig(f"Device ID {device_id} is not valid") + entry_ids = _device_config_entry_ids(device_entry) entry: HueConfigEntry | None = next( ( entry for entry in hass.config_entries.async_entries(DOMAIN) - if entry.entry_id in device_entry.config_entries + if entry.entry_id in entry_ids ), None, ) @@ -112,8 +114,9 @@ async def async_get_triggers( # Iterate all config entries for this device # and work out the bridge version + entry_ids = _device_config_entry_ids(device_entry) for entry in entries: - if entry.entry_id not in device_entry.config_entries: + if entry.entry_id not in entry_ids: continue bridge = entry.runtime_data @@ -123,6 +126,17 @@ async def async_get_triggers( return [] +def _device_config_entry_ids(device_entry: dr.DeviceEntry) -> set[str]: + """Return the ids of the config entries the device belongs to. + + A restored composite has no single owning config entry; the union of the + split devices' config entries covers every owning domain. + """ + if device_entry.is_composite_device: + return device_entry.config_entries + return {device_entry.config_entry_id} + + async def _async_attach_bridge_trigger( entry: HueConfigEntry, device_entry: dr.DeviceEntry, diff --git a/homeassistant/components/kitchen_sink/lawn_mower.py b/homeassistant/components/kitchen_sink/lawn_mower.py index e33f670420b3e5..5b9f5511d18fa6 100644 --- a/homeassistant/components/kitchen_sink/lawn_mower.py +++ b/homeassistant/components/kitchen_sink/lawn_mower.py @@ -57,7 +57,8 @@ async def async_setup_platform( LawnMowerActivity.DOCKED, LawnMowerEntityFeature.DOCK | LawnMowerEntityFeature.PAUSE - | LawnMowerEntityFeature.START_MOWING, + | LawnMowerEntityFeature.START_MOWING + | LawnMowerEntityFeature.STOP, ), DemoLawnMower( "kitchen_sink_mower_006", @@ -67,6 +68,12 @@ async def async_setup_platform( | LawnMowerEntityFeature.PAUSE | LawnMowerEntityFeature.START_MOWING, ), + DemoLawnMower( + "kitchen_sink_mower_007", + "Mower can stop", + LawnMowerActivity.MOWING, + LawnMowerEntityFeature.STOP | LawnMowerEntityFeature.START_MOWING, + ), ] ) @@ -113,3 +120,9 @@ async def async_pause(self) -> None: """Pause mower.""" self._attr_activity = LawnMowerActivity.PAUSED self.async_write_ha_state() + + @override + async def async_stop(self) -> None: + """Stop mower.""" + self._attr_activity = LawnMowerActivity.IDLE + self.async_write_ha_state() diff --git a/homeassistant/components/lawn_mower/__init__.py b/homeassistant/components/lawn_mower/__init__.py index a94ac0f1a12838..fc6099166ed903 100644 --- a/homeassistant/components/lawn_mower/__init__.py +++ b/homeassistant/components/lawn_mower/__init__.py @@ -19,6 +19,7 @@ SERVICE_DOCK, SERVICE_PAUSE, SERVICE_START_MOWING, + SERVICE_STOP, LawnMowerActivity, LawnMowerEntityFeature, ) @@ -51,6 +52,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service( SERVICE_DOCK, None, "async_dock", [LawnMowerEntityFeature.DOCK] ) + component.async_register_entity_service( + SERVICE_STOP, None, "async_stop", [LawnMowerEntityFeature.STOP] + ) return True @@ -123,3 +127,11 @@ def pause(self) -> None: async def async_pause(self) -> None: """Pause the lawn mower.""" await self.hass.async_add_executor_job(self.pause) + + def stop(self) -> None: + """Stop the lawn mower.""" + raise NotImplementedError + + async def async_stop(self) -> None: + """Stop the lawn mower.""" + await self.hass.async_add_executor_job(self.stop) diff --git a/homeassistant/components/lawn_mower/condition.py b/homeassistant/components/lawn_mower/condition.py index 0e0589655f33b1..a99025a3045d53 100644 --- a/homeassistant/components/lawn_mower/condition.py +++ b/homeassistant/components/lawn_mower/condition.py @@ -10,6 +10,7 @@ "is_encountering_an_error": make_entity_state_condition( DOMAIN, LawnMowerActivity.ERROR ), + "is_idle": make_entity_state_condition(DOMAIN, LawnMowerActivity.IDLE), "is_mowing": make_entity_state_condition(DOMAIN, LawnMowerActivity.MOWING), "is_paused": make_entity_state_condition(DOMAIN, LawnMowerActivity.PAUSED), "is_returning": make_entity_state_condition(DOMAIN, LawnMowerActivity.RETURNING), diff --git a/homeassistant/components/lawn_mower/conditions.yaml b/homeassistant/components/lawn_mower/conditions.yaml index 5fb1de71345ff9..068745a5fb4dc8 100644 --- a/homeassistant/components/lawn_mower/conditions.yaml +++ b/homeassistant/components/lawn_mower/conditions.yaml @@ -17,6 +17,7 @@ is_docked: *condition_common is_encountering_an_error: *condition_common +is_idle: *condition_common is_mowing: *condition_common is_paused: *condition_common is_returning: *condition_common diff --git a/homeassistant/components/lawn_mower/const.py b/homeassistant/components/lawn_mower/const.py index 16a99c5cab5b91..125675c0063277 100644 --- a/homeassistant/components/lawn_mower/const.py +++ b/homeassistant/components/lawn_mower/const.py @@ -22,6 +22,9 @@ class LawnMowerActivity(StrEnum): RETURNING = "returning" """Device is returning.""" + IDLE = "idle" + """Device is stopped, but neither docked nor paused.""" + class LawnMowerEntityFeature(IntFlag): """Supported features of the lawn mower entity.""" @@ -29,6 +32,7 @@ class LawnMowerEntityFeature(IntFlag): START_MOWING = 1 PAUSE = 2 DOCK = 4 + STOP = 8 DOMAIN: Final = "lawn_mower" @@ -36,3 +40,4 @@ class LawnMowerEntityFeature(IntFlag): SERVICE_START_MOWING = "start_mowing" SERVICE_PAUSE = "pause" SERVICE_DOCK = "dock" +SERVICE_STOP = "stop" diff --git a/homeassistant/components/lawn_mower/icons.json b/homeassistant/components/lawn_mower/icons.json index 3fccbbebca59d1..1c62eadedee2fa 100644 --- a/homeassistant/components/lawn_mower/icons.json +++ b/homeassistant/components/lawn_mower/icons.json @@ -6,6 +6,9 @@ "is_encountering_an_error": { "condition": "mdi:alert-circle-outline" }, + "is_idle": { + "condition": "mdi:stop" + }, "is_mowing": { "condition": "mdi:play" }, @@ -30,9 +33,15 @@ }, "start_mowing": { "service": "mdi:play" + }, + "stop": { + "service": "mdi:stop" } }, "triggers": { + "became_idle": { + "trigger": "mdi:stop" + }, "errored": { "trigger": "mdi:alert-circle-outline" }, diff --git a/homeassistant/components/lawn_mower/services.yaml b/homeassistant/components/lawn_mower/services.yaml index 8c9a2f1adcc74d..634c356a4d1da2 100644 --- a/homeassistant/components/lawn_mower/services.yaml +++ b/homeassistant/components/lawn_mower/services.yaml @@ -20,3 +20,10 @@ pause: domain: lawn_mower supported_features: - lawn_mower.LawnMowerEntityFeature.PAUSE + +stop: + target: + entity: + domain: lawn_mower + supported_features: + - lawn_mower.LawnMowerEntityFeature.STOP diff --git a/homeassistant/components/lawn_mower/strings.json b/homeassistant/components/lawn_mower/strings.json index b46b636ef8651a..5e76816329e2e4 100644 --- a/homeassistant/components/lawn_mower/strings.json +++ b/homeassistant/components/lawn_mower/strings.json @@ -30,6 +30,18 @@ }, "name": "Lawn mower is encountering an error" }, + "is_idle": { + "description": "Tests if one or more lawn mowers are idle.", + "fields": { + "behavior": { + "name": "[%key:component::lawn_mower::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::condition_for_name%]" + } + }, + "name": "Lawn mower is idle" + }, "is_mowing": { "description": "Tests if one or more lawn mowers are mowing.", "fields": { @@ -73,6 +85,7 @@ "state": { "docked": "Docked", "error": "[%key:common::state::error%]", + "idle": "[%key:common::state::idle%]", "mowing": "Mowing", "paused": "[%key:common::state::paused%]", "returning": "Returning" @@ -91,10 +104,26 @@ "start_mowing": { "description": "Starts a lawn mower's mowing task.", "name": "Start lawn mower" + }, + "stop": { + "description": "Stops a lawn mower's current task.", + "name": "Stop lawn mower" } }, "title": "Lawn mower", "triggers": { + "became_idle": { + "description": "Triggers when one or more lawn mowers become idle.", + "fields": { + "behavior": { + "name": "[%key:component::lawn_mower::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::trigger_for_name%]" + } + }, + "name": "Lawn mower became idle" + }, "errored": { "description": "Triggers when one or more lawn mowers encounter an error.", "fields": { diff --git a/homeassistant/components/lawn_mower/trigger.py b/homeassistant/components/lawn_mower/trigger.py index 7612c9c4120973..e414038655fb97 100644 --- a/homeassistant/components/lawn_mower/trigger.py +++ b/homeassistant/components/lawn_mower/trigger.py @@ -17,6 +17,7 @@ "started_returning": make_entity_target_state_trigger( DOMAIN, LawnMowerActivity.RETURNING ), + "became_idle": make_entity_target_state_trigger(DOMAIN, LawnMowerActivity.IDLE), } diff --git a/homeassistant/components/lawn_mower/triggers.yaml b/homeassistant/components/lawn_mower/triggers.yaml index 8cd5d878be6f12..f83ce726639342 100644 --- a/homeassistant/components/lawn_mower/triggers.yaml +++ b/homeassistant/components/lawn_mower/triggers.yaml @@ -20,3 +20,4 @@ errored: *trigger_common paused_mowing: *trigger_common started_mowing: *trigger_common started_returning: *trigger_common +became_idle: *trigger_common diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py index ced2ed7023fc62..4db077c4a654d5 100644 --- a/homeassistant/components/lcn/services.py +++ b/homeassistant/components/lcn/services.py @@ -71,8 +71,10 @@ def __init__(self, hass: HomeAssistant) -> None: def get_device_connection(self, service: ServiceCall) -> DeviceConnection: """Get address connection object.""" entry: LcnConfigEntry + # device_connections is keyed by the ids of the main devices LCN registers + # for its modules and groups, so a child device has no connection device, entry = async_get_device_and_config_entry( - self.hass, DOMAIN, service.data[CONF_DEVICE_ID] + self.hass, DOMAIN, service.data[CONF_DEVICE_ID], include_child_devices=False ) return entry.runtime_data.device_connections[device.id] diff --git a/homeassistant/components/lg_netcast/device_trigger.py b/homeassistant/components/lg_netcast/device_trigger.py index 647f097f57adff..a24568caedc7ac 100644 --- a/homeassistant/components/lg_netcast/device_trigger.py +++ b/homeassistant/components/lg_netcast/device_trigger.py @@ -8,15 +8,16 @@ DEVICE_TRIGGER_BASE_SCHEMA, InvalidDeviceAutomationConfig, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_DEVICE_ID, CONF_PLATFORM, CONF_TYPE from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType from . import trigger from .const import DOMAIN -from .helpers import async_get_device_entry_by_device_id from .triggers.turn_on import ( PLATFORM_TYPE as TURN_ON_PLATFORM_TYPE, async_get_turn_on_trigger, @@ -40,15 +41,14 @@ async def async_validate_trigger_config( if config[CONF_TYPE] == TURN_ON_PLATFORM_TYPE: device_id = config[CONF_DEVICE_ID] - try: - device = async_get_device_entry_by_device_id(hass, device_id) - except ValueError as err: - raise InvalidDeviceAutomationConfig(err) from err - - if not any( - entry.entry_id in device.config_entries - for entry in hass.config_entries.async_loaded_entries(DOMAIN) - ): + device, config_entry = dr.async_get_device_and_config_entry_for_domain( + hass, device_id, domain=DOMAIN + ) + if device is None: + raise InvalidDeviceAutomationConfig( + f"Device {device_id} is not a valid {DOMAIN} device." + ) + if config_entry is None or config_entry.state is not ConfigEntryState.LOADED: raise InvalidDeviceAutomationConfig( f"Device {device.id} is not from an existing {DOMAIN} config entry" ) diff --git a/homeassistant/components/libre_hardware_monitor/__init__.py b/homeassistant/components/libre_hardware_monitor/__init__.py index dae6a54a695152..f3270a6ec5ab45 100644 --- a/homeassistant/components/libre_hardware_monitor/__init__.py +++ b/homeassistant/components/libre_hardware_monitor/__init__.py @@ -4,11 +4,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, - issue_registry as ir, -) +from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import DOMAIN from .coordinator import ( @@ -82,21 +78,6 @@ async def async_setup_entry( lhm_coordinator = LibreHardwareMonitorCoordinator(hass, config_entry) await lhm_coordinator.async_config_entry_first_refresh() - if lhm_coordinator.data.is_deprecated_version: - issue_id = f"deprecated_api_{config_entry.entry_id}" - ir.async_create_issue( - hass, - DOMAIN, - issue_id, - breaks_in_ha_version="2026.9.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_api", - translation_placeholders={ - "lhm_releases_url": "https://github.com/LibreHardwareMonitor/LibreHardwareMonitor/releases" - }, - ) - config_entry.runtime_data = lhm_coordinator await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) diff --git a/homeassistant/components/libre_hardware_monitor/config_flow.py b/homeassistant/components/libre_hardware_monitor/config_flow.py index f9f525e706d27e..e59762d1b1e606 100644 --- a/homeassistant/components/libre_hardware_monitor/config_flow.py +++ b/homeassistant/components/libre_hardware_monitor/config_flow.py @@ -10,6 +10,7 @@ LibreHardwareMonitorNoDevicesError, LibreHardwareMonitorUnauthorizedError, ) +from librehardwaremonitor_api.model import LibreHardwareMonitorData import voluptuous as vol from homeassistant.config_entries import ( @@ -39,7 +40,9 @@ ) -async def _validate_connection(user_input: dict[str, Any]) -> str: +async def _validate_connection( + user_input: dict[str, Any], +) -> LibreHardwareMonitorData: """Ensure a connection can be established.""" api = LibreHardwareMonitorClient( host=user_input[CONF_HOST], @@ -48,7 +51,7 @@ async def _validate_connection(user_input: dict[str, Any]) -> str: password=user_input.get(CONF_PASSWORD), ) - return (await api.get_data()).computer_name + return await api.get_data() class LibreHardwareMonitorConfigFlow(ConfigFlow, domain=DOMAIN): @@ -73,7 +76,7 @@ async def async_step_user( self._async_abort_entries_match(user_input) try: - computer_name = await _validate_connection(user_input) + lhm_data = await _validate_connection(user_input) except LibreHardwareMonitorConnectionError as exception: _LOGGER.error(exception) errors["base"] = "cannot_connect" @@ -84,14 +87,17 @@ async def async_step_user( except LibreHardwareMonitorNoDevicesError: errors["base"] = "no_devices" else: - return self.async_create_entry( - title=( - f"{computer_name}" - f" ({user_input[CONF_HOST]}" - f":{user_input[CONF_PORT]})" - ), - data=user_input, - ) + if lhm_data.is_deprecated_version: + errors["base"] = "deprecated_version" + else: + return self.async_create_entry( + title=( + f"{lhm_data.computer_name}" + f" ({user_input[CONF_HOST]}" + f":{user_input[CONF_PORT]})" + ), + data=user_input, + ) return self.async_show_form( step_id="user", @@ -123,7 +129,7 @@ async def async_step_reauth_confirm( **user_input, } try: - computer_name = await _validate_connection(data) + lhm_data = await _validate_connection(data) except LibreHardwareMonitorConnectionError as exception: _LOGGER.error(exception) errors["base"] = "cannot_connect" @@ -132,17 +138,20 @@ async def async_step_reauth_confirm( except LibreHardwareMonitorNoDevicesError: errors["base"] = "no_devices" else: - if self.source == SOURCE_REAUTH: + if lhm_data.is_deprecated_version: + errors["base"] = "deprecated_version" + elif self.source == SOURCE_REAUTH: return self.async_update_reload_and_abort( entry=reauth_entry, # type: ignore[arg-type] data_updates=user_input, ) - # the initial connection was unauthorized, - # now we can create the config entry - return self.async_create_entry( - title=f"{computer_name} ({self._host}:{self._port})", - data=data, - ) + else: + # the initial connection was unauthorized, + # now we can create the config entry + return self.async_create_entry( + title=f"{lhm_data.computer_name} ({self._host}:{self._port})", + data=data, + ) return self.async_show_form( step_id="reauth_confirm", diff --git a/homeassistant/components/libre_hardware_monitor/coordinator.py b/homeassistant/components/libre_hardware_monitor/coordinator.py index 4f74e94138edfb..f024d0413ad26a 100644 --- a/homeassistant/components/libre_hardware_monitor/coordinator.py +++ b/homeassistant/components/libre_hardware_monitor/coordinator.py @@ -16,11 +16,11 @@ LibreHardwareMonitorData, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -67,7 +67,6 @@ def __init__( for device in device_entries if device.identifiers and device.name } - self._is_deprecated_version: bool | None = None @override async def _async_update_data(self) -> LibreHardwareMonitorData: @@ -83,12 +82,14 @@ async def _async_update_data(self) -> LibreHardwareMonitorData: except LibreHardwareMonitorNoDevicesError as err: raise UpdateFailed("No sensor data available, will retry") from err - # Check whether user has upgraded LHM from a deprecated - # version while the integration is running - if self._is_deprecated_version and not lhm_data.is_deprecated_version: - # Clear deprecation issue - ir.async_delete_issue(self.hass, DOMAIN, f"deprecated_api_{self._entry_id}") - self._is_deprecated_version = lhm_data.is_deprecated_version + if lhm_data.is_deprecated_version: + if self.config_entry.state is ConfigEntryState.LOADED: + # if user downgrades while HA is running, reload integration to surface ConfigEntryError + self.hass.config_entries.async_schedule_reload(self._entry_id) + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="deprecated_version", + ) await self._async_handle_changes_in_devices( dict(lhm_data.main_device_ids_and_names) diff --git a/homeassistant/components/libre_hardware_monitor/strings.json b/homeassistant/components/libre_hardware_monitor/strings.json index b6a35f6d3c4234..760a9dce86baca 100644 --- a/homeassistant/components/libre_hardware_monitor/strings.json +++ b/homeassistant/components/libre_hardware_monitor/strings.json @@ -1,10 +1,14 @@ { + "common": { + "deprecated_version": "Your version of Libre Hardware Monitor is no longer supported. Please update to version 0.9.5 or later." + }, "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "deprecated_version": "[%key:component::libre_hardware_monitor::common::deprecated_version%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "no_devices": "[%key:common::config_flow::abort::no_devices_found%]" }, @@ -33,10 +37,9 @@ } } }, - "issues": { - "deprecated_api": { - "description": "Your version of Libre Hardware Monitor is deprecated and may not provide stable sensor data. To fix this issue:\n\n1. Download version 0.9.5 or later from {lhm_releases_url}\n2. Close Libre Hardware Monitor on your computer\n3. Install or extract the new version and start Libre Hardware Monitor again (you might have to re-enable the remote web server)\n4. Home Assistant will detect the new version and this issue will clear automatically", - "title": "Deprecated Libre Hardware Monitor version" + "exceptions": { + "deprecated_version": { + "message": "[%key:component::libre_hardware_monitor::common::deprecated_version%]" } } } diff --git a/homeassistant/components/logbook/helpers.py b/homeassistant/components/logbook/helpers.py index 8002a4f0bce39f..83aa1a586140bc 100644 --- a/homeassistant/components/logbook/helpers.py +++ b/homeassistant/components/logbook/helpers.py @@ -68,8 +68,12 @@ def _async_config_entries_for_ids( if device_ids: dev_reg = dr.async_get(hass) for device_id in device_ids: - if (device := dev_reg.async_get(device_id)) and device.config_entries: + if not (device := dev_reg.async_get(device_id)): + continue + if device.is_composite_device: config_entry_ids |= device.config_entries + else: + config_entry_ids.add(device.config_entry_id) return config_entry_ids diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index 18829e4cebe717..8e0c0e0c882f3d 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -155,7 +155,7 @@ class DataType(StrEnum): SERVICE_STOP = "stop" # dispatcher signals -SIGNAL_STOP_ENTITY = "modbus.stop" +SIGNAL_STOP_ENTITY = "modbus.stop_{}" # integration names DEFAULT_HUB = "modbus_hub" diff --git a/homeassistant/components/modbus/entity.py b/homeassistant/components/modbus/entity.py index df7c3288230f49..5e6934099a1144 100644 --- a/homeassistant/components/modbus/entity.py +++ b/homeassistant/components/modbus/entity.py @@ -149,7 +149,11 @@ async def async_base_added_to_hass(self) -> None: ) ) self.async_on_remove( - async_dispatcher_connect(self.hass, SIGNAL_STOP_ENTITY, self.async_disable) + async_dispatcher_connect( + self.hass, + SIGNAL_STOP_ENTITY.format(self._hub.name), + self.async_disable, + ) ) diff --git a/homeassistant/components/modbus/services.py b/homeassistant/components/modbus/services.py index ff49255dbdea40..834707d48c8c5e 100644 --- a/homeassistant/components/modbus/services.py +++ b/homeassistant/components/modbus/services.py @@ -101,7 +101,7 @@ async def _async_stop_hub(service: ServiceCall) -> None: """Stop Modbus hub.""" hass = service.hass hub = _get_hubs(hass)[service.data[ATTR_HUB]] - async_dispatcher_send(hass, SIGNAL_STOP_ENTITY) + async_dispatcher_send(hass, SIGNAL_STOP_ENTITY.format(hub.name)) await hub.async_close() diff --git a/homeassistant/components/monzo/services.py b/homeassistant/components/monzo/services.py index 08040f2e51ae17..0c5ee510c78a67 100644 --- a/homeassistant/components/monzo/services.py +++ b/homeassistant/components/monzo/services.py @@ -88,7 +88,7 @@ def _transfer_rejection_reason(error: InvalidMonzoAPIResponseError) -> str | Non @callback -def _async_get_resource_id(device: dr.DeviceEntry) -> str: +def _async_get_resource_id(device: dr.AnyDeviceEntry) -> str: """Get the Monzo resource ID represented by a device.""" for domain, resource_id in device.identifiers: if domain == DOMAIN: @@ -99,7 +99,7 @@ def _async_get_resource_id(device: dr.DeviceEntry) -> str: ) -def _device_name(device: dr.DeviceEntry) -> str: +def _device_name(device: dr.AnyDeviceEntry) -> str: """Return the best available name for a device.""" return device.name_by_user or device.name or device.id diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index b0abaebdce3449..a2249a6b79f5ab 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -215,6 +215,8 @@ "stat_tpl": "state_template", "stat_val_tpl": "state_value_template", "step": "step", + "stop_cmd_t": "stop_command_topic", + "stop_cmd_tpl": "stop_command_template", "strt_mw_cmd_t": "start_mowing_command_topic", "strt_mw_cmd_tpl": "start_mowing_command_template", "stype": "subtype", diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py index 5d42f120502ae0..5ec5857bab07bb 100644 --- a/homeassistant/components/mqtt/lawn_mower.py +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -48,6 +48,8 @@ CONF_PAUSE_COMMAND_TEMPLATE = "pause_command_template" CONF_START_MOWING_COMMAND_TOPIC = "start_mowing_command_topic" CONF_START_MOWING_COMMAND_TEMPLATE = "start_mowing_command_template" +CONF_STOP_COMMAND_TOPIC = "stop_command_topic" +CONF_STOP_COMMAND_TEMPLATE = "stop_command_template" DEFAULT_NAME = "MQTT Lawn Mower" @@ -56,6 +58,7 @@ FEATURE_DOCK = "dock" FEATURE_PAUSE = "pause" FEATURE_START_MOWING = "start_mowing" +FEATURE_STOP = "stop" PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( { @@ -70,6 +73,8 @@ vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, vol.Optional(CONF_START_MOWING_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_START_MOWING_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_STOP_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_STOP_COMMAND_TOPIC): valid_publish_topic, }, ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) @@ -130,6 +135,9 @@ def _setup_from_config(self, config: ConfigType) -> None: CONF_START_MOWING_COMMAND_TOPIC ] supported_features |= LawnMowerEntityFeature.START_MOWING + if CONF_STOP_COMMAND_TOPIC in config: + self._command_topics[FEATURE_STOP] = config[CONF_STOP_COMMAND_TOPIC] + supported_features |= LawnMowerEntityFeature.STOP self._attr_supported_features = supported_features self._command_templates = {} self._command_templates[FEATURE_DOCK] = MqttCommandTemplate( @@ -141,6 +149,9 @@ def _setup_from_config(self, config: ConfigType) -> None: self._command_templates[FEATURE_START_MOWING] = MqttCommandTemplate( config.get(CONF_START_MOWING_COMMAND_TEMPLATE), entity=self ).async_render + self._command_templates[FEATURE_STOP] = MqttCommandTemplate( + config.get(CONF_STOP_COMMAND_TEMPLATE), entity=self + ).async_render @callback def _message_received(self, msg: ReceiveMessage) -> None: @@ -212,3 +223,8 @@ async def async_dock(self) -> None: async def async_pause(self) -> None: """Pause the lawn mower.""" await self._async_operate("pause", LawnMowerActivity.PAUSED) + + @override + async def async_stop(self) -> None: + """Stop the lawn mower.""" + await self._async_operate("stop", LawnMowerActivity.IDLE) diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py index da3489ac74b800..e664fa2f9ed133 100644 --- a/homeassistant/components/nest/climate.py +++ b/homeassistant/components/nest/climate.py @@ -353,10 +353,6 @@ async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" if fan_mode not in self.fan_modes: raise ValueError(f"Unsupported fan_mode '{fan_mode}'") - if fan_mode == FAN_ON and self.hvac_mode == HVACMode.OFF: - raise ValueError( - "Cannot turn on fan, please set an HVAC mode (e.g. heat/cool) first" - ) trait = self._device.traits[FanTrait.NAME] duration = None if fan_mode != FAN_OFF: @@ -373,12 +369,6 @@ async def async_set_fan_timer(self, duration: timedelta) -> None: if not self.supported_features & ClimateEntityFeature.FAN_MODE: raise HomeAssistantError(f"Entity {self.entity_id} does not support fan") - if self.hvac_mode == HVACMode.OFF: - raise HomeAssistantError( - f"Cannot turn on fan for {self.entity_id}," - " please set an HVAC mode (e.g. heat/cool) first" - ) - seconds = int(duration.total_seconds()) if seconds <= 0 or seconds > MAX_FAN_DURATION: raise ValueError( diff --git a/homeassistant/components/portainer/services.py b/homeassistant/components/portainer/services.py index c2031645828244..f191998e7cec7a 100644 --- a/homeassistant/components/portainer/services.py +++ b/homeassistant/components/portainer/services.py @@ -53,7 +53,7 @@ @callback def _async_get_device_and_entry( call: ServiceCall, device_id: str -) -> tuple[dr.DeviceEntry, PortainerConfigEntry]: +) -> tuple[dr.AnyDeviceEntry, PortainerConfigEntry]: """Resolve and validate the device and Portainer config entry for a device ID.""" entry: PortainerConfigEntry device, entry = service.async_get_device_and_config_entry( @@ -64,7 +64,7 @@ def _async_get_device_and_entry( @callback def _async_get_endpoint_id( - device: dr.DeviceEntry, + device: dr.AnyDeviceEntry, config_entry: PortainerConfigEntry, ) -> int: """Get the endpoint ID from a device entry.""" @@ -85,7 +85,7 @@ def _async_get_endpoint_id( @callback def _async_get_container_and_endpoint_ids( - device: dr.DeviceEntry, + device: dr.AnyDeviceEntry, config_entry: PortainerConfigEntry, ) -> tuple[int, str]: """Get the endpoint ID and container ID from a container device entry.""" diff --git a/homeassistant/components/rejseplanen/__init__.py b/homeassistant/components/rejseplanen/__init__.py index c67ab71dbb93fb..651ce6165c93a2 100644 --- a/homeassistant/components/rejseplanen/__init__.py +++ b/homeassistant/components/rejseplanen/__init__.py @@ -1 +1,51 @@ """The rejseplanen component.""" + +import logging + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import RejseplanenConfigEntry, RejseplanenDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: RejseplanenConfigEntry, +) -> bool: + """Set up Rejseplanen from a config entry.""" + coordinator = RejseplanenDataUpdateCoordinator(hass, config_entry) + + await coordinator.async_config_entry_first_refresh() + + config_entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + # Register update listener for subentry changes - but use minimal reload + config_entry.async_on_unload( + config_entry.add_update_listener(_async_update_listener) + ) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, + config_entry: RejseplanenConfigEntry, +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + + +async def _async_update_listener( + hass: HomeAssistant, + config_entry: RejseplanenConfigEntry, +) -> None: + """Handle update when subentries are added/removed.""" + _LOGGER.debug("Update listener triggered for entry: %s", config_entry.entry_id) + + # Instead of setting up platforms again, reload the entire config entry + # This is the standard approach for handling subentry changes + await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/rejseplanen/config_flow.py b/homeassistant/components/rejseplanen/config_flow.py new file mode 100644 index 00000000000000..b4c76ac8d40125 --- /dev/null +++ b/homeassistant/components/rejseplanen/config_flow.py @@ -0,0 +1,171 @@ +"""Config flow for Rejseplanen integration.""" + +from typing import Any, override + +from py_rejseplan.api.departures import DeparturesAPIClient as Rejseplanen +from py_rejseplan.dataclasses.transport_mappings import DEPARTURE_TYPE_TO_CLASS +from py_rejseplan.exceptions import ( + APIError as RejseplanenAPIError, + ConnectionError as RejseplanenConnectionError, +) +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import ( + CONF_DEPARTURE_TYPE, + CONF_DIRECTION, + CONF_STOP_ID, + DEFAULT_STOP_NAME, + DOMAIN, +) + +CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY, default=""): str, + } +) + +CONFIG_STOP_SCHEMA = vol.Schema( + { + vol.Required(CONF_STOP_ID): NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, min=1, max=999999999, step=1 + ), + ), + vol.Optional(CONF_NAME, default=DEFAULT_STOP_NAME): str, # pylint: disable=home-assistant-config-flow-name-field + vol.Optional(CONF_DIRECTION, default=[]): TextSelector( + TextSelectorConfig( + type=TextSelectorType.TEXT, + multiple=True, + ) + ), + vol.Optional( + CONF_DEPARTURE_TYPE, + default=[], + ): SelectSelector( + SelectSelectorConfig( + options=list(DEPARTURE_TYPE_TO_CLASS), + mode=SelectSelectorMode.DROPDOWN, + translation_key="departure_type", + multiple=True, + ) + ), + } +) + + +class RejseplanenConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle configflow for Rejseplanen integration.""" + + VERSION = 1 + MINOR_VERSION = 1 + + @override + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {"stop": RejseplanenSubentryStopFlow} + + @override + async def async_step_user( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle the initial step of the config flow.""" + + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=CONFIG_SCHEMA, + description_placeholders={"name": "Rejseplanen"}, + ) + + errors: dict[str, str] = {} + auth_key = user_input[CONF_API_KEY] + api = Rejseplanen( + auth_key=auth_key, + session=async_get_clientsession(self.hass), + ) + + try: + result = await api.validate_auth_key_async() + except RejseplanenConnectionError, RejseplanenAPIError, OSError: + errors["base"] = "cannot_connect" + else: + if not result: + errors["base"] = "invalid_auth" + + if errors: + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + CONFIG_SCHEMA, user_input + ), + errors=errors, + ) + # Store the authentication key and name + return self.async_create_entry( + title="Rejseplanen", + data={CONF_API_KEY: auth_key}, + ) + + +class RejseplanenSubentryStopFlow(ConfigSubentryFlow): + """Handle subentry flow for Rejseplanen stops.""" + + async def async_step_user( + self, + user_input: dict[str, Any] | None = None, + ) -> SubentryFlowResult: + """Handle the stop subentry step.""" + + if user_input is not None: + stop_id = user_input[CONF_STOP_ID] + name = user_input[CONF_NAME] + selected_keys: str | list = user_input.get(CONF_DEPARTURE_TYPE, []) + departure_types = [ + DEPARTURE_TYPE_TO_CLASS[key] + for key in selected_keys + if key in DEPARTURE_TYPE_TO_CLASS + ] + + return self.async_create_entry( + title=name, + data={ + CONF_STOP_ID: int(stop_id), + CONF_NAME: name, + CONF_DEPARTURE_TYPE: departure_types, + CONF_DIRECTION: user_input.get(CONF_DIRECTION, []), + }, + ) + return self.async_show_form( + step_id="user", + data_schema=CONFIG_STOP_SCHEMA, + description_placeholders={ + "documentation_url": "https://www.home-assistant.io/integrations/rejseplanen/" + }, + ) diff --git a/homeassistant/components/rejseplanen/const.py b/homeassistant/components/rejseplanen/const.py new file mode 100644 index 00000000000000..2e44c9f9ca6913 --- /dev/null +++ b/homeassistant/components/rejseplanen/const.py @@ -0,0 +1,12 @@ +"""Constants for the Rejseplanen integration.""" + +DOMAIN = "rejseplanen" + +CONF_STOP_ID = "stop_id" +CONF_DIRECTION = "direction" +CONF_DEPARTURE_TYPE = "departure_type" + +DEFAULT_NAME = "Next departure" +DEFAULT_STOP_NAME = "Unknown stop" + +SCAN_INTERVAL_MINUTES = 5 diff --git a/homeassistant/components/rejseplanen/coordinator.py b/homeassistant/components/rejseplanen/coordinator.py new file mode 100644 index 00000000000000..aca5ed131be18f --- /dev/null +++ b/homeassistant/components/rejseplanen/coordinator.py @@ -0,0 +1,157 @@ +"""Data update coordinator for Rejseplanen.""" + +from datetime import datetime, timedelta +import logging +from typing import override + +from py_rejseplan.api.departures import DeparturesAPIClient +from py_rejseplan.dataclasses.departure import Departure +from py_rejseplan.dataclasses.departure_board import DepartureBoard +from py_rejseplan.exceptions import APIError, ConnectionError, HTTPError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import ( + TimestampDataUpdateCoordinator, + UpdateFailed, +) +from homeassistant.util import dt as dt_util + +from .const import CONF_STOP_ID, DOMAIN, SCAN_INTERVAL_MINUTES +from .helpers import cph_to_tz + +_LOGGER = logging.getLogger(__name__) + +type RejseplanenConfigEntry = ConfigEntry[RejseplanenDataUpdateCoordinator] + + +class RejseplanenDataUpdateCoordinator(TimestampDataUpdateCoordinator[DepartureBoard]): + """Class to manage fetching data from the Rejseplanen API.""" + + config_entry: RejseplanenConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: RejseplanenConfigEntry, + ) -> None: + """Initialize.""" + + self.api = DeparturesAPIClient( + auth_key=config_entry.data[CONF_API_KEY], + session=async_get_clientsession(hass), + ) + self.stop_ids = { + subentry.data[CONF_STOP_ID] + for subentry in config_entry.get_subentries_of_type("stop") + } + + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN} Data Update Coordinator", + update_interval=timedelta(minutes=SCAN_INTERVAL_MINUTES), + config_entry=config_entry, + ) + + @override + async def _async_update_data(self) -> DepartureBoard: + """Update data via library.""" + try: + return await self._fetch_data(self.stop_ids) + except HTTPError as error: # runtime errors from the API + if error.status_code in (401, 403): + raise ConfigEntryAuthFailed("API key expired or revoked") from error + raise UpdateFailed(error) from error + except APIError as error: # runtime errors from the API + raise UpdateFailed(error) from error + except ConnectionError as error: # network errors + raise UpdateFailed( + f"Connection error while fetching data: {error}" + ) from error + except TypeError as error: + raise UpdateFailed( + f"Type error fetching data for stop {self.stop_ids}: {error}" + ) from error + + async def _fetch_data(self, stop_ids: set[int]) -> DepartureBoard: + """Fetch data from Rejseplanen API.""" + if not stop_ids: + _LOGGER.debug( + "No stops registered, Please add a stop through the UI configuration. Data not fetched" + ) + return DepartureBoard( + serverVersion="", + dialectVersion="", + planRtTs=dt_util.utcnow(), + requestId="", + technicalMessages=[], + departures=[], + ) + # Get all departures for this stop + _LOGGER.debug("Fetching data for stop IDs: %s", stop_ids) + # These values are passed through to aiohttp/yarl query encoding. + # Use int flags instead of bools because yarl rejects bool query variable types. + departure_board, _ = await self.api.get_departures_async( + list(stop_ids), use_bus=1, use_train=1, use_metro=1 + ) + return departure_board + + def get_filtered_departures( + self, + stop_id: int, + direction_filter: list[str] | None = None, + departure_type_filter: int | None = None, + ) -> list[Departure]: + """Get departures filtered by the specified criteria.""" + + if not self.data: + return [] + + filtered_data = [ + departure + for departure in self.data.departures + if departure.stopExtId == stop_id + ] + + if direction_filter: + filtered_data = [ + d for d in filtered_data if d.direction in direction_filter + ] + + if departure_type_filter: + filtered_data = [ + d + for d in filtered_data + if d.product.cls_id is not None + and (d.product.cls_id & departure_type_filter) + ] + + # Sort by due_in time + filtered_data.sort( + key=lambda x: ( + x.rtDate or x.date, + x.rtTime or x.time, + ), + ) + now = dt_util.utcnow() + + # Find the index where the departure time is not in the past + def departure_datetime(d: Departure) -> datetime: + date = d.rtDate or d.date + time = d.rtTime or d.time + naive_dt = datetime.combine(date, time) + return cph_to_tz(naive_dt.date(), naive_dt.time(), dt_util.UTC) + + idx = next( + ( + i + for i, d in enumerate(filtered_data) + if (departure_datetime(d) - now >= timedelta(minutes=0)) + ), + len(filtered_data), + ) + return filtered_data[idx:] diff --git a/homeassistant/components/rejseplanen/entity.py b/homeassistant/components/rejseplanen/entity.py new file mode 100644 index 00000000000000..f4a8fe89c9f5f4 --- /dev/null +++ b/homeassistant/components/rejseplanen/entity.py @@ -0,0 +1,31 @@ +"""Base entity for Rejseplanen integration.""" + +import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import RejseplanenDataUpdateCoordinator + + +class RejseplanenEntity(CoordinatorEntity[RejseplanenDataUpdateCoordinator]): + """Base Rejseplanen entity.""" + + _attr_has_entity_name = True + _attr_attribution = "Data provided by rejseplanen.dk" + + def __init__( + self, + coordinator: RejseplanenDataUpdateCoordinator, + stop_id: int, + name: str, + subentry_id: str, + ) -> None: + """Initialize base entity.""" + super().__init__(coordinator) + self._stop_id = stop_id + + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, subentry_id)}, + name=name, + manufacturer="Rejseplanen", + ) diff --git a/homeassistant/components/rejseplanen/helpers.py b/homeassistant/components/rejseplanen/helpers.py new file mode 100644 index 00000000000000..94a878c1ca6925 --- /dev/null +++ b/homeassistant/components/rejseplanen/helpers.py @@ -0,0 +1,15 @@ +"""Helpers for timezone conversion for the Rejseplanen integration.""" + +from datetime import date, datetime, time, timezone +from zoneinfo import ZoneInfo + +from homeassistant.util import dt as dt_util + +COPENHAGEN_TZ = dt_util.get_time_zone("Europe/Copenhagen") + + +def cph_to_tz(dt_date: date, dt_time: time, target_tz: ZoneInfo | timezone) -> datetime: + """Return a datetime in the target_tz, assuming input is Copenhagen local time.""" + cph_naive = datetime.combine(dt_date, dt_time) + cph_aware = cph_naive.replace(tzinfo=COPENHAGEN_TZ) + return cph_aware.astimezone(target_tz) diff --git a/homeassistant/components/rejseplanen/manifest.json b/homeassistant/components/rejseplanen/manifest.json index 6d0642cc996521..8b044131b03292 100644 --- a/homeassistant/components/rejseplanen/manifest.json +++ b/homeassistant/components/rejseplanen/manifest.json @@ -1,10 +1,13 @@ { "domain": "rejseplanen", "name": "Rejseplanen", - "codeowners": [], + "codeowners": ["@Jawar19"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rejseplanen", + "integration_type": "service", "iot_class": "cloud_polling", - "loggers": ["rjpl"], + "loggers": ["py_rejseplan"], "quality_scale": "legacy", - "requirements": ["rjpl==0.3.6"] + "requirements": ["pyrejseplan==1.0.9"], + "single_config_entry": true } diff --git a/homeassistant/components/rejseplanen/sensor.py b/homeassistant/components/rejseplanen/sensor.py index d4b56530d2f82d..50e5907474133e 100644 --- a/homeassistant/components/rejseplanen/sensor.py +++ b/homeassistant/components/rejseplanen/sensor.py @@ -4,50 +4,47 @@ https://help.rejseplanen.dk/hc/en-us/articles/214174465-Rejseplanen-s-API """ -from contextlib import suppress +from collections.abc import Callable +from dataclasses import dataclass from datetime import datetime, timedelta import logging -from operator import itemgetter -from typing import Any, override +from typing import override +import zoneinfo -import rjpl +from py_rejseplan.dataclasses.departure import Departure import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, + SensorDeviceClass, SensorEntity, + SensorEntityDescription, ) -from homeassistant.const import CONF_NAME, UnitOfTime -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_AUTHENTICATION, CONF_NAME, UnitOfTime +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) +from homeassistant.helpers.event import async_track_point_in_time +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from homeassistant.util import dt as dt_util -_LOGGER = logging.getLogger(__name__) +from .const import CONF_DEPARTURE_TYPE, CONF_DIRECTION, CONF_STOP_ID, DOMAIN +from .coordinator import RejseplanenConfigEntry, RejseplanenDataUpdateCoordinator +from .entity import RejseplanenEntity +from .helpers import cph_to_tz -ATTR_STOP_ID = "stop_id" -ATTR_STOP_NAME = "stop" -ATTR_ROUTE = "route" -ATTR_TYPE = "type" -ATTR_DIRECTION = "direction" -ATTR_FINAL_STOP = "final_stop" -ATTR_DUE_IN = "due_in" -ATTR_DUE_AT = "due_at" -ATTR_SCHEDULED_AT = "scheduled_at" -ATTR_REAL_TIME_AT = "real_time_at" -ATTR_TRACK = "track" -ATTR_NEXT_UP = "next_departures" - -CONF_STOP_ID = "stop_id" -CONF_ROUTE = "route" -CONF_DIRECTION = "direction" -CONF_DEPARTURE_TYPE = "departure_type" +_LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = "Next departure" +PARALLEL_UPDATES = 0 +# Buffer time after departure for cleanup +DEPARTURE_CLEANUP_BUFFER = timedelta(seconds=15) -SCAN_INTERVAL = timedelta(minutes=1) +DEFAULT_NAME = "Next departure" BUS_TYPES = ["BUS", "EXB", "TB"] TRAIN_TYPES = ["LET", "S", "REG", "IC", "LYN", "TOG"] @@ -55,10 +52,9 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( { - vol.Required(CONF_STOP_ID): cv.string, + vol.Required(CONF_AUTHENTICATION): cv.string, + vol.Required(CONF_STOP_ID): vol.All(cv.ensure_list, [cv.positive_int]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_ROUTE, default=[]): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_DIRECTION, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_DEPARTURE_TYPE, default=[]): vol.All( cv.ensure_list, [vol.In([*BUS_TYPES, *TRAIN_TYPES, *METRO_TYPES])] ), @@ -66,195 +62,283 @@ ) -def due_in_minutes(timestamp): - """Get the time in minutes from a timestamp. +@dataclass(kw_only=True, frozen=True) +class RejseplanenSensorEntityDescription(SensorEntityDescription): + """Describes Rejseplanen sensor entity.""" - The timestamp should be in the format day.month.year hour:minute - """ - diff = datetime.strptime(timestamp, "%d.%m.%y %H:%M") - dt_util.now().replace( - tzinfo=None - ) + value_fn: Callable[ + [list[Departure], zoneinfo.ZoneInfo], StateType | datetime | None + ] + + +def _get_current_departures( + departures: list[Departure], tz: zoneinfo.ZoneInfo +) -> list[Departure]: + """Filter out past departures and return only current/future ones.""" + if not departures: + return [] + + now = dt_util.now(tz) + + current_departures = [] + for departure in departures: + dep_time = _get_departure_timestamp(departure, tz) + # Only include departures that haven't left yet (with small buffer) + if dep_time and dep_time > now - DEPARTURE_CLEANUP_BUFFER: + current_departures.append(departure) + + return current_departures + + +def _get_next_departure_cleanup_time( + departures: list[Departure], tz: zoneinfo.ZoneInfo +) -> datetime | None: + """Get the next time when departures should be filtered (first departure + buffer).""" + current_departures = _get_current_departures(departures, tz) + if not current_departures: + return None + + now = dt_util.now(tz) + for departure in current_departures: + # Use realtime if available, otherwise planned time + departure_datetime = _get_departure_timestamp(departure, tz) + + if departure_datetime and departure_datetime > now: + # Schedule cleanup slightly after the departure time + return departure_datetime + DEPARTURE_CLEANUP_BUFFER + return None + + +def _get_departure_timestamp( + departure: Departure | None, tz: zoneinfo.ZoneInfo +) -> datetime | None: + """Get departure timestamp (realtime if available, otherwise planned).""" + if departure is None: + return None + next_date = departure.rtDate or departure.date + next_time = departure.rtTime or departure.time + return cph_to_tz(next_date, next_time, tz) + + +def _get_delay_minutes( + departure: Departure | None, tz: zoneinfo.ZoneInfo +) -> int | None: + """Get delay minutes for the departure at index.""" + if departure is None: + return None + + planned_datetime = cph_to_tz(departure.date, departure.time, tz) + realtime_datetime = _get_departure_timestamp(departure, tz) + + if realtime_datetime is None: + return None + + delay_seconds = (realtime_datetime - planned_datetime).total_seconds() + delay_minutes = round(delay_seconds / 60) if delay_seconds != 0 else 0 - return int(diff.total_seconds() // 60) + return max(0, delay_minutes) -def setup_platform( +# SENSORS tuple definition +SENSORS: tuple[RejseplanenSensorEntityDescription, ...] = ( + RejseplanenSensorEntityDescription( + key="line", + translation_key="line", + value_fn=lambda departures, tz: ( + current[0].name + if (current := _get_current_departures(departures, tz)) + else None + ), + ), + RejseplanenSensorEntityDescription( + key="departure_time", + translation_key="departure_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda departures, tz: ( + _get_departure_timestamp(current[0], tz) + if (current := _get_current_departures(departures, tz)) + else None + ), + ), + RejseplanenSensorEntityDescription( + key="delay", + translation_key="delay", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + value_fn=lambda departures, tz: ( + _get_delay_minutes(current[0], tz) + if (current := _get_current_departures(departures, tz)) + else None + ), + ), + RejseplanenSensorEntityDescription( + key="direction", + translation_key="direction", + value_fn=lambda departures, tz: ( + current[0].direction + if (current := _get_current_departures(departures, tz)) + else None + ), + ), + RejseplanenSensorEntityDescription( + key="track", + translation_key="track", + value_fn=lambda departures, tz: ( + (current[0].rtTrack or current[0].track) + if (current := _get_current_departures(departures, tz)) + else None + ), + ), + RejseplanenSensorEntityDescription( + key="departures", + translation_key="no_departures", + value_fn=lambda departures, tz: len(_get_current_departures(departures, tz)), + ), +) + + +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_devices: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Rejseplanen transport sensor.""" - name = config[CONF_NAME] - stop_id = config[CONF_STOP_ID] - route = config.get(CONF_ROUTE) - direction = config[CONF_DIRECTION] - departure_type = config[CONF_DEPARTURE_TYPE] - - data = PublicTransportData(stop_id, route, direction, departure_type) - add_devices( - [RejseplanenTransportSensor(data, stop_id, route, direction, name)], True + """Set up the Rejseplanen sensors (deprecated).""" + ir.async_create_issue( + hass, + DOMAIN, + "yaml_deprecated", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="yaml_deprecated", + ) + _LOGGER.warning( + "YAML configuration for Rejseplanen is deprecated. " + "Please remove the rejseplanen entry from your configuration.yaml " + "and set up the integration through the UI" ) -class RejseplanenTransportSensor(SensorEntity): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: RejseplanenConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Rejseplanen sensors.""" + coordinator = config_entry.runtime_data + + # Process all subentries (stop configurations) + for subentry in config_entry.get_subentries_of_type("stop"): + async_add_entities( + [ + RejseplanenTransportSensor( + coordinator=coordinator, + config=subentry, + entity_description=description, + ) + for description in SENSORS + ], + config_subentry_id=subentry.subentry_id, + ) + + +class RejseplanenTransportSensor(RejseplanenEntity, SensorEntity): """Implementation of Rejseplanen transport sensor.""" - _attr_attribution = "Data provided by rejseplanen.dk" + entity_description: RejseplanenSensorEntityDescription _attr_icon = "mdi:bus" - def __init__(self, data, stop_id, route, direction, name): + def __init__( + self, + coordinator: RejseplanenDataUpdateCoordinator, + config: ConfigSubentry, + entity_description: RejseplanenSensorEntityDescription, + ) -> None: """Initialize the sensor.""" - self.data = data - self._name = name - self._stop_id = stop_id - self._route = route - self._direction = direction - self._times = self._state = None + stop_id = int(config.data[CONF_STOP_ID]) + super().__init__(coordinator, stop_id, config.title, config.subentry_id) + self.entity_description = entity_description - @property - @override - def name(self): - """Return the name of the sensor.""" - return self._name + self._departure_cleanup_unsubscribe: CALLBACK_TYPE | None = None + self._last_cleanup_time: datetime | None = None - @property - @override - def native_value(self): - """Return the state of the sensor.""" - return self._state + self._direction = config.data[CONF_DIRECTION] + self._departure_type = config.data[CONF_DEPARTURE_TYPE] + self._attr_unique_id = f"{config.subentry_id}_{entity_description.key}" + # Calculate bitflag for filtering + self._departure_type_bitflag = coordinator.api.calculate_departure_type_bitflag( + self._departure_type + ) - @property @override - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes.""" - if not self._times: - return {ATTR_STOP_ID: self._stop_id} + async def async_added_to_hass(self) -> None: + """Handle entity addition to Home Assistant.""" + await super().async_added_to_hass() + + # Use async_on_remove for proper cleanup registration + self.async_on_remove(self._cancel_cleanup_trigger) + self._schedule_next_cleanup() + + def _schedule_next_cleanup(self) -> None: + """Schedule the next departure cleanup trigger.""" + self._cancel_cleanup_trigger() + + departures = self._get_filtered_departures() + tz = dt_util.get_time_zone(self.hass.config.time_zone) or zoneinfo.ZoneInfo( + "UTC" + ) + cleanup_time = _get_next_departure_cleanup_time(departures, tz) + + if cleanup_time and cleanup_time != self._last_cleanup_time: + # Only schedule if we have a departure and it's different from last time + now = dt_util.utcnow() + if cleanup_time > now: + _LOGGER.debug( + "Scheduling departure cleanup for %s at %s", + self.entity_id, + cleanup_time, + ) + self._departure_cleanup_unsubscribe = async_track_point_in_time( + self.hass, self._async_departure_cleanup, cleanup_time + ) + self._last_cleanup_time = cleanup_time + + def _cancel_cleanup_trigger(self) -> None: + """Cancel any scheduled cleanup trigger.""" + if self._departure_cleanup_unsubscribe: + self._departure_cleanup_unsubscribe() + self._departure_cleanup_unsubscribe = None + self._last_cleanup_time = None - next_up = [] - if len(self._times) > 1: - next_up = self._times[1:] - - attributes = { - ATTR_NEXT_UP: next_up, - ATTR_STOP_ID: self._stop_id, - } + @override + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + super()._handle_coordinator_update() + self._schedule_next_cleanup() - if self._times[0] is not None: - attributes.update(self._times[0]) + @callback + def _async_departure_cleanup(self, now: datetime) -> None: + """Handle cleanup when a departure time has passed.""" + _LOGGER.debug("Processing departure cleanup for %s", self.entity_id) - return attributes + self.async_write_ha_state() + self._schedule_next_cleanup() - @property @override - def native_unit_of_measurement(self): - """Return the unit this state is expressed in.""" - return UnitOfTime.MINUTES - - def update(self) -> None: - """Get the latest data from rejseplanen.dk and update the states.""" - self.data.update() - self._times = self.data.info - - if not self._times: - self._state = None - else: - with suppress(TypeError): - self._state = self._times[0][ATTR_DUE_IN] - - -class PublicTransportData: - """The Class for handling the data retrieval.""" - - def __init__(self, stop_id, route, direction, departure_type): - """Initialize the data object.""" - self.stop_id = stop_id - self.route = route - self.direction = direction - self.departure_type = departure_type - self.info = [] - - def update(self): - """Get the latest data from rejseplanen.""" - self.info = [] - - def intersection(lst1, lst2): - """Return items contained in both lists.""" - return list(set(lst1) & set(lst2)) - - # Limit search to selected types, to get more results - all_types = not bool(self.departure_type) - use_train = all_types or bool(intersection(TRAIN_TYPES, self.departure_type)) - use_bus = all_types or bool(intersection(BUS_TYPES, self.departure_type)) - use_metro = all_types or bool(intersection(METRO_TYPES, self.departure_type)) - - try: - results = rjpl.departureBoard( - int(self.stop_id), - timeout=5, - useTrain=use_train, - useBus=use_bus, - useMetro=use_metro, - ) - except rjpl.rjplAPIError as error: - _LOGGER.debug("API returned error: %s", error) - return - except rjpl.rjplConnectionError, rjpl.rjplHTTPError: - _LOGGER.debug("Error occurred while connecting to the API") - return - - # Filter result - results = [d for d in results if "cancelled" not in d] - if self.route: - results = [d for d in results if d["name"] in self.route] - if self.direction: - results = [d for d in results if d["direction"] in self.direction] - if self.departure_type: - results = [d for d in results if d["type"] in self.departure_type] - - for item in results: - route = item.get("name") - - scheduled_date = item.get("date") - scheduled_time = item.get("time") - real_time_date = due_at_date = item.get("rtDate") - real_time_time = due_at_time = item.get("rtTime") - - if due_at_date is None: - due_at_date = scheduled_date - if due_at_time is None: - due_at_time = scheduled_time - - if ( - due_at_date is not None - and due_at_time is not None - and route is not None - ): - due_at = f"{due_at_date} {due_at_time}" - scheduled_at = f"{scheduled_date} {scheduled_time}" - - departure_data = { - ATTR_DIRECTION: item.get("direction"), - ATTR_DUE_IN: due_in_minutes(due_at), - ATTR_DUE_AT: due_at, - ATTR_FINAL_STOP: item.get("finalStop"), - ATTR_ROUTE: route, - ATTR_SCHEDULED_AT: scheduled_at, - ATTR_STOP_NAME: item.get("stop"), - ATTR_TYPE: item.get("type"), - } - - if real_time_date is not None and real_time_time is not None: - departure_data[ATTR_REAL_TIME_AT] = ( - f"{real_time_date} {real_time_time}" - ) - if item.get("rtTrack") is not None: - departure_data[ATTR_TRACK] = item.get("rtTrack") - - self.info.append(departure_data) - - if not self.info: - _LOGGER.debug("No departures with given parameters") - - # Sort the data by time - self.info = sorted(self.info, key=itemgetter(ATTR_DUE_IN)) + @property + def native_value(self) -> StateType | datetime | None: + """Return the state of the sensor.""" + departures = self._get_filtered_departures() + tz = dt_util.get_time_zone(self.hass.config.time_zone) or zoneinfo.ZoneInfo( + "UTC" + ) + return self.entity_description.value_fn(departures, tz) + + def _get_filtered_departures(self) -> list[Departure]: + """Get filtered departures based on the configured parameters.""" + return self.coordinator.get_filtered_departures( + stop_id=self._stop_id, + direction_filter=self._direction or None, + departure_type_filter=self._departure_type_bitflag, + ) diff --git a/homeassistant/components/rejseplanen/strings.json b/homeassistant/components/rejseplanen/strings.json new file mode 100644 index 00000000000000..fb4aa200f3e70f --- /dev/null +++ b/homeassistant/components/rejseplanen/strings.json @@ -0,0 +1,99 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + }, + "error": { + "cannot_connect": "Cannot connect to the Rejseplanen service", + "invalid_auth": "Invalid authentication key" + }, + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "API key provided by Rejseplanen.dk" + }, + "description": "Set up Rejseplanen data service." + } + } + }, + "config_subentries": { + "stop": { + "entry_type": "Stop", + "initiate_flow": { + "user": "Add stop" + }, + "step": { + "user": { + "data": { + "departure_type": "Departure type", + "direction": "Direction", + "name": "Name", + "stop_id": "Stop ID" + }, + "data_description": { + "departure_type": "Select which types of departures to include", + "direction": "Optional destination filter for departures", + "name": "An optional name for this stop subentry", + "stop_id": "The Rejseplanen stop ID for the stop to monitor" + }, + "description": "Set up stop subentry for Rejseplanen, see the [documentation]({documentation_url}) for information on obtaining the stop id.", + "title": "Add Stop" + } + } + } + }, + "entity": { + "sensor": { + "delay": { + "name": "Delayed by" + }, + "departure_time": { + "name": "Departing in" + }, + "direction": { + "name": "Towards" + }, + "is_delayed": { + "name": "Delayed" + }, + "line": { + "name": "Line" + }, + "no_departures": { + "name": "Number of departures" + }, + "track": { + "name": "Departing from track" + } + } + }, + "issues": { + "yaml_deprecated": { + "description": "You have configured Rejseplanen in configuration.yaml. This method is deprecated and will be ignored. Please use the UI to configure the integration. To solve this issue, remove the platform configuration from the configuration.yaml.", + "title": "YAML configuration detected" + } + }, + "selector": { + "departure_type": { + "options": { + "bus": "City buses", + "express_bus": "Express buses", + "ferry": "Ferry", + "flexible_bus": "Flexible transport", + "flight": "Flight", + "ic": "InterCity trains (IC, IB)", + "icl": "InterCity Lyn trains (ICL, ICL-X, ICL+)", + "letbane": "Light rail", + "metro": "Metro", + "night_bus": "Night & special buses", + "re": "Regional trains (Re, RA, RX)", + "s_tog": "S-trains (Copenhagen suburban)", + "tog": "Long distance trains (EC, IR, ICE, SJ, etc.)" + } + } + } +} diff --git a/homeassistant/components/remote_calendar/config_flow.py b/homeassistant/components/remote_calendar/config_flow.py index 3af55e7278f930..43826076f19a67 100644 --- a/homeassistant/components/remote_calendar/config_flow.py +++ b/homeassistant/components/remote_calendar/config_flow.py @@ -71,12 +71,8 @@ async def async_step_user( return await self.async_step_auth() if res.status_code == HTTPStatus.FORBIDDEN: errors["base"] = "forbidden" - return self.async_show_form( - step_id="user", - data_schema=STEP_USER_DATA_SCHEMA, - errors=errors, - ) - res.raise_for_status() + else: + res.raise_for_status() except TimeoutException as err: errors["base"] = "timeout_connect" _LOGGER.debug( @@ -86,18 +82,21 @@ async def async_step_user( errors["base"] = "cannot_connect" _LOGGER.debug("An error occurred: %s", str(err) or type(err).__name__) else: - try: - await parse_calendar(self.hass, res.text) - except InvalidIcsException: - errors["base"] = "invalid_ics_file" - else: - return self.async_create_entry( - title=user_input[CONF_CALENDAR_NAME], data=user_input - ) + if not errors: + try: + await parse_calendar(self.hass, res.text) + except InvalidIcsException: + errors["base"] = "invalid_ics_file" + else: + return self.async_create_entry( + title=user_input[CONF_CALENDAR_NAME], data=user_input + ) return self.async_show_form( step_id="user", - data_schema=STEP_USER_DATA_SCHEMA, + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), errors=errors, ) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 58c5f251af7aed..d871798807ce13 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -355,7 +355,7 @@ async def async_remove_config_entry_device( # This integration does not create child devices. return False host: ReolinkHost = config_entry.runtime_data.host - (_device_uid, ch, is_chime) = get_device_uid_and_ch(device, host) + (_device_uid, ch, is_chime) = get_device_uid_and_ch(device.identifiers, host) if is_chime: await host.api.get_state(cmd="GetDingDongList") @@ -442,7 +442,7 @@ def migrate_entity_ids( devices = dr.async_entries_for_config_entry(device_reg, config_entry_id) ch_device_ids = {} for device in devices: - (device_uid, ch, is_chime) = get_device_uid_and_ch(device, host) + (device_uid, ch, is_chime) = get_device_uid_and_ch(device.identifiers, host) if host.api.supported(None, "UID") and device_uid[0] != host.unique_id: if ch is None: @@ -465,7 +465,7 @@ def migrate_entity_ids( remove_ids = True # NVR/Hub in identifiers, keep that one, remove others for old_id in device.identifiers: (old_device_uid, _old_ch, _old_is_chime) = get_device_uid_and_ch( - old_id, host + {old_id}, host ) if ( not old_device_uid diff --git a/homeassistant/components/reolink/services.py b/homeassistant/components/reolink/services.py index d7e38d45e5c5cb..da42ff3aef5095 100644 --- a/homeassistant/components/reolink/services.py +++ b/homeassistant/components/reolink/services.py @@ -29,7 +29,9 @@ async def _async_play_chime(service_call: ServiceCall) -> None: service_call.hass, DOMAIN, device_id ) host: ReolinkHost = config_entry.runtime_data.host - (_device_uid, chime_id, is_chime) = get_device_uid_and_ch(device, host) + (_device_uid, chime_id, is_chime) = get_device_uid_and_ch( + device.identifiers, host + ) chime: Chime | None = host.api.chime(chime_id) if not is_chime or chime is None: raise ServiceValidationError( diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py index ecef195ebc2a09..a71bbff148bcdc 100644 --- a/homeassistant/components/reolink/util.py +++ b/homeassistant/components/reolink/util.py @@ -23,7 +23,6 @@ from homeassistant.components.media_source import Unresolvable from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import device_registry as dr from homeassistant.helpers.storage import Store from homeassistant.helpers.translation import async_get_exception_message @@ -75,18 +74,13 @@ def get_store(hass: HomeAssistant, config_entry_id: str) -> Store[str]: def get_device_uid_and_ch( - device: dr.DeviceEntry | tuple[str, str], host: ReolinkHost + identifiers: set[tuple[str, str]], host: ReolinkHost ) -> tuple[list[str], int | None, bool]: - """Get the channel and the split device_uid from a reolink DeviceEntry.""" + """Get the channel and the split device_uid from reolink device identifiers.""" device_uid = [] is_chime = False - if isinstance(device, dr.DeviceEntry): - dev_ids = device.identifiers - else: - dev_ids = {device} - - for dev_id in dev_ids: + for dev_id in identifiers: if dev_id[0] == DOMAIN: device_uid = dev_id[1].split("_") if device_uid[0] == host.unique_id: diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index b966717276ff5b..dc96a3ae369c6d 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -278,7 +278,10 @@ def __init__( @property @override - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return the value reported by the sensor.""" - value = self.coordinator.data[self.entity_description.data_protocol] + if ( + value := self.coordinator.data.get(self.entity_description.data_protocol) + ) is None: + return None return self.entity_description.value_fn(value) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index d66596f37c35af..c2bebddd979dff 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -574,6 +574,7 @@ def __init__( """Initialize.""" super().__init__(hass, config_entry, device) self.api = api + self._unsub_update = api.add_update_listener(self._handle_update) supported_schema_ids = device.product.supported_schema_ids self.request_protocols = [ protocol @@ -586,13 +587,41 @@ async def _async_update_data( self, ) -> dict[RoborockDyadDataProtocol, StateType]: try: - return await self.api.query_values(self.request_protocols) + await self.api.query_values(self.request_protocols) except RoborockException as ex: _LOGGER.debug("Failed to update wet dry vac data: %s", ex) - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="update_data_fail", - ) from ex + if not self._should_suppress_update_failure(): + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_data_fail", + ) from ex + return self.api.values + + def _should_suppress_update_failure(self) -> bool: + """Determine if we should suppress update failure reporting. + + The device leaves the network while it sleeps on its dock, so a poll can + fail while the device is still reporting its state on its own. + """ + if (last_message_time := self.api.last_message_time) is None: + return False + failure_duration = dt_util.utcnow() - last_message_time + _LOGGER.debug("Update failure duration: %s", failure_duration) + return failure_duration < MIN_UNAVAILABLE_DURATION + + @callback + def _handle_update(self) -> None: + """Apply the state the device reported on its own.""" + _LOGGER.debug("Wet dry vac state updated, updating coordinator data") + self.data = self.api.values + self.last_update_success = True + self.async_update_listeners() + + @override + async def async_shutdown(self) -> None: + """Stop following the device state on shutdown.""" + self._unsub_update() + await super().async_shutdown() class RoborockDataUpdateCoordinatorB01(DataUpdateCoordinator[B01Props]): diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 987c88b691dd9d..1a5219ec32303f 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -698,7 +698,7 @@ def __init__( @override def native_value(self) -> StateType: """Return the value reported by the sensor.""" - return self.coordinator.data[self.entity_description.data_protocol] + return self.coordinator.data.get(self.entity_description.data_protocol) class RoborockSensorEntityB01Q7(RoborockCoordinatedEntityB01Q7, SensorEntity): diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index 6c40b77dae0fc1..0580793b8003ee 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -147,8 +147,7 @@ def _async_search_area(self, area_id: str, *, entry_point: bool = True) -> None: self._add(ItemType.DEVICE, device.id) # Config entries for devices in this area - if device_entry := self._device_registry.async_get(device.id): - self._add(ItemType.CONFIG_ENTRY, device_entry.config_entries) + self._add(ItemType.CONFIG_ENTRY, device.config_entry_id) # Automations and scripts referencing this device self._async_add_automations_and_scripts_for_device(device) @@ -623,8 +622,12 @@ def _async_resolve_up_device(self, device_id: str) -> dr.AnyDeviceEntry | None: self._add(ItemType.AREA, area_id) self._async_resolve_up_area(area_id) - self._add(ItemType.CONFIG_ENTRY, device_entry.config_entries) - for config_entry_id in device_entry.config_entries: + if device_entry.is_composite_device: + config_entry_ids = device_entry.config_entries + else: + config_entry_ids = {device_entry.config_entry_id} + self._add(ItemType.CONFIG_ENTRY, config_entry_ids) + for config_entry_id in config_entry_ids: if entry := self.hass.config_entries.async_get_entry(config_entry_id): self._add(ItemType.INTEGRATION, entry.domain) diff --git a/homeassistant/components/smartthings/media_player.py b/homeassistant/components/smartthings/media_player.py index 28d5221c6b427e..8a4e70f9ebf79e 100644 --- a/homeassistant/components/smartthings/media_player.py +++ b/homeassistant/components/smartthings/media_player.py @@ -70,6 +70,17 @@ HA_REPEAT_MODE_TO_SMARTTHINGS = {v: k for k, v in REPEAT_MODE_TO_HA.items()} +NETWORK_AUDIO_VENDOR_IDS = ["VD-NetworkAudio-002S"] + +SOUND_MODE_TO_HA = { + "standard": "standard", + "surround": "surround", + "game": "game", + "adaptive sound": "adaptive_sound", +} + +HA_TO_SOUND_MODE = {v: k for k, v in SOUND_MODE_TO_HA.items()} + async def async_setup_entry( hass: HomeAssistant, @@ -104,6 +115,7 @@ def __init__(self, client: SmartThings, device: FullDevice) -> None: Capability.AUDIO_MUTE, Capability.AUDIO_TRACK_DATA, Capability.AUDIO_VOLUME, + Capability.EXECUTE, Capability.MEDIA_INPUT_SOURCE, Capability.MEDIA_PLAYBACK, Capability.MEDIA_PLAYBACK_REPEAT, @@ -120,6 +132,9 @@ def __init__(self, client: SmartThings, device: FullDevice) -> None: ) self._source_to_smartthings_id: dict[str, str] = {} + if self._supports_samsung_network_audio_sound_mode(): + self._attr_sound_mode_list = list(SOUND_MODE_TO_HA.values()) + @override def _update_attr(self) -> None: """Update the attributes.""" @@ -157,6 +172,11 @@ def _get_raw_source_list(self) -> list[str] | None: ) return None + def _supports_samsung_network_audio_sound_mode(self) -> bool: + """Return True if the device is a Samsung network audio soundbar.""" + ocf = self.device.device.ocf + return ocf is not None and ocf.vendor_id in NETWORK_AUDIO_VENDOR_IDS + def _determine_features(self) -> MediaPlayerEntityFeature: flags = ( MediaPlayerEntityFeature.VOLUME_SET @@ -189,6 +209,8 @@ def _determine_features(self) -> MediaPlayerEntityFeature: flags |= MediaPlayerEntityFeature.SHUFFLE_SET if self.supports_capability(Capability.MEDIA_PLAYBACK_REPEAT): flags |= MediaPlayerEntityFeature.REPEAT_SET + if self._supports_samsung_network_audio_sound_mode(): + flags |= MediaPlayerEntityFeature.SELECT_SOUND_MODE return flags @override @@ -316,6 +338,20 @@ async def async_set_repeat(self, repeat: RepeatMode) -> None: argument=HA_REPEAT_MODE_TO_SMARTTHINGS[repeat], ) + @override + async def async_select_sound_mode(self, sound_mode: str) -> None: + """Select sound mode.""" + await self.execute_device_command( + Capability.EXECUTE, + Command.EXECUTE, + argument=[ + "/sec/networkaudio/soundmode", + {"x.com.samsung.networkaudio.soundmode": HA_TO_SOUND_MODE[sound_mode]}, + ], + ) + self._attr_sound_mode = sound_mode + self.async_write_ha_state() + @property @override def media_title(self) -> str | None: diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index d2250780190cd5..648ad48ad1ed33 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -193,6 +193,14 @@ "media_player": { "media_player": { "state_attributes": { + "sound_mode": { + "state": { + "adaptive_sound": "Adaptive sound", + "game": "Game", + "standard": "Standard", + "surround": "Surround" + } + }, "source": { "state": { "am": "AM", diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index 1308ed5b54c3e6..1b6cfbdb5d2f9d 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -3,6 +3,7 @@ import asyncio from typing import Final +from aiohttp import ClientError import jwt from tesla_fleet_api import TeslaFleetApi, is_valid_region from tesla_fleet_api.const import Scope @@ -77,7 +78,12 @@ async def _async_get_products(tesla: TeslaFleetApi) -> list[dict]: OAuth2TokenRequestReauthError, ) as e: raise ConfigEntryAuthFailed from e - except (TeslaFleetError, OAuth2TokenRequestError) as e: + except ( + TeslaFleetError, + OAuth2TokenRequestError, + ClientError, + TimeoutError, + ) as e: raise ConfigEntryNotReady from e try: @@ -90,7 +96,12 @@ async def _async_get_products(tesla: TeslaFleetApi) -> list[dict]: OAuth2TokenRequestReauthError, ) as e: raise ConfigEntryAuthFailed from e - except (TeslaFleetError, OAuth2TokenRequestError) as e: + except ( + TeslaFleetError, + OAuth2TokenRequestError, + ClientError, + TimeoutError, + ) as e: raise ConfigEntryNotReady from e try: @@ -102,7 +113,12 @@ async def _async_get_products(tesla: TeslaFleetApi) -> list[dict]: OAuth2TokenRequestReauthError, ) as e: raise ConfigEntryAuthFailed from e - except (TeslaFleetError, OAuth2TokenRequestError) as e: + except ( + TeslaFleetError, + OAuth2TokenRequestError, + ClientError, + TimeoutError, + ) as e: raise ConfigEntryNotReady from e diff --git a/homeassistant/components/tesla_fleet/models.py b/homeassistant/components/tesla_fleet/models.py index d81ceada6966d7..9515aea791a5fb 100644 --- a/homeassistant/components/tesla_fleet/models.py +++ b/homeassistant/components/tesla_fleet/models.py @@ -1,7 +1,7 @@ """The Tesla Fleet integration models.""" import asyncio -from dataclasses import dataclass +from dataclasses import dataclass, field from tesla_fleet_api.const import Scope from tesla_fleet_api.tesla import EnergySite, VehicleFleet @@ -34,7 +34,7 @@ class TeslaFleetVehicleData: vin: str device: DeviceInfo signing: bool - wakelock = asyncio.Lock() + wakelock: asyncio.Lock = field(default_factory=asyncio.Lock) @dataclass diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index a587c860125c60..554cdb366850c6 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -8,16 +8,19 @@ from aiohttp import ClientError from aiopowerwall import PowerwallClient, PowerwallEnergySite, PowerwallError +from bleak.exc import BleakError from tesla_fleet_api.const import Scope from tesla_fleet_api.exceptions import ( Forbidden, InvalidToken, LoginRequired, + PrivateKeyError, SubscriptionRequired, TeslaFleetError, ) +from tesla_fleet_api.router import VehicleRouter from tesla_fleet_api.tesla import EnergySiteRouter -from tesla_fleet_api.teslemetry import EnergySite, Teslemetry +from tesla_fleet_api.teslemetry import EnergySite, Teslemetry, Vehicle from teslemetry_stream import TeslemetryStream, TeslemetryStreamAuthenticationError from teslemetry_stream.const import SseTopic @@ -25,8 +28,15 @@ ClientCredential, async_import_client_credential, ) +from homeassistant.components.bluetooth import async_ble_device_from_address from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_PASSWORD, Platform +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_ADDRESS, + CONF_HOST, + CONF_PASSWORD, + Platform, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ( ConfigEntryAuthFailed, @@ -50,11 +60,13 @@ from .const import ( CLIENT_ID, + CONF_VIN, DOMAIN, LOGGER, POWERWALL_KEY_FILE, RSA_PARENT_KEY, SUBENTRY_TYPE_ENERGY_SITE, + SUBENTRY_TYPE_VEHICLE, VEHICLE_ISSUE_LEARN_MORE, ) from .coordinator import ( @@ -64,7 +76,7 @@ TeslemetryMetadataCoordinator, TeslemetryVehicleDataCoordinator, ) -from .helpers import async_update_device_sw_version, flatten +from .helpers import async_get_ble_parent, async_update_device_sw_version, flatten from .models import TeslemetryData, TeslemetryEnergyData, TeslemetryVehicleData from .services import async_setup_services @@ -271,6 +283,71 @@ def _handle_metadata_update() -> None: ) +def _ble_address_for_vin(entry: TeslemetryConfigEntry, vin: str) -> str | None: + """Return the paired Bluetooth address for a vehicle, if one was added.""" + for subentry in entry.subentries.values(): + if ( + subentry.subentry_type == SUBENTRY_TYPE_VEHICLE + and subentry.data.get(CONF_VIN) == vin + ): + return subentry.data.get(CONF_ADDRESS) + return None + + +# Two failure shapes must be caught to fall back to cloud control: the library +# wraps existing-key failures in PrivateKeyError, the create-race path raises raw errors. +_BLE_KEY_ERRORS: Final = ( + OSError, + ValueError, + AssertionError, + TypeError, + PrivateKeyError, +) + + +async def _async_resolve_vehicle_api( + hass: HomeAssistant, + entry: TeslemetryConfigEntry, + vin: str, + cloud_vehicle: Vehicle, +) -> Vehicle | VehicleRouter: + """Return the API a vehicle's platforms should call.""" + address = _ble_address_for_vin(entry, vin) + if not address: + return cloud_vehicle + + # A bad BLE key file for one vehicle must not tear down the whole entry. + try: + parent = await async_get_ble_parent(hass) + except _BLE_KEY_ERRORS: + LOGGER.warning( + "Failed to load the Bluetooth key for vehicle %s; " + "falling back to cloud control", + vin, + exc_info=True, + ) + return cloud_vehicle + # disable keep alive to allow vehicles to sleep + bluetooth_vehicle = parent.vehicles.createBluetooth( + vin, + confirmation="verify", + raise_unconfirmed=False, + keepalive_interval=None, + ) + + @callback + def _in_range() -> bool: + """Report whether the vehicle is currently reachable over Bluetooth.""" + device = async_ble_device_from_address(hass, address, connectable=True) + if device is None: + return False + # The library never refreshes the BLE handle, so set it here while it is known fresh. + bluetooth_vehicle.set_device(device) + return True + + return VehicleRouter(bluetooth_vehicle, cloud_vehicle, health=_in_range) + + def _find_energy_subentry_id(entry: TeslemetryConfigEntry, site_id: int) -> str | None: """Return the user-added local-control subentry id bound to site_id, if any.""" return next( @@ -333,20 +410,17 @@ async def _async_get_rsa_key_pem(hass: HomeAssistant) -> bytes: pem: bytes | None = hass.data.get(RSA_PARENT_KEY) if pem is None: path = hass.config.path(POWERWALL_KEY_FILE) - try: - await Teslemetry( - session=async_get_clientsession(hass), access_token="" - ).get_rsa_private_key(path) - except TypeError as err: - # An encrypted PEM surfaces as TypeError from the cryptography loader. - raise ValueError("RSA private key file is encrypted") from err + await Teslemetry( + session=async_get_clientsession(hass), access_token="" + ).get_rsa_private_key(path) pem = await hass.async_add_executor_job(Path(path).read_bytes) hass.data[RSA_PARENT_KEY] = pem return pem -# aiopowerwall raises PowerwallError; key I/O and parsing raise OSError/ValueError. -_LOCAL_CONTROL_ERRORS: Final = (OSError, ValueError, PowerwallError) +# Both key-load failure shapes (wrapped PrivateKeyError, raw OSError/ValueError) plus +# PowerwallError must be caught to fall back to cloud control. +_LOCAL_CONTROL_ERRORS: Final = (OSError, ValueError, PowerwallError, PrivateKeyError) async def _async_resolve_local_control( @@ -526,9 +600,16 @@ def create_stream() -> TeslemetryStream: ) stream_vehicle = stream.get_vehicle(vin) + vehicle_api = await _async_resolve_vehicle_api( + hass, + entry, + vin, + vehicle, + ) + vehicles.append( TeslemetryVehicleData( - api=vehicle, + api=vehicle_api, config_entry=entry, coordinator=coordinator, poll=poll, @@ -697,7 +778,7 @@ async def listen() -> None: def _setup_subentry_change_reload( hass: HomeAssistant, entry: TeslemetryConfigEntry ) -> None: - """Reload the entry when a local-energy-site subentry is added or removed.""" + """Reload the entry when a subentry is added or removed.""" known = set(entry.subentries) async def _handle_update( @@ -818,7 +899,20 @@ async def _async_setup_energy_site( async def async_unload_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -> bool: """Unload Teslemetry Config.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unloaded: + # Release any on-demand Bluetooth link only after platforms unloaded, or the still-loaded entry's backends must keep working. + for vehicle in entry.runtime_data.vehicles: + if isinstance(vehicle.api, VehicleRouter): + try: + await vehicle.api.primary.disconnect() + except (BleakError, TeslaFleetError, TimeoutError) as err: + # Swallowed so one stuck link cannot block the unload, but + # warn: a leaked BLE connection can keep the vehicle awake. + LOGGER.warning( + "Error disconnecting Bluetooth for %s: %s", vehicle.vin, err + ) + return unloaded async def async_migrate_entry( diff --git a/homeassistant/components/teslemetry/button.py b/homeassistant/components/teslemetry/button.py index 891ea9ede7a044..e0f71f4331dc0d 100644 --- a/homeassistant/components/teslemetry/button.py +++ b/homeassistant/components/teslemetry/button.py @@ -5,6 +5,7 @@ from typing import Any, override from tesla_fleet_api.const import Scope +from tesla_fleet_api.router import VehicleRouter from tesla_fleet_api.teslemetry import Vehicle from homeassistant.components.button import ButtonEntity, ButtonEntityDescription @@ -75,7 +76,7 @@ async def async_setup_entry( class TeslemetryButtonEntity(TeslemetryVehicleStreamEntity, ButtonEntity): """Base class for Teslemetry buttons.""" - api: Vehicle + api: Vehicle | VehicleRouter entity_description: TeslemetryButtonEntityDescription def __init__( diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index 6fefafa54cabb1..917ada942230c4 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -5,6 +5,7 @@ from tesla_fleet_api import firmware_at_least from tesla_fleet_api.const import CabinOverheatProtectionTemp, Scope +from tesla_fleet_api.router import VehicleRouter from tesla_fleet_api.teslemetry import Vehicle from homeassistant.components.climate import ( @@ -90,7 +91,7 @@ async def async_setup_entry( class TeslemetryClimateEntity(TeslemetryRootEntity, ClimateEntity): """Vehicle Climate Control.""" - api: Vehicle + api: Vehicle | VehicleRouter _attr_precision = PRECISION_HALVES _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = [HVACMode.HEAT_COOL, HVACMode.OFF] @@ -385,7 +386,7 @@ def _async_handle_rhd(self, data: bool | None) -> None: class TeslemetryCabinOverheatProtectionEntity(TeslemetryRootEntity, ClimateEntity): """Vehicle Cabin Overheat Protection.""" - api: Vehicle + api: Vehicle | VehicleRouter _attr_precision = PRECISION_WHOLE _attr_target_temperature_step = 5 _attr_min_temp = 30 diff --git a/homeassistant/components/teslemetry/config_flow.py b/homeassistant/components/teslemetry/config_flow.py index cec2ed7198ff92..0b34a86cb324f3 100644 --- a/homeassistant/components/teslemetry/config_flow.py +++ b/homeassistant/components/teslemetry/config_flow.py @@ -1,5 +1,6 @@ """Config Flow for Teslemetry integration.""" +import asyncio from collections.abc import Mapping import logging from pathlib import Path @@ -7,16 +8,23 @@ from aiohttp import ClientError from aiopowerwall import PowerwallAuthenticationError, PowerwallClient, PowerwallError +from bleak.exc import BleakError from tesla_fleet_api.const import ( AuthorizedClientKeyType, AuthorizedClientState, AuthorizedClientType, ) from tesla_fleet_api.exceptions import ( + BluetoothTimeout, + BluetoothTransportError, InvalidToken, + NotOnWhitelistFault, + PrivateKeyError, SubscriptionRequired, TeslaFleetError, + WhitelistOperationAttemptingToAddExistingKey, ) +from tesla_fleet_api.tesla.vehicle.bluetooth import VehicleBluetooth from tesla_fleet_api.teslemetry import Teslemetry from tesla_fleet_api.teslemetry.energysite import AuthorizedClient, TeslemetryEnergySite import voluptuous as vol @@ -25,6 +33,10 @@ ClientCredential, async_import_client_credential, ) +from homeassistant.components.bluetooth import ( + async_discovered_service_info, + async_request_active_scan, +) from homeassistant.config_entries import ( SOURCE_REAUTH, SOURCE_RECONFIGURE, @@ -34,20 +46,29 @@ ConfigSubentryFlow, SubentryFlowResult, ) -from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_PASSWORD from homeassistant.core import callback from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) -from . import TeslemetryConfigEntry +from . import _BLE_KEY_ERRORS, TeslemetryConfigEntry from .const import ( CLIENT_ID, CONF_SITE_ID, + CONF_VIN, DOMAIN, LOGGER, POWERWALL_KEY_FILE, SUBENTRY_TYPE_ENERGY_SITE, + SUBENTRY_TYPE_VEHICLE, ) +from .helpers import async_get_ble_parent class PowerwallLookupError(Exception): @@ -85,7 +106,10 @@ def async_get_supported_subentry_types( cls, config_entry: ConfigEntry ) -> dict[str, type[ConfigSubentryFlow]]: """Return the subentry types supported by this integration.""" - return {SUBENTRY_TYPE_ENERGY_SITE: EnergySiteSubentryFlowHandler} + return { + SUBENTRY_TYPE_VEHICLE: VehicleSubentryFlowHandler, + SUBENTRY_TYPE_ENERGY_SITE: EnergySiteSubentryFlowHandler, + } @override async def async_step_user( @@ -187,6 +211,210 @@ async def async_step_reconfigure( return await self.async_step_user() +class VehicleSubentryFlowHandler(ConfigSubentryFlow): + """Add local Bluetooth control to one of the account's vehicles.""" + + def __init__(self) -> None: + """Initialize the vehicle subentry flow.""" + self._vin: str | None = None + self._title: str | None = None + self._address: str | None = None + self._vehicle: VehicleBluetooth | None = None + self._pair_task: asyncio.Task[None] | None = None + self._pair_error: dict[str, str] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Select an account vehicle to add over Bluetooth, then pair it.""" + entry = self._get_entry() + if entry.state is not ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + already_added = { + subentry.data[CONF_VIN] + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE) + if CONF_VIN in subentry.data + } + choices = { + vehicle.vin: vehicle.device["name"] or vehicle.vin + for vehicle in entry.runtime_data.vehicles + if vehicle.vin not in already_added + } + if not choices: + return self.async_abort(reason="no_vehicles") + + if user_input is not None: + self._vin = user_input[CONF_VIN] + self._title = choices[self._vin] + return await self.async_step_scan() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_VIN): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict(value=vin, label=name) + for vin, name in choices.items() + ], + mode=SelectSelectorMode.DROPDOWN, + ) + ) + } + ), + ) + + async def async_step_scan( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Find the vehicle over Bluetooth and connect to it.""" + if TYPE_CHECKING: + assert self._vin is not None + errors: dict[str, str] = {} + + if user_input is not None: + try: + parent = await async_get_ble_parent(self.hass) + except _BLE_KEY_ERRORS as err: + LOGGER.debug("Bluetooth key load failed: %s", err) + errors["base"] = "cannot_connect" + else: + # The advertised BLE name is a hash of the VIN; match on its prefix. + expected = parent.get_name(self._vin)[:17] + device = None + # The name is only in scan responses, so an active scan may be needed to see it. + await async_request_active_scan(self.hass) + for info in async_discovered_service_info(self.hass, connectable=True): + if info.name and info.name.startswith(expected): + device = info.device + self._address = info.address + break + + if device is None: + errors["base"] = "device_not_found" + else: + # Uses default keepalive so the link survives the on-screen key-approval wait. + self._vehicle = parent.vehicles.createBluetooth( + self._vin, device=device + ) + try: + await self._vehicle.connect() + except (BleakError, TeslaFleetError, TimeoutError) as err: + LOGGER.error("Failed to connect over Bluetooth: %s", err) + await self._async_disconnect() + errors["base"] = "cannot_connect" + else: + return await self.async_step_pair() + + return self.async_show_form( + step_id="scan", + errors=errors, + description_placeholders={"vin": self._vin}, + ) + + async def async_step_pair( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Check whether the virtual key is already whitelisted on the vehicle.""" + if TYPE_CHECKING: + assert self._vehicle is not None + try: + await self._vehicle.handshakeVehicleSecurity() + except NotOnWhitelistFault: + return await self.async_step_instructions() + except (BleakError, TeslaFleetError, TimeoutError) as err: + LOGGER.error("Bluetooth security handshake failed: %s", err) + await self._async_disconnect() + # The scan step owns the form; re-show it so a retry redoes scan and connect. + return self.async_show_form( + step_id="scan", + errors={"base": "cannot_connect"}, + description_placeholders={"vin": self._vin or ""}, + ) + if TYPE_CHECKING: + assert self._address is not None + assert self._vin is not None + await self._async_disconnect() + return self.async_create_entry( + title=self._title or self._vin, + data={CONF_VIN: self._vin, CONF_ADDRESS: self._address}, + unique_id=self._vin, + ) + + async def async_step_instructions( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Ask the user to approve the virtual key on the vehicle touchscreen.""" + if user_input is not None: + return await self.async_step_authorize() + errors = self._pair_error + self._pair_error = {} + return self.async_show_form( + step_id="instructions", + errors=errors, + description_placeholders={"vin": self._vin or ""}, + ) + + async def async_step_authorize( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Add the virtual key to the vehicle while showing pairing progress.""" + if self._pair_task is None: + if TYPE_CHECKING: + assert self._vehicle is not None + # pair() can take minutes, so run it as a progress task rather than blocking the flow. + self._pair_task = self.hass.async_create_task(self._vehicle.pair()) + + if not self._pair_task.done(): + return self.async_show_progress( + step_id="authorize", + progress_action="pair", + progress_task=self._pair_task, + description_placeholders={"vin": self._vin or ""}, + ) + + task = self._pair_task + self._pair_task = None + try: + task.result() + except (BluetoothTransportError, BleakError) as err: + LOGGER.debug("Bluetooth transport failed during pairing: %s", err) + self._pair_error = {"base": "cannot_connect"} + return self.async_show_progress_done(next_step_id="instructions") + except (BluetoothTimeout, TimeoutError) as err: + LOGGER.debug("Bluetooth pairing timed out: %s", err) + self._pair_error = {"base": "timeout"} + return self.async_show_progress_done(next_step_id="instructions") + except WhitelistOperationAttemptingToAddExistingKey as err: + LOGGER.debug("Virtual key is already on the whitelist: %s", err) + except TeslaFleetError as err: + LOGGER.error("Bluetooth pairing was rejected: %s", err) + self._pair_error = {"base": "pair_failed"} + return self.async_show_progress_done(next_step_id="instructions") + return self.async_show_progress_done(next_step_id="pair") + + async def _async_disconnect(self) -> None: + """Disconnect the BLE link, if any, and drop the reference to it.""" + vehicle = self._vehicle + if vehicle is not None: + try: + await vehicle.disconnect() + except (BleakError, TeslaFleetError, TimeoutError) as err: + LOGGER.debug("Error disconnecting Bluetooth: %s", err) + finally: + self._vehicle = None + + @callback + @override + def async_remove(self) -> None: + """Release resources if the flow is abandoned mid-pairing.""" + if self._pair_task is not None and not self._pair_task.done(): + self._pair_task.cancel() + if self._vehicle is not None: + self.hass.async_create_task(self._async_disconnect()) + + class EnergySiteSubentryFlowHandler(ConfigSubentryFlow): """Pair a local Powerwall gateway for TEDAPI v1r command routing.""" @@ -257,10 +485,7 @@ async def async_step_user( async def _prepare_energy_site( self, energy_site: TeslemetryEnergySite ) -> SubentryFlowResult | None: - """Discover the gateway address and load the integration's RSA key. - - Returns an abort result if the RSA key cannot be loaded, else None. - """ + """Discover the gateway address and load the integration's RSA key.""" self._energy_site = energy_site try: @@ -277,15 +502,11 @@ async def _prepare_energy_site( session=async_get_clientsession(self.hass), access_token="" ) try: - try: - await keyholder.get_rsa_private_key(path) - except TypeError as err: - # An encrypted PEM surfaces as TypeError from the cryptography loader. - raise ValueError("RSA private key file is encrypted") from err + await keyholder.get_rsa_private_key(path) self._key_pem = await self.hass.async_add_executor_job( Path(path).read_bytes ) - except (OSError, ValueError) as err: + except (OSError, ValueError, PrivateKeyError) as err: LOGGER.debug("RSA key load failed: %s", err) return self.async_abort(reason="cannot_connect") self._public_key_der = keyholder.rsa_public_der_pkcs1 diff --git a/homeassistant/components/teslemetry/const.py b/homeassistant/components/teslemetry/const.py index 4f3aa00868a519..c94145d19a6c0c 100644 --- a/homeassistant/components/teslemetry/const.py +++ b/homeassistant/components/teslemetry/const.py @@ -12,6 +12,12 @@ TOKEN_URL = "https://api.teslemetry.com/oauth/token" CLIENT_ID = "homeassistant" +SUBENTRY_TYPE_VEHICLE = "vehicle" +CONF_VIN = "vin" +VEHICLE_KEY_FILE = "tesla_vehicle.key" +BLE_PARENT_KEY = f"{DOMAIN}_ble_parent" +BLE_PARENT_LOCK_KEY = f"{DOMAIN}_ble_parent_lock" + SUBENTRY_TYPE_ENERGY_SITE = "energy_site" CONF_SITE_ID = "site_id" POWERWALL_KEY_FILE = "tesla_powerwall.key" diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py index e54e978f74b322..42fbe3377cf93f 100644 --- a/homeassistant/components/teslemetry/cover.py +++ b/homeassistant/components/teslemetry/cover.py @@ -11,6 +11,7 @@ Trunk, WindowCommand, ) +from tesla_fleet_api.router import VehicleRouter from tesla_fleet_api.teslemetry import Vehicle from teslemetry_stream import Signal from teslemetry_stream.const import WindowState @@ -120,7 +121,7 @@ async def async_added_to_hass(self) -> None: class TeslemetryWindowEntity(TeslemetryRootEntity, CoverEntity): """Base class for window cover entities.""" - api: Vehicle + api: Vehicle | VehicleRouter _attr_device_class = CoverDeviceClass.WINDOW _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE @@ -254,7 +255,7 @@ class TeslemetryChargePortEntity( ): """Base class for for charge port cover entities.""" - api: Vehicle + api: Vehicle | VehicleRouter _attr_device_class = CoverDeviceClass.DOOR _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE @@ -340,7 +341,7 @@ def _async_value_from_stream(self, value: bool | None) -> None: class TeslemetryFrontTrunkEntity(TeslemetryRootEntity, CoverEntity): """Base class for the front trunk cover entities.""" - api: Vehicle + api: Vehicle | VehicleRouter _attr_device_class = CoverDeviceClass.DOOR _attr_supported_features = CoverEntityFeature.OPEN @@ -407,7 +408,7 @@ def _async_value_from_stream(self, value: bool | None) -> None: class TeslemetryRearTrunkEntity(TeslemetryRootEntity, CoverEntity): """Cover entity for the rear trunk.""" - api: Vehicle + api: Vehicle | VehicleRouter _attr_device_class = CoverDeviceClass.DOOR _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE @@ -482,7 +483,7 @@ def _async_value_from_stream(self, value: bool | None) -> None: class TeslemetrySunroofEntity(TeslemetryVehiclePollingEntity, CoverEntity): """Cover entity for the sunroof.""" - api: Vehicle + api: Vehicle | VehicleRouter _attr_device_class = CoverDeviceClass.WINDOW _attr_supported_features = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP @@ -538,7 +539,7 @@ async def async_stop_cover(self, **kwargs: Any) -> None: class TeslemetryTonneauEntity(TeslemetryRootEntity, CoverEntity): """Base class for the Cybertruck tonneau cover entity.""" - api: Vehicle + api: Vehicle | VehicleRouter _attr_device_class = CoverDeviceClass.DOOR _attr_supported_features = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py index f6c8d453b64b5a..9c4c3d88b7b827 100644 --- a/homeassistant/components/teslemetry/entity.py +++ b/homeassistant/components/teslemetry/entity.py @@ -4,6 +4,7 @@ from typing import Any, override from tesla_fleet_api.const import Scope +from tesla_fleet_api.router import VehicleRouter from tesla_fleet_api.tesla import EnergySiteRouter from tesla_fleet_api.teslemetry import EnergySite, Vehicle @@ -106,7 +107,7 @@ class TeslemetryVehiclePollingEntity(TeslemetryPollingEntity): """Parent class for Teslemetry Vehicle entities.""" _last_update: int = 0 - api: Vehicle + api: Vehicle | VehicleRouter vehicle: TeslemetryVehicleData def __init__( @@ -259,7 +260,7 @@ def exists(self) -> bool: class TeslemetryVehicleStreamEntity(TeslemetryRootEntity): """Parent class for Teslemetry Vehicle Stream entities.""" - api: Vehicle + api: Vehicle | VehicleRouter def __init__(self, data: TeslemetryVehicleData, key: str) -> None: """Initialize common aspects of a Teslemetry entity.""" diff --git a/homeassistant/components/teslemetry/helpers.py b/homeassistant/components/teslemetry/helpers.py index 7c85f8cca9bda7..e3ef8a1b3ebf64 100644 --- a/homeassistant/components/teslemetry/helpers.py +++ b/homeassistant/components/teslemetry/helpers.py @@ -1,15 +1,30 @@ """Teslemetry helper functions.""" +import asyncio from collections.abc import Awaitable from typing import Any from tesla_fleet_api.exceptions import TeslaFleetError +from tesla_fleet_api.tesla.bluetooth import TeslaBluetooth from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from .const import DOMAIN, LOGGER +from .const import BLE_PARENT_KEY, BLE_PARENT_LOCK_KEY, DOMAIN, LOGGER, VEHICLE_KEY_FILE + + +async def async_get_ble_parent(hass: HomeAssistant) -> TeslaBluetooth: + """Return a shared TeslaBluetooth parent with the private key loaded.""" + lock: asyncio.Lock = hass.data.setdefault(BLE_PARENT_LOCK_KEY, asyncio.Lock()) + async with lock: + existing: TeslaBluetooth | None = hass.data.get(BLE_PARENT_KEY) + if existing is not None: + return existing + parent = TeslaBluetooth() + await parent.get_private_key(hass.config.path(VEHICLE_KEY_FILE)) + hass.data[BLE_PARENT_KEY] = parent + return parent def flatten( diff --git a/homeassistant/components/teslemetry/lock.py b/homeassistant/components/teslemetry/lock.py index cf578db2ff953b..7b331e4be6f367 100644 --- a/homeassistant/components/teslemetry/lock.py +++ b/homeassistant/components/teslemetry/lock.py @@ -5,6 +5,7 @@ from tesla_fleet_api import firmware_at_least from tesla_fleet_api.const import Scope +from tesla_fleet_api.router import VehicleRouter from tesla_fleet_api.teslemetry import Vehicle from homeassistant.components.lock import LockEntity @@ -64,7 +65,7 @@ async def async_setup_entry( class TeslemetryVehicleLockEntity(TeslemetryRootEntity, LockEntity): """Base vehicle lock entity for Teslemetry.""" - api: Vehicle + api: Vehicle | VehicleRouter @override async def async_lock(self, **kwargs: Any) -> None: @@ -141,7 +142,7 @@ def _callback(self, value: bool | None) -> None: class TeslemetryCableLockEntity(TeslemetryRootEntity, LockEntity): """Base cable Lock entity for Teslemetry.""" - api: Vehicle + api: Vehicle | VehicleRouter @override async def async_lock(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 2257ff2b335424..14310abd092662 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -3,7 +3,7 @@ "name": "Teslemetry", "codeowners": ["@Bre77"], "config_flow": true, - "dependencies": ["application_credentials"], + "dependencies": ["application_credentials", "bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/teslemetry", "integration_type": "hub", "iot_class": "cloud_polling", diff --git a/homeassistant/components/teslemetry/media_player.py b/homeassistant/components/teslemetry/media_player.py index 5c1d332c103de8..9c73d05a17031b 100644 --- a/homeassistant/components/teslemetry/media_player.py +++ b/homeassistant/components/teslemetry/media_player.py @@ -4,6 +4,7 @@ from tesla_fleet_api import firmware_at_least from tesla_fleet_api.const import Scope +from tesla_fleet_api.router import VehicleRouter from tesla_fleet_api.teslemetry import Vehicle from homeassistant.components.media_player import ( @@ -64,7 +65,7 @@ async def async_setup_entry( class TeslemetryMediaEntity(TeslemetryRootEntity, MediaPlayerEntity): """Base vehicle media player class.""" - api: Vehicle + api: Vehicle | VehicleRouter _attr_device_class = MediaPlayerDeviceClass.SPEAKER _attr_volume_step = VOLUME_STEP diff --git a/homeassistant/components/teslemetry/models.py b/homeassistant/components/teslemetry/models.py index 12c602036d0500..b951173f60b609 100644 --- a/homeassistant/components/teslemetry/models.py +++ b/homeassistant/components/teslemetry/models.py @@ -4,6 +4,7 @@ from dataclasses import dataclass, field from tesla_fleet_api.const import Scope +from tesla_fleet_api.router import VehicleRouter from tesla_fleet_api.tesla import EnergySiteRouter from tesla_fleet_api.teslemetry import EnergySite, Vehicle from teslemetry_stream import TeslemetryStream, TeslemetryStreamVehicle @@ -35,7 +36,7 @@ class TeslemetryData: class TeslemetryVehicleData: """Data for a vehicle in the Teslemetry integration.""" - api: Vehicle + api: Vehicle | VehicleRouter config_entry: ConfigEntry coordinator: TeslemetryVehicleDataCoordinator poll: bool diff --git a/homeassistant/components/teslemetry/number.py b/homeassistant/components/teslemetry/number.py index 548d95ddca7f03..6240f6aa59c00f 100644 --- a/homeassistant/components/teslemetry/number.py +++ b/homeassistant/components/teslemetry/number.py @@ -7,6 +7,7 @@ from tesla_fleet_api import firmware_at_least from tesla_fleet_api.const import Scope +from tesla_fleet_api.router import VehicleRouter from tesla_fleet_api.tesla import EnergySiteRouter from tesla_fleet_api.teslemetry import EnergySite, Vehicle from teslemetry_stream import TeslemetryStreamVehicle @@ -45,7 +46,7 @@ class TeslemetryNumberVehicleEntityDescription(NumberEntityDescription): """Describes Teslemetry Number entity.""" - func: Callable[[Vehicle, int], Awaitable[Any]] + func: Callable[[Vehicle | VehicleRouter, int], Awaitable[Any]] min_key: str | None = None max_key: str native_min_value: float @@ -171,7 +172,7 @@ async def async_setup_entry( class TeslemetryVehicleNumberEntity(TeslemetryRootEntity, NumberEntity): """Vehicle number entity base class.""" - api: Vehicle + api: Vehicle | VehicleRouter entity_description: TeslemetryNumberVehicleEntityDescription @override diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py index 0f337fddabf5fa..9468865749b0e2 100644 --- a/homeassistant/components/teslemetry/select.py +++ b/homeassistant/components/teslemetry/select.py @@ -6,6 +6,7 @@ from tesla_fleet_api import firmware_at_least from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode, Scope, Seat +from tesla_fleet_api.router import VehicleRouter from tesla_fleet_api.teslemetry import Vehicle from teslemetry_stream import TeslemetryStreamVehicle @@ -43,7 +44,7 @@ class TeslemetrySelectEntityDescription(SelectEntityDescription): """Seat Heater entity description.""" - select_fn: Callable[[Vehicle, int], Awaitable[Any]] + select_fn: Callable[[Vehicle | VehicleRouter, int], Awaitable[Any]] supported_fn: Callable[[dict], bool] = lambda _: True streaming_listener: ( Callable[ @@ -273,7 +274,7 @@ async def async_setup_entry( class TeslemetrySelectEntity(TeslemetryRootEntity, SelectEntity): """Parent vehicle select entity class.""" - api: Vehicle + api: Vehicle | VehicleRouter entity_description: TeslemetrySelectEntityDescription _climate: bool = False diff --git a/homeassistant/components/teslemetry/services.py b/homeassistant/components/teslemetry/services.py index 022de84d55ddaa..58a5fa12f2e640 100644 --- a/homeassistant/components/teslemetry/services.py +++ b/homeassistant/components/teslemetry/services.py @@ -73,8 +73,9 @@ def async_get_device_and_config_for_service_call( ) -> tuple[dr.DeviceEntry, TeslemetryConfigEntry]: """Get the device entry and config entry related to a service call.""" config_entry: TeslemetryConfigEntry + # Callers match the device's serial number, which only a main device has device_entry, config_entry = service.async_get_device_and_config_entry( - hass, DOMAIN, call.data[CONF_DEVICE_ID] + hass, DOMAIN, call.data[CONF_DEVICE_ID], include_child_devices=False ) return device_entry, config_entry diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 8d10d28ce2bd7a..1cbdaf47ea629c 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -48,6 +48,7 @@ "energy_site": { "abort": { "all_sites_added": "Every accessible energy site with a Powerwall on your Teslemetry account has already been added for local control.", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "entry_not_loaded": "The Teslemetry account must be loaded before setting up local control. Try again once it has finished loading.", "no_powerwall": "Local control requires a Powerwall, and no energy site with one is currently accessible on your Teslemetry account." @@ -84,6 +85,46 @@ "title": "Add local energy site" } } + }, + "vehicle": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "entry_not_loaded": "The Teslemetry configuration entry is not loaded. Please ensure it is set up correctly before adding a vehicle.", + "no_vehicles": "Every vehicle in your Teslemetry account has already been added over Bluetooth." + }, + "entry_type": "Bluetooth vehicle", + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "device_not_found": "No matching Tesla vehicle was found nearby over Bluetooth. Make sure the vehicle is awake and within range, then try again.", + "pair_failed": "The vehicle rejected the key. Make sure it is not in valet mode and does not already have the maximum number of keys, then try again.", + "timeout": "Timed out waiting for the vehicle to accept the key. Try again after approving the key on the vehicle's touchscreen." + }, + "initiate_flow": { + "user": "Add Bluetooth vehicle" + }, + "progress": { + "pair": "Approve Home Assistant's virtual key on the vehicle's touchscreen. Waiting for the vehicle to accept the key." + }, + "step": { + "instructions": { + "description": "Select **Submit**, then place your key card against the center console card reader of vehicle {vin} to approve Home Assistant's virtual key. This only needs to be done once.", + "title": "Approve the virtual key" + }, + "scan": { + "description": "Home Assistant will look for vehicle {vin} over Bluetooth to enable local command control. Make sure the vehicle is awake and within Bluetooth range of a Home Assistant Bluetooth adapter, then continue.", + "title": "Set up Bluetooth control" + }, + "user": { + "data": { + "vin": "Vehicle" + }, + "data_description": { + "vin": "The account vehicle to enable local Bluetooth control for." + }, + "description": "Select which of your Teslemetry account vehicles to add over Bluetooth for local command control.", + "title": "Add Bluetooth vehicle" + } + } } }, "entity": { diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py index 05b3ea75c6965f..4ecb2cf248edd6 100644 --- a/homeassistant/components/teslemetry/switch.py +++ b/homeassistant/components/teslemetry/switch.py @@ -6,6 +6,7 @@ from tesla_fleet_api import firmware_at_least from tesla_fleet_api.const import AutoSeat, Scope +from tesla_fleet_api.router import VehicleRouter from tesla_fleet_api.teslemetry import Vehicle from teslemetry_stream import TeslemetryStreamVehicle @@ -37,8 +38,8 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription): """Describes Teslemetry Switch entity.""" polling: bool = False - on_func: Callable[[Vehicle], Awaitable[dict[str, Any]]] - off_func: Callable[[Vehicle], Awaitable[dict[str, Any]]] + on_func: Callable[[Vehicle | VehicleRouter], Awaitable[dict[str, Any]]] + off_func: Callable[[Vehicle | VehicleRouter], Awaitable[dict[str, Any]]] scopes: list[Scope] value_func: Callable[[StateType], bool] = bool streaming_listener: Callable[ @@ -200,7 +201,7 @@ async def async_setup_entry( class TeslemetryVehicleSwitchEntity(TeslemetryRootEntity, SwitchEntity): """Base class for all Teslemetry switch entities.""" - api: Vehicle + api: Vehicle | VehicleRouter _attr_device_class = SwitchDeviceClass.SWITCH entity_description: TeslemetrySwitchEntityDescription diff --git a/homeassistant/components/teslemetry/update.py b/homeassistant/components/teslemetry/update.py index 94747e786e5051..543960d80c2582 100644 --- a/homeassistant/components/teslemetry/update.py +++ b/homeassistant/components/teslemetry/update.py @@ -4,6 +4,7 @@ from tesla_fleet_api import firmware_at_least from tesla_fleet_api.const import Scope +from tesla_fleet_api.router import VehicleRouter from tesla_fleet_api.teslemetry import Vehicle from homeassistant.components.update import ( @@ -51,7 +52,7 @@ async def async_setup_entry( class TeslemetryUpdateEntity(TeslemetryRootEntity, UpdateEntity): """Teslemetry Updates entity.""" - api: Vehicle + api: Vehicle | VehicleRouter _attr_supported_features = UpdateEntityFeature.PROGRESS @override diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index dda330cba92bf2..7d9c2cc86b5a5c 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -833,23 +833,6 @@ def _async_signal_public_update( update_callback(obj) -@callback -def async_ufp_instance_for_config_entry_ids( - hass: HomeAssistant, config_entry_ids: set[str] -) -> ProtectApiClient | None: - """Find the UFP instance for the config entry ids.""" - return next( - iter( - entry.runtime_data.api - for entry_id in config_entry_ids - if (entry := hass.config_entries.async_get_entry(entry_id)) - and entry.domain == DOMAIN - and hasattr(entry, "runtime_data") - ), - None, - ) - - @callback def async_get_ufp_entries(hass: HomeAssistant) -> list[UFPConfigEntry]: """Get all the UFP entries.""" diff --git a/homeassistant/components/unifiprotect/services.py b/homeassistant/components/unifiprotect/services.py index 93cdd16e5067db..211b64db30e36c 100644 --- a/homeassistant/components/unifiprotect/services.py +++ b/homeassistant/components/unifiprotect/services.py @@ -25,6 +25,7 @@ config_validation as cv, device_registry as dr, entity_registry as er, + service, ) from homeassistant.helpers.target import ( TargetSelection, @@ -43,7 +44,7 @@ KEYRINGS_USER_FULL_NAME, KEYRINGS_USER_STATUS, ) -from .data import async_ufp_instance_for_config_entry_ids +from .data import UFPConfigEntry _LOGGER = logging.getLogger(__name__) @@ -104,35 +105,24 @@ @callback def _async_get_ufp_instance(hass: HomeAssistant, device_id: str) -> ProtectApiClient: device_registry = dr.async_get(hass) - if not (device_entry := device_registry.async_get(device_id)): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="device_not_found", - translation_placeholders={"device_id": device_id}, - ) + device_entry = device_registry.async_get(device_id) if isinstance(device_entry, dr.ChildDeviceEntry): return _async_get_ufp_instance(hass, device_entry.parent_device_id) - if device_entry.via_device_id is not None: + if device_entry is not None and device_entry.via_device_id is not None: return _async_get_ufp_instance(hass, device_entry.via_device_id) - config_entry_ids = device_entry.config_entries - if ufp_instance := async_ufp_instance_for_config_entry_ids(hass, config_entry_ids): - if ufp_instance.is_public_only: - # Actions read/write through the private bootstrap, which an - # API-key-only entry never initializes. - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="public_only_no_actions", - ) - return ufp_instance - - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="device_not_found", - translation_placeholders={"device_id": device_id}, - ) + _, config_entry = service.async_get_device_and_config_entry(hass, DOMAIN, device_id) + ufp_instance = cast(UFPConfigEntry, config_entry).runtime_data.api + if ufp_instance.is_public_only: + # Actions read/write through the private bootstrap, which an + # API-key-only entry never initializes. + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="public_only_no_actions", + ) + return ufp_instance @callback diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 49272c1732af40..debed2d482a7be 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -841,9 +841,6 @@ "command_error": { "message": "Error communicating with UniFi Protect while sending command: {error}" }, - "device_not_found": { - "message": "No device found for device id: {device_id}" - }, "entry_auth_failed": { "message": "Authentication failed, please reauthenticate" }, diff --git a/homeassistant/components/zhong_hong/__init__.py b/homeassistant/components/zhong_hong/__init__.py index 2533d981f48f66..5aadcae03984ee 100644 --- a/homeassistant/components/zhong_hong/__init__.py +++ b/homeassistant/components/zhong_hong/__init__.py @@ -1,56 +1,28 @@ """The ZhongHong HVAC integration.""" from collections.abc import Iterable -from dataclasses import dataclass from zhong_hong_hvac.hub import ZhongHongGateway from zhong_hong_hvac.hvac import HVAC as ZhongHongHVAC from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import entity_registry as er from .const import CONF_GATEWAY_ADDRESS, DOMAIN +from .coordinator import ( + DeviceAddress, + ZhongHongConfigEntry, + ZhongHongCoordinator, + ZhongHongData, + device_unique_id, + legacy_device_unique_id, +) PLATFORMS: list[Platform] = [Platform.CLIMATE] -type DeviceAddress = tuple[int, int] - - -@dataclass -class ZhongHongData: - """What a loaded config entry holds. - - The air conditioners are found once, when the entry is set up: discovery - needs the listener thread stopped, so the gateway cannot be asked again - while the entry is running. - """ - - hub: ZhongHongGateway - devices: dict[DeviceAddress, ZhongHongHVAC] - - -type ZhongHongConfigEntry = ConfigEntry[ZhongHongData] - - -def device_unique_id(entry: ZhongHongConfigEntry, address: DeviceAddress) -> str: - """Return the unique ID of the air conditioner at an address.""" - return f"{entry.entry_id}_{address[0]}_{address[1]}" - - -def legacy_device_unique_id(address: DeviceAddress) -> str: - """Return the identifier the YAML platform gave the air conditioner. - - It carried only the address on the bus, so two gateways with an air - conditioner at the same address, which `(1, 1)` commonly is, produced the - same one and the second entity was dropped. Entities are moved off it on - setup; it is still needed to find them. - """ - return f"zhong_hong_hvac_{address[0]}_{address[1]}" - @callback def _async_migrate_unique_ids( @@ -93,8 +65,8 @@ def _connect(hub: ZhongHongGateway) -> dict[DeviceAddress, ZhongHongHVAC]: """Ask the gateway what is on its bus, then start listening to it. Discovery has to finish before the listener thread starts, because the two - read from the same socket. All of it blocks, so it runs as one executor - job rather than hopping back to the event loop in between. + read from the same socket. Both block, so this runs as one executor job + rather than hopping back to the event loop in between. """ addresses = hub.discovery_ac() if not addresses: @@ -105,12 +77,6 @@ def _connect(hub: ZhongHongGateway) -> dict[DeviceAddress, ZhongHongHVAC]: devices = {address: ZhongHongHVAC(hub, *address) for address in addresses} hub.start_listen() - - # The gateway reports a unit only when it changes, so without asking once - # here the entities would have no state until someone touched a unit. - if not hub.query_all_status(): - raise OSError(f"The gateway at {hub.ip_addr} did not answer the first query") - return devices @@ -155,7 +121,10 @@ async def _async_close() -> None: devices = await _async_connect(hass, hub) - entry.runtime_data = ZhongHongData(hub, devices) + coordinator = ZhongHongCoordinator(hass, entry, hub, devices) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = ZhongHongData(hub, devices, coordinator) _async_migrate_unique_ids(hass, entry, devices) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/zhong_hong/climate.py b/homeassistant/components/zhong_hong/climate.py index be5566e467e309..3af5dff40ccb07 100644 --- a/homeassistant/components/zhong_hong/climate.py +++ b/homeassistant/components/zhong_hong/climate.py @@ -28,8 +28,8 @@ AddEntitiesCallback, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DeviceAddress, ZhongHongConfigEntry, device_unique_id from .const import ( ALL_FAN_MODES, BREAKS_IN_HA_VERSION, @@ -42,6 +42,12 @@ INTEGRATION_TITLE, LOGGER, ) +from .coordinator import ( + DeviceAddress, + ZhongHongConfigEntry, + ZhongHongCoordinator, + device_unique_id, +) # The gateway serializes everything onto a single socket, so there is nothing # to gain from issuing commands in parallel. @@ -154,13 +160,14 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ZhongHong climate entities from a config entry.""" + data = entry.runtime_data async_add_entities( - ZhongHongClimate(entry, address, device) - for address, device in entry.runtime_data.devices.items() + ZhongHongClimate(data.coordinator, entry, address, device) + for address, device in data.devices.items() ) -class ZhongHongClimate(ClimateEntity): +class ZhongHongClimate(CoordinatorEntity[ZhongHongCoordinator], ClimateEntity): """Representation of an air conditioner behind a ZhongHong gateway.""" _attr_fan_modes = ALL_FAN_MODES @@ -171,9 +178,6 @@ class ZhongHongClimate(ClimateEntity): HVACMode.FAN_ONLY, HVACMode.OFF, ] - # The gateway reports every change on its own socket, so there is nothing - # to poll for. - _attr_should_poll = False _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE @@ -185,30 +189,18 @@ class ZhongHongClimate(ClimateEntity): def __init__( self, + coordinator: ZhongHongCoordinator, entry: ZhongHongConfigEntry, address: DeviceAddress, device: ZhongHongHVAC, ) -> None: """Set up a ZhongHong climate device.""" + super().__init__(coordinator) self._device = device addr_out, addr_in = address self._attr_name = f"AC {addr_out}-{addr_in}" self._attr_unique_id = device_unique_id(entry, address) - @override - async def async_added_to_hass(self) -> None: - """Take the state the gateway pushes for this air conditioner.""" - self._device.register_update_callback(self._handle_device_update) - - def _handle_device_update(self, device: ZhongHongHVAC) -> None: - """Handle a state push from the gateway. - - The library writes the new state into the device object before calling - this, and it does so on its own listener thread, so all that is left is - to ask for the entity to be written from that thread. - """ - self.schedule_update_ha_state() - @property @override def current_temperature(self) -> float | None: diff --git a/homeassistant/components/zhong_hong/config_flow.py b/homeassistant/components/zhong_hong/config_flow.py index e3d34b254cfd1e..485b88a56fc40a 100644 --- a/homeassistant/components/zhong_hong/config_flow.py +++ b/homeassistant/components/zhong_hong/config_flow.py @@ -75,8 +75,8 @@ async def async_step_user( if user_input is not None: # A gateway is identified by the endpoint it is reached on and the - # address it answers to, all three of which are needed to talk - # to it. + # address it answers to, all three of which the coordinator needs + # to talk to it. self._async_abort_entries_match( { CONF_HOST: user_input[CONF_HOST], diff --git a/homeassistant/components/zhong_hong/const.py b/homeassistant/components/zhong_hong/const.py index dc5b714626bf02..2adf190521f6e4 100644 --- a/homeassistant/components/zhong_hong/const.py +++ b/homeassistant/components/zhong_hong/const.py @@ -1,5 +1,6 @@ """Constants for the ZhongHong integration.""" +from datetime import timedelta import logging from typing import Final @@ -38,3 +39,7 @@ DEFAULT_PORT: Final = 9999 DEFAULT_GATEWAY_ADDRESS: Final = 1 + +# The gateway pushes state changes, so polling only has to cover pushes that +# were missed while the connection was down. +SCAN_INTERVAL: Final = timedelta(seconds=60) diff --git a/homeassistant/components/zhong_hong/coordinator.py b/homeassistant/components/zhong_hong/coordinator.py new file mode 100644 index 00000000000000..ec96c8ef5bff9e --- /dev/null +++ b/homeassistant/components/zhong_hong/coordinator.py @@ -0,0 +1,109 @@ +"""Coordinator for the ZhongHong integration.""" + +from dataclasses import dataclass +from typing import override + +from zhong_hong_hvac.hub import ZhongHongGateway +from zhong_hong_hvac.hvac import HVAC as ZhongHongHVAC + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER, SCAN_INTERVAL + +type DeviceAddress = tuple[int, int] + + +@dataclass +class ZhongHongData: + """What a loaded config entry holds. + + The air conditioners are found once, when the entry is set up: discovery + needs the listener thread stopped, so the gateway cannot be asked again + while the entry is running. They belong to the entry that found them + rather than to whatever happens to be updating them. + """ + + hub: ZhongHongGateway + devices: dict[DeviceAddress, ZhongHongHVAC] + coordinator: ZhongHongCoordinator + + +type ZhongHongConfigEntry = ConfigEntry[ZhongHongData] + + +def device_unique_id(entry: ZhongHongConfigEntry, address: DeviceAddress) -> str: + """Return the unique ID of the air conditioner at an address.""" + return f"{entry.entry_id}_{address[0]}_{address[1]}" + + +def legacy_device_unique_id(address: DeviceAddress) -> str: + """Return the identifier the YAML platform gave the air conditioner. + + It carried only the address on the bus, so two gateways with an air + conditioner at the same address, which `(1, 1)` commonly is, produced the + same one and the second entity was dropped. Entities are moved off it on + setup; it is still needed to find them. + """ + return f"zhong_hong_hvac_{address[0]}_{address[1]}" + + +class ZhongHongCoordinator(DataUpdateCoordinator[None]): + """Tell the entities when to look at their air conditioner again. + + There is no data to hand out. The gateway pushes state on its own socket + and the library writes it into the device objects in place, so all this + has to carry is that something changed; the entities read the device they + were given. Polling remains as a fallback for pushes missed while the + connection was down, and is what decides availability. + + The connection and the devices belong to the config entry, which hands + them over already listening. + """ + + config_entry: ZhongHongConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: ZhongHongConfigEntry, + hub: ZhongHongGateway, + devices: dict[DeviceAddress, ZhongHongHVAC], + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=config_entry.data[CONF_HOST], + update_interval=SCAN_INTERVAL, + ) + self.hub = hub + + for device in devices.values(): + device.register_update_callback(self._handle_device_update) + + def _handle_device_update(self, device: ZhongHongHVAC) -> None: + """Handle a state push from the gateway. + + Called on the library's listener thread, so the update has to be handed + back to the event loop before touching any coordinator state. + + The listeners are told directly rather than through + `async_set_updated_data`, which would also push the next poll back a + full interval. A push says one unit changed, not that every unit was + accounted for, so a gateway with something on it that reports often + would keep postponing the poll the units that went quiet depend on. + """ + self.hass.loop.call_soon_threadsafe(self.async_update_listeners) + + @override + async def _async_update_data(self) -> None: + """Ask the gateway to re-send the state of every device.""" + if not self.hub.connected: + raise UpdateFailed(f"Lost connection to the gateway at {self.hub.ip_addr}") + + if not await self.hass.async_add_executor_job(self.hub.query_all_status): + raise UpdateFailed(f"Failed to query the gateway at {self.hub.ip_addr}") diff --git a/homeassistant/components/zhong_hong/manifest.json b/homeassistant/components/zhong_hong/manifest.json index 94ca40091bf94c..52482564712eb9 100644 --- a/homeassistant/components/zhong_hong/manifest.json +++ b/homeassistant/components/zhong_hong/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zhong_hong_hvac"], "quality_scale": "legacy", - "requirements": ["zhong-hong-hvac==1.0.19"] + "requirements": ["zhong-hong-hvac==1.0.21"] } diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 93b46d0940e4c2..d6d19a1959042b 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -286,7 +286,7 @@ def async_get_node_from_device_id(hass: HomeAssistant, device_id: str) -> ZwaveN # Use the device config entry to validate that this is a valid zwave_js device # and to get the client device, config_entry = cast( - tuple[dr.DeviceEntry | None, ZwaveJSConfigEntry | None], + tuple[dr.AnyDeviceEntry | None, ZwaveJSConfigEntry | None], dr.async_get_device_and_config_entry_for_domain(hass, device_id, domain=DOMAIN), ) if device is None: @@ -340,7 +340,7 @@ async def async_get_provisioning_entry_from_device_id( # Use the device config entry to validate that this is a valid zwave_js device # and to get the client device, config_entry = cast( - tuple[dr.DeviceEntry | None, ZwaveJSConfigEntry | None], + tuple[dr.AnyDeviceEntry | None, ZwaveJSConfigEntry | None], dr.async_get_device_and_config_entry_for_domain(hass, device_id, domain=DOMAIN), ) if device is None: diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 26f525c1a545d1..c65c3d747a7132 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -661,6 +661,7 @@ "redgtech", "refoss", "rehlko", + "rejseplanen", "remember_the_milk", "remote_calendar", "renault", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index cf9ab63316b623..57731dce82ea9a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6074,9 +6074,10 @@ }, "rejseplanen": { "name": "Rejseplanen", - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_polling" + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "single_config_entry": true }, "remember_the_milk": { "name": "Remember The Milk", diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 0144fbe94c6400..9ad99a6e378db0 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -4593,26 +4593,80 @@ def async_get_device_id_by_identifier( return device.id +@overload +def async_get_device_and_config_entry_for_domain( + hass: HomeAssistant, + device_id: str, + *, + domain: str, + include_child_devices: Literal[False], + include_main_devices: bool = True, +) -> tuple[DeviceEntry | None, ConfigEntry | None]: ... + + +@overload +def async_get_device_and_config_entry_for_domain( + hass: HomeAssistant, + device_id: str, + *, + domain: str, + include_child_devices: Literal[True] = True, + include_main_devices: Literal[False], +) -> tuple[ChildDeviceEntry | None, ConfigEntry | None]: ... + + +@overload +def async_get_device_and_config_entry_for_domain( + hass: HomeAssistant, + device_id: str, + *, + domain: str, + include_child_devices: Literal[True] = True, + include_main_devices: Literal[True] = True, +) -> tuple[AnyDeviceEntry | None, ConfigEntry | None]: ... + + @callback def async_get_device_and_config_entry_for_domain( - hass: HomeAssistant, device_id: str, *, domain: str -) -> tuple[DeviceEntry | None, ConfigEntry | None]: + hass: HomeAssistant, + device_id: str, + *, + domain: str, + include_child_devices: bool = True, + include_main_devices: bool = True, +) -> tuple[AnyDeviceEntry | None, ConfigEntry | None]: """Get the device and the config entry of the domain owning it. - Returns (None, None) for an unknown device id or if the device is a child - device, and (device, None) when no config entry of the domain owns the - device. A returned pair is consistent: for a pre-migration composite - device id, the device is the domain's split device, not the composite; if - several splits belong to config entries of the domain, which pair is - returned is undefined. When no split matches the domain, the restored - composite is returned as the device. + Returns (None, None) for an unknown device id, and (device, None) when no + config entry of the domain owns the device. + + With include_child_devices=False a child-device id resolves to None. + + With include_main_devices=False a main-device id resolves to None. A + composite-device id then resolves to None as well, because both the splits + of a composite and the restored composite itself are main devices. + + A returned pair is consistent: for a pre-migration composite device id, the + device is the domain's split device, not the composite; if several splits + belong to config entries of the domain, which pair is returned is undefined. + When no split matches the domain, the restored composite is returned as the + device. """ registry = async_get(hass) - if (device := registry._devices.get(device_id)) is not None: # noqa: SLF001 + device: AnyDeviceEntry | None = None + if include_main_devices: + device = registry._devices.get(device_id) # noqa: SLF001 + if device is None and include_child_devices: + device = registry.async_get( + device_id, include_main_devices=False, include_composite_devices=False + ) + if device is not None: config_entry = hass.config_entries.async_get_entry(device.config_entry_id) if config_entry is not None and config_entry.domain == domain: return device, config_entry return device, None + if not include_main_devices: + return None, None for split in registry.async_get_devices_for_composite_device_id(device_id): config_entry = hass.config_entries.async_get_entry(split.config_entry_id) if config_entry is not None and config_entry.domain == domain: diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index a7b7f3842e55dd..d4e9534c6337ba 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -7,7 +7,7 @@ import inspect import logging from types import ModuleType -from typing import TYPE_CHECKING, Any, TypedDict, cast +from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast, overload import voluptuous as vol @@ -433,7 +433,7 @@ async def async_extract_config_entry_ids( if ( device := dev_reg.async_get(device_id, include_composite_devices=False) ) is not None: - config_entry_ids.update(device.config_entries) + config_entry_ids.add(device.config_entry_id) for entity_id in referenced.referenced | referenced.indirectly_referenced: entry = ent_reg.async_get(entity_id) @@ -1425,17 +1425,67 @@ def _async_get_single_loaded_config_entry( return config_entry +@overload +def async_get_device_and_config_entry( + hass: HomeAssistant, + domain: str, + device_id: str, + *, + include_child_devices: Literal[False], + include_main_devices: bool = True, +) -> tuple[device_registry.DeviceEntry, ConfigEntry]: ... + + +@overload +def async_get_device_and_config_entry( + hass: HomeAssistant, + domain: str, + device_id: str, + *, + include_child_devices: Literal[True] = True, + include_main_devices: Literal[False], +) -> tuple[device_registry.ChildDeviceEntry, ConfigEntry]: ... + + +@overload +def async_get_device_and_config_entry( + hass: HomeAssistant, + domain: str, + device_id: str, + *, + include_child_devices: Literal[True] = True, + include_main_devices: Literal[True] = True, +) -> tuple[device_registry.AnyDeviceEntry, ConfigEntry]: ... + + @callback def async_get_device_and_config_entry( - hass: HomeAssistant, domain: str, device_id: str -) -> tuple[device_registry.DeviceEntry, ConfigEntry]: + hass: HomeAssistant, + domain: str, + device_id: str, + *, + include_child_devices: bool = True, + include_main_devices: bool = True, +) -> tuple[device_registry.AnyDeviceEntry, ConfigEntry]: """Get and validate the device and the loaded config entry of the domain owning it. Raises ServiceValidationError if the device is unknown, is not owned by a config entry of the domain, or if that config entry is not loaded. + + With include_child_devices=False a child-device id raises as an unknown device. + With include_main_devices=False a main-device id raises as an unknown device; + as does a composite-device id, because both the splits of a composite and the + restored composite itself are main devices. """ - device, config_entry = device_registry.async_get_device_and_config_entry_for_domain( - hass, device_id, domain=domain + device: device_registry.AnyDeviceEntry | None + config_entry: ConfigEntry | None + # The flags are plain bools here, which matches none of the Literal overloads + device, config_entry = device_registry.async_get_device_and_config_entry_for_domain( # type: ignore[call-overload] + hass, + device_id, + domain=domain, + include_child_devices=include_child_devices, + include_main_devices=include_main_devices, ) if device is None: raise ServiceValidationError( diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 17f13279796bda..3aa9230ec35160 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -40,7 +40,7 @@ habluetooth==7.0.0 hass-nabucasa==2.7.0 hassil==3.12.0 home-assistant-bluetooth==2.0.0 -home-assistant-frontend==20260826.6 +home-assistant-frontend==20260826.7 home-assistant-intents==2026.8.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py index 494445e416478e..5afca78a453a12 100644 --- a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py +++ b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py @@ -5,7 +5,7 @@ from typing import Final -FRONTEND_VERSION: Final[str] = "20260826.6" +FRONTEND_VERSION: Final[str] = "20260826.7" MDI_ICONS: Final[set[str]] = { "ab-testing", diff --git a/requirements_all.txt b/requirements_all.txt index 346388511b6208..9ff3c813539e29 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1312,7 +1312,7 @@ hole==0.9.2 holidays==0.103 # homeassistant.components.frontend -home-assistant-frontend==20260826.6 +home-assistant-frontend==20260826.7 # homeassistant.components.conversation home-assistant-intents==2026.8.28 @@ -2217,7 +2217,7 @@ pyegps==0.2.5 pyemoncms==0.1.3 # homeassistant.components.enphase_envoy -pyenphase==4.0.1 +pyenphase==4.0.3 # homeassistant.components.envertech_evt800 pyenvertechevt800==0.2.4 @@ -2569,6 +2569,9 @@ pyrate-limiter==4.5.0 # homeassistant.components.recswitch pyrecswitch==1.0.2 +# homeassistant.components.rejseplanen +pyrejseplan==1.0.9 + # homeassistant.components.repetier pyrepetierng==0.1.0 @@ -2994,9 +2997,6 @@ ring-doorbell==0.9.14 # homeassistant.components.fleetgo ritassist==0.9.2 -# homeassistant.components.rejseplanen -rjpl==0.3.6 - # homeassistant.components.rocketchat rocketchat-API==0.6.1 @@ -3555,7 +3555,7 @@ zha-quirks==2.2.2 zha==2.2.2 # homeassistant.components.zhong_hong -zhong-hong-hvac==1.0.19 +zhong-hong-hvac==1.0.21 # homeassistant.components.ziggo_mediabox_xl ziggo-mediabox-xl==1.1.0 diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 971743f480940f..19e64adb53d126 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -82,7 +82,7 @@ FlowResultType, section, ) -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.translation import async_get_translations from homeassistant.helpers.typing import VolSchemaType @@ -1249,6 +1249,8 @@ async def _check_exception_translation( request: pytest.FixtureRequest, ignore_translations_for_mock_domains: set[str], ) -> None: + if isinstance(exception, Unauthorized): + return if exception.translation_key is None: if ( _get_request_quality_scale(request, "exception-translations") diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index 4cc8658f3e0b8e..ba5d1b9fcee78a 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -1243,6 +1243,50 @@ async def test_automation_with_dynamically_validated_trigger( module.async_attach_trigger.assert_awaited_once() +@pytest.mark.usefixtures("fake_integration") +async def test_automation_with_child_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test device automation targeting a child device of the domain's config entry.""" + module_cache = hass.data[loader.DATA_COMPONENTS] + module = module_cache["fake_integration.device_trigger"] + module.async_attach_trigger = AsyncMock() + module.async_validate_trigger_config = AsyncMock(wraps=lambda hass, config: config) + + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.mock_state(hass, ConfigEntryState.LOADED) + config_entry.add_to_hass(hass) + parent_device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("fake_integration", "parent")}, + ) + child_device_entry = device_registry.async_get_or_create_child( + config_entry_id=config_entry.entry_id, + identifiers={("fake_integration", "child")}, + parent_device_id=parent_device_entry.id, + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "alias": "hello", + "trigger": { + "platform": "device", + "device_id": child_device_entry.id, + "domain": "fake_integration", + }, + "action": {"service": "test.automation", "entity_id": "hello.world"}, + } + }, + ) + + module.async_validate_trigger_config.assert_awaited_once() + module.async_attach_trigger.assert_awaited_once() + + async def test_automation_with_integration_without_device_trigger( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/components/energyzero/snapshots/test_services.ambr b/tests/components/energyzero/snapshots/test_services.ambr index 9dda7e607c235b..b0b3351425aa8b 100644 --- a/tests/components/energyzero/snapshots/test_services.ambr +++ b/tests/components/energyzero/snapshots/test_services.ambr @@ -3,99 +3,147 @@ dict({ 'prices': list([ dict({ + 'end': '2026-04-10 08:00:00+00:00', 'price': 0.158361775, + 'start': '2026-04-10 07:00:00+00:00', 'timestamp': '2026-04-10 07:00:00+00:00', }), dict({ + 'end': '2026-04-10 09:00:00+00:00', 'price': 0.15199415, + 'start': '2026-04-10 08:00:00+00:00', 'timestamp': '2026-04-10 08:00:00+00:00', }), dict({ + 'end': '2026-04-10 10:00:00+00:00', 'price': 0.129712, + 'start': '2026-04-10 09:00:00+00:00', 'timestamp': '2026-04-10 09:00:00+00:00', }), dict({ + 'end': '2026-04-10 11:00:00+00:00', 'price': 0.12542255, + 'start': '2026-04-10 10:00:00+00:00', 'timestamp': '2026-04-10 10:00:00+00:00', }), dict({ + 'end': '2026-04-10 12:00:00+00:00', 'price': 0.1105819, + 'start': '2026-04-10 11:00:00+00:00', 'timestamp': '2026-04-10 11:00:00+00:00', }), dict({ + 'end': '2026-04-10 13:00:00+00:00', 'price': 0.11546425, + 'start': '2026-04-10 12:00:00+00:00', 'timestamp': '2026-04-10 12:00:00+00:00', }), dict({ + 'end': '2026-04-10 14:00:00+00:00', 'price': 0.10231155, + 'start': '2026-04-10 13:00:00+00:00', 'timestamp': '2026-04-10 13:00:00+00:00', }), dict({ + 'end': '2026-04-10 15:00:00+00:00', 'price': 0.122527625, + 'start': '2026-04-10 14:00:00+00:00', 'timestamp': '2026-04-10 14:00:00+00:00', }), dict({ + 'end': '2026-04-10 16:00:00+00:00', 'price': 0.146068175, + 'start': '2026-04-10 15:00:00+00:00', 'timestamp': '2026-04-10 15:00:00+00:00', }), dict({ + 'end': '2026-04-10 17:00:00+00:00', 'price': 0.181984, + 'start': '2026-04-10 16:00:00+00:00', 'timestamp': '2026-04-10 16:00:00+00:00', }), dict({ + 'end': '2026-04-10 18:00:00+00:00', 'price': 0.272253025, + 'start': '2026-04-10 17:00:00+00:00', 'timestamp': '2026-04-10 17:00:00+00:00', }), dict({ + 'end': '2026-04-10 19:00:00+00:00', 'price': 0.288152425, + 'start': '2026-04-10 18:00:00+00:00', 'timestamp': '2026-04-10 18:00:00+00:00', }), dict({ + 'end': '2026-04-10 20:00:00+00:00', 'price': 0.2064623, + 'start': '2026-04-10 19:00:00+00:00', 'timestamp': '2026-04-10 19:00:00+00:00', }), dict({ + 'end': '2026-04-10 21:00:00+00:00', 'price': 0.17191075, + 'start': '2026-04-10 20:00:00+00:00', 'timestamp': '2026-04-10 20:00:00+00:00', }), dict({ + 'end': '2026-04-10 22:00:00+00:00', 'price': 0.1521212, + 'start': '2026-04-10 21:00:00+00:00', 'timestamp': '2026-04-10 21:00:00+00:00', }), dict({ + 'end': '2026-04-10 23:00:00+00:00', 'price': 0.15236925, + 'start': '2026-04-10 22:00:00+00:00', 'timestamp': '2026-04-10 22:00:00+00:00', }), dict({ + 'end': '2026-04-11 00:00:00+00:00', 'price': 0.135583525, + 'start': '2026-04-10 23:00:00+00:00', 'timestamp': '2026-04-10 23:00:00+00:00', }), dict({ + 'end': '2026-04-11 01:00:00+00:00', 'price': 0.12652365, + 'start': '2026-04-11 00:00:00+00:00', 'timestamp': '2026-04-11 00:00:00+00:00', }), dict({ + 'end': '2026-04-11 02:00:00+00:00', 'price': 0.122267475, + 'start': '2026-04-11 01:00:00+00:00', 'timestamp': '2026-04-11 01:00:00+00:00', }), dict({ + 'end': '2026-04-11 03:00:00+00:00', 'price': 0.120640025, + 'start': '2026-04-11 02:00:00+00:00', 'timestamp': '2026-04-11 02:00:00+00:00', }), dict({ + 'end': '2026-04-11 04:00:00+00:00', 'price': 0.117239925, + 'start': '2026-04-11 03:00:00+00:00', 'timestamp': '2026-04-11 03:00:00+00:00', }), dict({ + 'end': '2026-04-11 05:00:00+00:00', 'price': 0.116616775, + 'start': '2026-04-11 04:00:00+00:00', 'timestamp': '2026-04-11 04:00:00+00:00', }), dict({ + 'end': '2026-04-11 06:00:00+00:00', 'price': 0.10214215, + 'start': '2026-04-11 05:00:00+00:00', 'timestamp': '2026-04-11 05:00:00+00:00', }), dict({ + 'end': '2026-04-11 07:00:00+00:00', 'price': 0.077503525, + 'start': '2026-04-11 06:00:00+00:00', 'timestamp': '2026-04-11 06:00:00+00:00', }), ]), @@ -105,7 +153,9 @@ dict({ 'prices': list([ dict({ + 'end': '2026-04-12 04:00:00+00:00', 'price': 0.430304930424, + 'start': '2026-04-11 04:00:00+00:00', 'timestamp': '2026-04-11 04:00:00+00:00', }), ]), diff --git a/tests/components/energyzero/test_services.py b/tests/components/energyzero/test_services.py index 9e094e024eff2e..bf99e96d9d3a59 100644 --- a/tests/components/energyzero/test_services.py +++ b/tests/components/energyzero/test_services.py @@ -1,11 +1,11 @@ """Tests for the services provided by the EnergyZero integration.""" -from datetime import date +from datetime import UTC, date, datetime import re from unittest.mock import AsyncMock from zoneinfo import ZoneInfo -from energyzero import EnergyZeroNoDataError, PriceType +from energyzero import EnergyPrices, EnergyZeroNoDataError, PriceType, TimeRange import pytest from syrupy.assertion import SnapshotAssertion import voluptuous as vol @@ -139,15 +139,32 @@ async def test_service_dates_normalized_to_hass_timezone( { "price": 0.45193447944, "timestamp": "2026-04-10 04:00:00+00:00", + "start": "2026-04-10 04:00:00+00:00", + "end": "2026-04-11 04:00:00+00:00", } ], ), ( ENERGY_SERVICE_NAME, [ - {"price": 0.12572, "timestamp": "2026-04-10 21:00:00+00:00"}, - {"price": 0.125925, "timestamp": "2026-04-10 22:00:00+00:00"}, - {"price": 0.1120525, "timestamp": "2026-04-10 23:00:00+00:00"}, + { + "price": 0.12572, + "timestamp": "2026-04-10 21:00:00+00:00", + "start": "2026-04-10 21:00:00+00:00", + "end": "2026-04-10 22:00:00+00:00", + }, + { + "price": 0.125925, + "timestamp": "2026-04-10 22:00:00+00:00", + "start": "2026-04-10 22:00:00+00:00", + "end": "2026-04-10 23:00:00+00:00", + }, + { + "price": 0.1120525, + "timestamp": "2026-04-10 23:00:00+00:00", + "start": "2026-04-10 23:00:00+00:00", + "end": "2026-04-11 00:00:00+00:00", + }, ], ), ], @@ -177,6 +194,8 @@ async def test_service_filters_datetime_range( ) assert response == {"prices": expected_prices} + assert response["prices"] + assert all(item["timestamp"] == item["start"] for item in response["prices"]) method = ( mock_energyzero.get_gas_prices @@ -193,6 +212,178 @@ async def test_service_filters_datetime_range( ) +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + "equal_time", ["00:00:00", "14:30:00"], ids=["midnight", "non-midnight"] +) +@pytest.mark.parametrize( + ("service", "expected_count", "expected_first", "expected_last"), + [ + ( + GAS_SERVICE_NAME, + 1, + { + "price": 0.430304930424, + "timestamp": "2026-04-11 04:00:00+00:00", + "start": "2026-04-11 04:00:00+00:00", + "end": "2026-04-12 04:00:00+00:00", + }, + { + "price": 0.430304930424, + "timestamp": "2026-04-11 04:00:00+00:00", + "start": "2026-04-11 04:00:00+00:00", + "end": "2026-04-12 04:00:00+00:00", + }, + ), + ( + ENERGY_SERVICE_NAME, + 24, + { + "price": 0.125925, + "timestamp": "2026-04-10 22:00:00+00:00", + "start": "2026-04-10 22:00:00+00:00", + "end": "2026-04-10 23:00:00+00:00", + }, + { + "price": 0.082385, + "timestamp": "2026-04-11 21:00:00+00:00", + "start": "2026-04-11 21:00:00+00:00", + "end": "2026-04-11 22:00:00+00:00", + }, + ), + ], +) +async def test_service_equal_start_end_selects_full_local_day( + hass: HomeAssistant, + mock_energyzero: AsyncMock, + mock_config_entry: MockConfigEntry, + service: str, + equal_time: str, + expected_count: int, + expected_first: dict[str, str | float], + expected_last: dict[str, str | float], +) -> None: + """Equal start/end datetimes select the full local calendar day.""" + await hass.config.async_set_time_zone("Europe/Amsterdam") + mock_energyzero.reset_mock() + equal_datetime = f"2026-04-11 {equal_time}" + + response = await hass.services.async_call( + DOMAIN, + service, + { + ATTR_CONFIG_ENTRY: mock_config_entry.entry_id, + "incl_vat": False, + "start": equal_datetime, + "end": equal_datetime, + }, + blocking=True, + return_response=True, + ) + + assert len(response["prices"]) == expected_count + assert response["prices"][0] == expected_first + assert response["prices"][-1] == expected_last + assert all(item["timestamp"] == item["start"] for item in response["prices"]) + + method = ( + mock_energyzero.get_gas_prices + if service == GAS_SERVICE_NAME + else mock_energyzero.get_electricity_prices + ) + assert len(method.await_args_list) == 1 + assert method.await_args.kwargs["start_date"] == date(2026, 4, 11) + assert method.await_args.kwargs["end_date"] == date(2026, 4, 11) + + +@pytest.mark.usefixtures("init_integration") +async def test_service_equal_start_end_full_day_respects_dst( + hass: HomeAssistant, + mock_energyzero: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Equal start/end selects a local calendar day, not a fixed 24h window.""" + await hass.config.async_set_time_zone("Europe/Amsterdam") + + in_range = TimeRange( + datetime(2026, 3, 28, 23, 0, tzinfo=UTC), + datetime(2026, 3, 29, 22, 0, tzinfo=UTC), + ) + out_of_range = TimeRange( + datetime(2026, 3, 29, 22, 0, tzinfo=UTC), + datetime(2026, 3, 29, 23, 0, tzinfo=UTC), + ) + mock_energyzero.get_electricity_prices.side_effect = None + mock_energyzero.get_electricity_prices.return_value = EnergyPrices( + prices={in_range: 0.1, out_of_range: 0.2}, average_price=None + ) + + response = await hass.services.async_call( + DOMAIN, + ENERGY_SERVICE_NAME, + { + ATTR_CONFIG_ENTRY: mock_config_entry.entry_id, + "incl_vat": False, + "start": "2026-03-29 00:00:00", + "end": "2026-03-29 00:00:00", + }, + blocking=True, + return_response=True, + ) + + assert response == { + "prices": [ + { + "price": 0.1, + "timestamp": "2026-03-28 23:00:00+00:00", + "start": "2026-03-28 23:00:00+00:00", + "end": "2026-03-29 22:00:00+00:00", + } + ] + } + assert mock_energyzero.get_electricity_prices.await_args.kwargs[ + "start_date" + ] == date(2026, 3, 29) + + +@pytest.mark.usefixtures("init_integration") +async def test_service_date_and_equal_datetime_selects_full_local_day( + hass: HomeAssistant, + mock_energyzero: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """A date-only start and a same-day midnight datetime end are equal.""" + await hass.config.async_set_time_zone("Europe/Amsterdam") + mock_energyzero.reset_mock() + + response = await hass.services.async_call( + DOMAIN, + GAS_SERVICE_NAME, + { + ATTR_CONFIG_ENTRY: mock_config_entry.entry_id, + "incl_vat": False, + "start": "2026-04-11", + "end": "2026-04-11 00:00:00", + }, + blocking=True, + return_response=True, + ) + + assert response == { + "prices": [ + { + "price": 0.430304930424, + "timestamp": "2026-04-11 04:00:00+00:00", + "start": "2026-04-11 04:00:00+00:00", + "end": "2026-04-12 04:00:00+00:00", + } + ] + } + assert mock_energyzero.get_gas_prices.await_args.kwargs["start_date"] == date( + 2026, 4, 11 + ) + + @pytest.fixture def config_entry_data( mock_config_entry: MockConfigEntry, request: pytest.FixtureRequest @@ -262,6 +453,17 @@ def config_entry_data( ServiceValidationError, "Invalid date range provided. End 2023-01-01 must be after start 2023-01-02", ), + ( + {"config_entry": True}, + { + "incl_vat": True, + "start": "2026-04-11 12:00:00", + "end": "2026-04-11 08:00:00", + }, + ServiceValidationError, + "Invalid date range provided. End 2026-04-11 08:00:00 must be " + "after start 2026-04-11 12:00:00", + ), ], indirect=["config_entry_data"], ) diff --git a/tests/components/file_upload/test_init.py b/tests/components/file_upload/test_init.py index 6062a25cb35e93..da8a3755ceda55 100644 --- a/tests/components/file_upload/test_init.py +++ b/tests/components/file_upload/test_init.py @@ -284,6 +284,10 @@ async def multipart(self) -> _Reader: with pytest.raises(asyncio.CancelledError): task.result() + # The cancelled upload must not orphan its file directory on disk. + file_upload_data = hass.data[file_upload.DOMAIN] + assert list(file_upload_data.temp_dir.iterdir()) == [] + async def test_receive_file_field_cancelled_while_joining_writer( hass: HomeAssistant, tmp_path: Path @@ -294,7 +298,8 @@ async def test_receive_file_field_cancelled_while_joining_writer( writer (the whole field already streamed, sentinel queued) must not return until the writer thread has finished, so the caller's cleanup cannot race it. """ - file_path = tmp_path / "uploaded.bin" + # Nested under a directory that does not exist yet, so the writer's mkdir runs. + file_path = tmp_path / "upload_dir" / "uploaded.bin" writing_started = asyncio.Event() release_writer = threading.Event() # blocks the writer thread mid-write writes: list[bytes] = [] @@ -347,8 +352,8 @@ async def read_chunk(self, size: int) -> bytes: task.cancel() for _ in range(10): await asyncio.sleep(0) - # Without the cancellation-safe join the task would finish here (returning - # while the writer thread runs on); the fix keeps it waiting for the writer. + # The task must still be waiting for the writer thread to finish before it + # returns, so the caller's cleanup cannot race the writer. assert not task.done() finally: # Always release the writer so a failed assertion can't leak the blocked @@ -361,3 +366,149 @@ async def read_chunk(self, size: int) -> bytes: task.result() # The writer finished writing both chunks before the cancellation propagated. assert b"".join(writes) == b"chunk1chunk2" + # The writer created the parent directory as part of the joined job, so cleanup + # cannot race an in-flight mkdir. + assert file_path.parent.is_dir() + + +async def test_receive_file_field_cancel_wins_over_writer_error( + hass: HomeAssistant, tmp_path: Path +) -> None: + """Test a writer error during the join does not mask the cancellation. + + When the task is cancelled while joining the writer and the writer then fails, + the caller must still observe CancelledError, not the writer's exception. + """ + file_path = tmp_path / "upload_dir" / "uploaded.bin" + writing_started = asyncio.Event() + release_writer = threading.Event() # blocks the writer thread mid-write + + class _FailingHandle: + """A file handle whose first write parks the writer, then fails.""" + + def __enter__(self) -> Self: + return self + + def __exit__(self, *exc: object) -> bool: + return False + + def write(self, data: bytes) -> int: + hass.loop.call_soon_threadsafe(writing_started.set) + release_writer.wait() + raise OSError("write failed") + + real_open = Path.open + + def _failing_open(self: Path, *args: object, **kwargs: object) -> object: + if self != file_path: + return real_open(self, *args, **kwargs) + return _FailingHandle() + + chunks = iter([b"chunk1", b"chunk2"]) + + class _Part: + """Fake BodyPartReader yielding two chunks then EOF.""" + + async def read_chunk(self, size: int) -> bytes: + return next(chunks, b"") + + with patch.object(Path, "open", _failing_open): + task = asyncio.create_task( + file_upload._receive_file_field(hass, _Part(), file_path) + ) + try: + # Let the task settle at the join with the writer blocked mid-write, so + # the cancel lands on the join and the writer fails afterwards. + await writing_started.wait() + for _ in range(3): + await asyncio.sleep(0) + task.cancel() + for _ in range(10): + await asyncio.sleep(0) + assert not task.done() + finally: + # Always release the writer so a failed assertion can't leak the blocked + # thread and hang teardown. + release_writer.set() + _done, pending = await asyncio.wait({task}, timeout=10) + + assert not pending + # The cancellation wins over the writer's OSError. + assert task.cancelled() + with pytest.raises(asyncio.CancelledError): + task.result() + + +async def test_receive_file_field_cancel_in_stream_wins_over_writer_error( + hass: HomeAssistant, tmp_path: Path +) -> None: + """Test a cancel in the streaming loop is not masked by a writer error. + + When the cancellation lands in read_chunk (not the join) and the writer has + already failed, the caller must still observe CancelledError, not the writer's + exception. + """ + file_path = tmp_path / "upload_dir" / "uploaded.bin" + writer_failed = asyncio.Event() # set once the writer thread has raised + reading_blocked = asyncio.Event() # set when the stream parks on its 2nd read + blocked = asyncio.Event() # never set, so the 2nd read blocks until cancelled + + class _FailingHandle: + """A file handle whose first write fails the writer thread.""" + + def __enter__(self) -> Self: + return self + + def __exit__(self, *exc: object) -> bool: + return False + + def write(self, data: bytes) -> int: + hass.loop.call_soon_threadsafe(writer_failed.set) + raise OSError("write failed") + + real_open = Path.open + + def _failing_open(self: Path, *args: object, **kwargs: object) -> object: + if self != file_path: + return real_open(self, *args, **kwargs) + return _FailingHandle() + + reads = 0 + + class _Part: + """Fake BodyPartReader yielding one chunk, then blocking on the next read.""" + + async def read_chunk(self, size: int) -> bytes: + nonlocal reads + reads += 1 + if reads == 1: + return b"chunk1" + reading_blocked.set() + await blocked.wait() + return b"" + + with patch.object(Path, "open", _failing_open): + task = asyncio.create_task( + file_upload._receive_file_field(hass, _Part(), file_path) + ) + try: + # The stream is parked on its second read and the writer has failed, so + # the cancel lands in the streaming loop with the writer future already + # done with an error. + await reading_blocked.wait() + await writer_failed.wait() + for _ in range(5): + await asyncio.sleep(0) + task.cancel() + for _ in range(10): + await asyncio.sleep(0) + finally: + # Always unblock the stream so a failed assertion can't hang teardown. + blocked.set() + _done, pending = await asyncio.wait({task}, timeout=10) + + assert not pending + # The cancellation wins over the writer's OSError. + assert task.cancelled() + with pytest.raises(asyncio.CancelledError): + task.result() diff --git a/tests/components/flexit/snapshots/test_sensor.ambr b/tests/components/flexit/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..8471b428398de5 --- /dev/null +++ b/tests/components/flexit/snapshots/test_sensor.ambr @@ -0,0 +1,279 @@ +# serializer version: 1 +# name: test_sensors[sensor.flexit_air_filter_operating_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.flexit_air_filter_operating_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Air filter operating time', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Air filter operating time', + 'platform': 'flexit', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'air_filter_operating_time', + 'unique_id': 'flexit_001-air_filter_operating_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.flexit_air_filter_operating_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Flexit Air filter operating time', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.flexit_air_filter_operating_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120', + }) +# --- +# name: test_sensors[sensor.flexit_cooling_regulation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.flexit_cooling_regulation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cooling regulation', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cooling regulation', + 'platform': 'flexit', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooling_regulation', + 'unique_id': 'flexit_001-cooling_regulation', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.flexit_cooling_regulation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Flexit Cooling regulation', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.flexit_cooling_regulation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_sensors[sensor.flexit_electric_heater_regulation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.flexit_electric_heater_regulation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Electric heater regulation', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Electric heater regulation', + 'platform': 'flexit', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electric_heater_regulation', + 'unique_id': 'flexit_001-electric_heater_regulation', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.flexit_electric_heater_regulation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Flexit Electric heater regulation', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.flexit_electric_heater_regulation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_sensors[sensor.flexit_heat_exchanger_regulation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.flexit_heat_exchanger_regulation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Heat exchanger regulation', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heat exchanger regulation', + 'platform': 'flexit', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heat_exchanger_regulation', + 'unique_id': 'flexit_001-heat_exchanger_regulation', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.flexit_heat_exchanger_regulation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Flexit Heat exchanger regulation', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.flexit_heat_exchanger_regulation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- +# name: test_sensors[sensor.flexit_outdoor_air_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.flexit_outdoor_air_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Outdoor air temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor air temperature', + 'platform': 'flexit', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'outdoor_air_temperature', + 'unique_id': 'flexit_001-outdoor_air_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.flexit_outdoor_air_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Flexit Outdoor air temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.flexit_outdoor_air_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- diff --git a/tests/components/flexit/test_sensor.py b/tests/components/flexit/test_sensor.py new file mode 100644 index 00000000000000..968dcc51da42e2 --- /dev/null +++ b/tests/components/flexit/test_sensor.py @@ -0,0 +1,43 @@ +"""Test the Flexit sensor platform.""" + +from unittest.mock import patch + +from modbus_connection.mock import MockModbusUnit +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_sensors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """Test Flexit sensor states.""" + mock_modbus_unit.holding.update({8: 215, 17: 2}) + mock_modbus_unit.input.update( + { + 8: 120, + 9: 200, + 11: 50, + 13: 20, + 14: 35, + 15: 40, + 27: 0, + 28: 1, + 48: 10, + } + ) + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.flexit._PLATFORMS", [Platform.SENSOR]): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/hive/test_config_flow.py b/tests/components/hive/test_config_flow.py index f0aa395d43552e..08a2e3efbbc922 100644 --- a/tests/components/hive/test_config_flow.py +++ b/tests/components/hive/test_config_flow.py @@ -1,8 +1,9 @@ """Test the Hive config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from apyhiveapi.helper import hive_exceptions +import pytest from homeassistant import config_entries from homeassistant.components.hive.const import CONF_CODE, CONF_DEVICE_NAME, DOMAIN @@ -25,8 +26,13 @@ MFA_INVALID_CODE = "HIVE" -async def test_user_flow(hass: HomeAssistant) -> None: - """Test the user flow.""" +@pytest.mark.parametrize( + "username", + [USERNAME, USERNAME.upper()], + ids=["lowercase", "uppercase"], +) +async def test_user_flow(hass: HomeAssistant, username: str) -> None: + """Test the user flow normalizes the username for Hive auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -35,31 +41,31 @@ async def test_user_flow(hass: HomeAssistant) -> None: assert result["errors"] == {} with ( + patch("homeassistant.components.hive.config_flow.Auth") as mock_auth, patch( - "homeassistant.components.hive.config_flow.Auth.login", + "homeassistant.components.hive.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + mock_auth.return_value.login = AsyncMock( return_value={ "ChallengeName": "SUCCESS", "AuthenticationResult": { "RefreshToken": "mock-refresh-token", "AccessToken": "mock-access-token", }, - }, - ), - patch( - "homeassistant.components.hive.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): + } + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + {CONF_USERNAME: username, CONF_PASSWORD: PASSWORD}, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == USERNAME + assert result["title"] == username assert result["data"] == { - CONF_USERNAME: USERNAME, + CONF_USERNAME: username, CONF_PASSWORD: PASSWORD, "tokens": { "AuthenticationResult": { @@ -69,9 +75,10 @@ async def test_user_flow(hass: HomeAssistant) -> None: "ChallengeName": "SUCCESS", }, } - + assert mock_auth.call_args.kwargs["username"] == username.lower() assert len(mock_setup_entry.mock_calls) == 1 assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert hass.config_entries.async_entries(DOMAIN)[0].unique_id == username.lower() async def test_user_flow_with_no_2fa(hass: HomeAssistant) -> None: @@ -740,7 +747,7 @@ async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - CONF_USERNAME: USERNAME, + CONF_USERNAME: USERNAME.upper(), CONF_PASSWORD: PASSWORD, }, ) diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index c29b80a252f0f9..c86dd141240774 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -228,7 +228,8 @@ async def test_windowcovering_set_cover_position( ) await hass.async_block_till_done() assert acc.char_current_position.value == 60 - assert acc.char_target_position.value == 0 + # Aimed at the end of travel, not left behind at the starting position. + assert acc.char_target_position.value == 100 assert acc.char_position_state.value == 1 hass.states.async_set( @@ -241,7 +242,7 @@ async def test_windowcovering_set_cover_position( ) await hass.async_block_till_done() assert acc.char_current_position.value == 70 - assert acc.char_target_position.value == 0 + assert acc.char_target_position.value == 100 assert acc.char_position_state.value == 1 hass.states.async_set( @@ -296,6 +297,109 @@ async def test_windowcovering_set_cover_position( assert events[-1].data[ATTR_VALUE] == 75 +async def test_windowcovering_target_position_while_moving( + hass: HomeAssistant, hk_driver +) -> None: + """Test the target position while the cover is moving. + + The Home app derives the direction of travel from the target position + relative to the current one, so a target left behind the cover makes it + report the opposite of what the cover is doing. + """ + entity_id = "cover.window" + features = CoverEntityFeature.SET_POSITION + + hass.states.async_set( + entity_id, CoverState.CLOSED, {ATTR_SUPPORTED_FEATURES: features} + ) + await hass.async_block_till_done() + acc = WindowCovering(hass, hk_driver, "Cover", entity_id, 2, None) + acc.run() + await hass.async_block_till_done() + + # Opened from outside HomeKit: the target still points at the start. + hass.states.async_set( + entity_id, + CoverState.OPENING, + {ATTR_SUPPORTED_FEATURES: features, ATTR_CURRENT_POSITION: 20}, + ) + await hass.async_block_till_done() + assert acc.char_target_position.value == 100 + + # Coming to rest hands the target back to the real position. + hass.states.async_set( + entity_id, + CoverState.OPEN, + {ATTR_SUPPORTED_FEATURES: features, ATTR_CURRENT_POSITION: 40}, + ) + await hass.async_block_till_done() + assert acc.char_target_position.value == 40 + + # Closed from outside HomeKit. + hass.states.async_set( + entity_id, + CoverState.CLOSING, + {ATTR_SUPPORTED_FEATURES: features, ATTR_CURRENT_POSITION: 30}, + ) + await hass.async_block_till_done() + assert acc.char_target_position.value == 0 + + # A partial move asked for from HomeKit must survive. + async_mock_service(hass, COVER_DOMAIN, "set_cover_position") + hass.states.async_set( + entity_id, + CoverState.CLOSED, + {ATTR_SUPPORTED_FEATURES: features, ATTR_CURRENT_POSITION: 0}, + ) + await hass.async_block_till_done() + acc.char_target_position.client_update_value(60) + await hass.async_block_till_done() + assert acc.char_target_position.value == 60 + + hass.states.async_set( + entity_id, + CoverState.OPENING, + {ATTR_SUPPORTED_FEATURES: features, ATTR_CURRENT_POSITION: 30}, + ) + await hass.async_block_till_done() + assert acc.char_target_position.value == 60 + + # Still reported as opening on arrival at the requested position: the + # target must not jump to the end of travel in that last moment. + hass.states.async_set( + entity_id, + CoverState.OPENING, + {ATTR_SUPPORTED_FEATURES: features, ATTR_CURRENT_POSITION: 60}, + ) + await hass.async_block_till_done() + assert acc.char_target_position.value == 60 + + +async def test_windowcovering_tilt_only_target_position_stays_closed( + hass: HomeAssistant, hk_driver +) -> None: + """Test that a tilt-only cover keeps its target position locked at closed.""" + entity_id = "cover.window" + features = CoverEntityFeature.OPEN_TILT | CoverEntityFeature.SET_TILT_POSITION + + hass.states.async_set( + entity_id, CoverState.CLOSED, {ATTR_SUPPORTED_FEATURES: features} + ) + await hass.async_block_till_done() + acc = WindowCovering(hass, hk_driver, "Cover", entity_id, 2, None) + acc.run() + await hass.async_block_till_done() + + hass.states.async_set( + entity_id, + CoverState.OPENING, + {ATTR_SUPPORTED_FEATURES: features, ATTR_CURRENT_POSITION: 20}, + ) + await hass.async_block_till_done() + # Capped at 0 for tilt-only covers, so 100 would be out of range. + assert acc.char_target_position.value == 0 + + async def test_window_instantiate_set_position(hass: HomeAssistant, hk_driver) -> None: """Test if Window accessory is instantiated correctly and can set position.""" entity_id = "cover.window" diff --git a/tests/components/hue/test_device_trigger_v2.py b/tests/components/hue/test_device_trigger_v2.py index 834af956a20f8f..04dbdca6160985 100644 --- a/tests/components/hue/test_device_trigger_v2.py +++ b/tests/components/hue/test_device_trigger_v2.py @@ -4,6 +4,7 @@ from unittest.mock import Mock, patch from aiohue.v2.models.button import ButtonEvent +import attr import pytest from pytest_unordered import unordered @@ -159,6 +160,79 @@ async def test_get_triggers( assert triggers == unordered(expected_triggers) +async def test_get_triggers_for_composite_device_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, + device_registry: dr.DeviceRegistry, +) -> None: + """Test we get the expected triggers for a pre-migration composite device id.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform( + hass, mock_bridge_v2, [Platform.BINARY_SENSOR, Platform.SENSOR] + ) + hue_wall_switch_device = device_registry.async_get_device_by_identifier( + (hue.DOMAIN, WALL_SWITCH_DEVICE_ID), mock_bridge_v2.config_entry.entry_id + ) + other_entry = MockConfigEntry(domain="other") + other_entry.add_to_hass(hass) + other_device = device_registry.async_get_or_create( + config_entry_id=other_entry.entry_id, identifiers={("other", "1")} + ) + composite_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry._devices[hue_wall_switch_device.id] = attr.evolve( + hue_wall_switch_device, composite_device_id=composite_id + ) + device_registry._devices[other_device.id] = attr.evolve( + other_device, composite_device_id=composite_id + ) + + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, composite_id + ) + + hue_bat_sensor = entity_registry.async_get( + "sensor.wall_switch_with_2_controls_battery" + ) + trigger_batt = { + "platform": "device", + "domain": "sensor", + "device_id": composite_id, + "type": "battery_level", + "entity_id": hue_bat_sensor.id, + "metadata": {"secondary": True}, + } + expected_triggers = [ + trigger_batt, + *( + { + "platform": "device", + "domain": hue.DOMAIN, + "device_id": composite_id, + "unique_id": resource_id, + "type": event_type.value, + "subtype": control_id, + "metadata": {}, + } + for event_type in ( + ButtonEvent.INITIAL_PRESS, + ButtonEvent.LONG_RELEASE, + ButtonEvent.REPEAT, + ButtonEvent.LONG_PRESS, + ButtonEvent.SHORT_RELEASE, + ) + for control_id, resource_id in ( + (1, "c658d3d8-a013-4b81-8ac6-78b248537e70"), + (2, "be1eb834-bdf5-4d26-8fba-7b1feaa83a9d"), + ) + ), + ] + + assert triggers == unordered(expected_triggers) + + async def test_get_triggers_for_removed_device( hass: HomeAssistant, mock_bridge_v2: Mock, diff --git a/tests/components/kitchen_sink/snapshots/test_lawn_mower.ambr b/tests/components/kitchen_sink/snapshots/test_lawn_mower.ambr index 4b3165d606aad1..1a4f735598637a 100644 --- a/tests/components/kitchen_sink/snapshots/test_lawn_mower.ambr +++ b/tests/components/kitchen_sink/snapshots/test_lawn_mower.ambr @@ -4,7 +4,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Mower can do all', - : , + : , }), 'context': , 'entity_id': 'lawn_mower.mower_can_do_all', @@ -61,6 +61,18 @@ 'last_updated': , 'state': 'returning', }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mower can stop', + : , + }), + 'context': , + 'entity_id': 'lawn_mower.mower_can_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'mowing', + }), StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Mower is paused', diff --git a/tests/components/kitchen_sink/test_lawn_mower.py b/tests/components/kitchen_sink/test_lawn_mower.py index 5bd4fc834f8e17..f98621f3ff1b32 100644 --- a/tests/components/kitchen_sink/test_lawn_mower.py +++ b/tests/components/kitchen_sink/test_lawn_mower.py @@ -11,6 +11,7 @@ SERVICE_DOCK, SERVICE_PAUSE, SERVICE_START_MOWING, + SERVICE_STOP, LawnMowerActivity, ) from homeassistant.const import ATTR_ENTITY_ID, EVENT_STATE_CHANGED, Platform @@ -78,6 +79,12 @@ async def test_states(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: LawnMowerActivity.RETURNING, LawnMowerActivity.DOCKED, ), + ( + "lawn_mower.mower_can_stop", + SERVICE_STOP, + LawnMowerActivity.MOWING, + LawnMowerActivity.IDLE, + ), ], ) async def test_mower( @@ -109,6 +116,7 @@ async def test_mower( SERVICE_DOCK, SERVICE_START_MOWING, SERVICE_PAUSE, + SERVICE_STOP, ], ) async def test_service_calls_mocked(hass: HomeAssistant, service_call) -> None: diff --git a/tests/components/lawn_mower/test_condition.py b/tests/components/lawn_mower/test_condition.py index 3c26c39181e2b8..28c0e21ac3226c 100644 --- a/tests/components/lawn_mower/test_condition.py +++ b/tests/components/lawn_mower/test_condition.py @@ -32,6 +32,7 @@ async def target_lawn_mowers(hass: HomeAssistant) -> dict[str, list[str]]: _CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { "is_docked": TargetSupport.STANDARD, "is_encountering_an_error": TargetSupport.STANDARD, + "is_idle": TargetSupport.STANDARD, "is_mowing": TargetSupport.STANDARD, "is_paused": TargetSupport.STANDARD, "is_returning": TargetSupport.STANDARD, @@ -43,6 +44,7 @@ async def target_lawn_mowers(hass: HomeAssistant) -> dict[str, list[str]]: [ ("lawn_mower.is_docked", {}, True, True), ("lawn_mower.is_encountering_an_error", {}, True, True), + ("lawn_mower.is_idle", {}, True, True), ("lawn_mower.is_mowing", {}, True, True), ("lawn_mower.is_paused", {}, True, True), ("lawn_mower.is_returning", {}, True, True), @@ -87,6 +89,11 @@ def test_condition_target_support() -> None: target_states=[LawnMowerActivity.ERROR], other_states=other_states(LawnMowerActivity.ERROR), ), + *parametrize_condition_states_any( + condition="lawn_mower.is_idle", + target_states=[LawnMowerActivity.IDLE], + other_states=other_states(LawnMowerActivity.IDLE), + ), *parametrize_condition_states_any( condition="lawn_mower.is_mowing", target_states=[LawnMowerActivity.MOWING], @@ -144,6 +151,11 @@ async def test_lawn_mower_state_condition_behavior_any( target_states=[LawnMowerActivity.ERROR], other_states=other_states(LawnMowerActivity.ERROR), ), + *parametrize_condition_states_all( + condition="lawn_mower.is_idle", + target_states=[LawnMowerActivity.IDLE], + other_states=other_states(LawnMowerActivity.IDLE), + ), *parametrize_condition_states_all( condition="lawn_mower.is_mowing", target_states=[LawnMowerActivity.MOWING], diff --git a/tests/components/lawn_mower/test_init.py b/tests/components/lawn_mower/test_init.py index bf501cc114769b..2a78408e886fe5 100644 --- a/tests/components/lawn_mower/test_init.py +++ b/tests/components/lawn_mower/test_init.py @@ -160,6 +160,17 @@ async def test_sync_pause(hass: HomeAssistant) -> None: assert lawn_mower.pause.called +async def test_sync_stop(hass: HomeAssistant) -> None: + """Test if async stop calls sync stop.""" + lawn_mower = MockLawnMowerEntity() + lawn_mower.hass = hass + + lawn_mower.stop = MagicMock() + await lawn_mower.async_stop() + + assert lawn_mower.stop.called + + async def test_lawn_mower_default(hass: HomeAssistant) -> None: """Test lawn mower entity with defaults.""" lawn_mower = MockLawnMowerEntity() diff --git a/tests/components/lawn_mower/test_trigger.py b/tests/components/lawn_mower/test_trigger.py index 5f8b740b660a9b..1ca4f9d517859e 100644 --- a/tests/components/lawn_mower/test_trigger.py +++ b/tests/components/lawn_mower/test_trigger.py @@ -35,6 +35,7 @@ async def target_lawn_mowers(hass: HomeAssistant) -> dict[str, list[str]]: "paused_mowing": TargetSupport.STANDARD, "started_mowing": TargetSupport.STANDARD, "started_returning": TargetSupport.STANDARD, + "became_idle": TargetSupport.STANDARD, } @@ -46,6 +47,7 @@ async def target_lawn_mowers(hass: HomeAssistant) -> dict[str, list[str]]: ("lawn_mower.paused_mowing", {}, True, True), ("lawn_mower.started_mowing", {}, True, True), ("lawn_mower.started_returning", {}, True, True), + ("lawn_mower.became_idle", {}, True, True), ], ) async def test_lawn_mower_trigger_options_validation( @@ -102,6 +104,11 @@ def test_trigger_target_support() -> None: target_states=[LawnMowerActivity.RETURNING], other_states=other_states(LawnMowerActivity.RETURNING), ), + *parametrize_trigger_states( + trigger="lawn_mower.became_idle", + target_states=[LawnMowerActivity.IDLE], + other_states=other_states(LawnMowerActivity.IDLE), + ), ], ) async def test_lawn_mower_state_trigger_behavior_each( @@ -159,6 +166,11 @@ async def test_lawn_mower_state_trigger_behavior_each( target_states=[LawnMowerActivity.RETURNING], other_states=other_states(LawnMowerActivity.RETURNING), ), + *parametrize_trigger_states( + trigger="lawn_mower.became_idle", + target_states=[LawnMowerActivity.IDLE], + other_states=other_states(LawnMowerActivity.IDLE), + ), ], ) async def test_lawn_mower_state_trigger_behavior_first( @@ -216,6 +228,11 @@ async def test_lawn_mower_state_trigger_behavior_first( target_states=[LawnMowerActivity.RETURNING], other_states=other_states(LawnMowerActivity.RETURNING), ), + *parametrize_trigger_states( + trigger="lawn_mower.became_idle", + target_states=[LawnMowerActivity.IDLE], + other_states=other_states(LawnMowerActivity.IDLE), + ), ], ) async def test_lawn_mower_state_trigger_behavior_all( diff --git a/tests/components/lg_netcast/test_device_trigger.py b/tests/components/lg_netcast/test_device_trigger.py index e9fe4b97e793f2..6ad1eb1f652e1d 100644 --- a/tests/components/lg_netcast/test_device_trigger.py +++ b/tests/components/lg_netcast/test_device_trigger.py @@ -161,4 +161,25 @@ async def test_failure_scenarios( with pytest.raises(InvalidDeviceAutomationConfig): await device_trigger.async_validate_trigger_config(hass, config) + not_loaded_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id="not-loaded-unique-id" + ) + not_loaded_entry.add_to_hass(hass) + + not_loaded_device = device_registry.async_get_or_create( + config_entry_id=not_loaded_entry.entry_id, + identifiers={(DOMAIN, "not-loaded-unique-id")}, + ) + + not_loaded_config = { + "platform": "device", + "domain": DOMAIN, + "device_id": not_loaded_device.id, + "type": "lg_netcast.turn_on", + } + + # Test that a device from a not-loaded lg_netcast config entry raises exception + with pytest.raises(InvalidDeviceAutomationConfig, match="is not from an existing"): + await device_trigger.async_validate_trigger_config(hass, not_loaded_config) + # Test that only valid triggers are attached diff --git a/tests/components/libre_hardware_monitor/conftest.py b/tests/components/libre_hardware_monitor/conftest.py index 882595e525b269..e168123f5a206d 100644 --- a/tests/components/libre_hardware_monitor/conftest.py +++ b/tests/components/libre_hardware_monitor/conftest.py @@ -1,6 +1,7 @@ """Common fixtures for the LibreHardwareMonitor tests.""" from collections.abc import Generator +from dataclasses import replace from unittest.mock import AsyncMock, patch from librehardwaremonitor_api.parser import LibreHardwareMonitorParser @@ -84,3 +85,12 @@ def mock_lhm_client() -> Generator[AsyncMock]: client.get_data.return_value = test_data yield client + + +@pytest.fixture +def mock_deprecated_lhm_client(mock_lhm_client: AsyncMock) -> AsyncMock: + """Mock a LibreHardwareMonitor client reporting a deprecated version.""" + mock_lhm_client.get_data.return_value = replace( + mock_lhm_client.get_data.return_value, is_deprecated_version=True + ) + return mock_lhm_client diff --git a/tests/components/libre_hardware_monitor/test_config_flow.py b/tests/components/libre_hardware_monitor/test_config_flow.py index fd575deb2d6ae7..11e357eb4d0f2a 100644 --- a/tests/components/libre_hardware_monitor/test_config_flow.py +++ b/tests/components/libre_hardware_monitor/test_config_flow.py @@ -1,5 +1,6 @@ """Test the LibreHardwareMonitor config flow.""" +from dataclasses import replace from unittest.mock import AsyncMock from librehardwaremonitor_api import ( @@ -266,3 +267,58 @@ async def test_reauth_errors( assert result["reason"] == "reauth_successful" assert mock_config_entry.data == {**VALID_CONFIG, **REAUTH_INPUT} assert len(hass.config_entries.async_entries()) == 1 + + +async def test_deprecated_version_is_rejected_and_flow_recovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_lhm_client: AsyncMock, +) -> None: + """Test that a deprecated LHM version cannot be configured.""" + mock_lhm_client.get_data.return_value = replace( + mock_lhm_client.get_data.return_value, is_deprecated_version=True + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=VALID_CONFIG + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "deprecated_version"} + assert mock_setup_entry.call_count == 0 + + mock_lhm_client.get_data.return_value = replace( + mock_lhm_client.get_data.return_value, is_deprecated_version=False + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=VALID_CONFIG + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_setup_entry.call_count == 1 + + +@pytest.mark.usefixtures("mock_deprecated_lhm_client") +async def test_reauth_deprecated_version_is_rejected( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that reauth does not complete for a deprecated LHM version.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], REAUTH_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "deprecated_version"} + assert mock_config_entry.data == VALID_CONFIG diff --git a/tests/components/libre_hardware_monitor/test_init.py b/tests/components/libre_hardware_monitor/test_init.py index 3a83d6098c0bd4..385bb109acdc52 100644 --- a/tests/components/libre_hardware_monitor/test_init.py +++ b/tests/components/libre_hardware_monitor/test_init.py @@ -1,15 +1,24 @@ """Tests for the LibreHardwareMonitor init.""" +from dataclasses import replace +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.libre_hardware_monitor.const import DOMAIN +from homeassistant.components.libre_hardware_monitor.const import ( + DEFAULT_SCAN_INTERVAL, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from . import init_integration from .conftest import VALID_CONFIG -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.usefixtures("mock_lhm_client") @@ -93,3 +102,39 @@ async def test_migration_to_unique_ids( legacy_config_entry_v1.entry_id ) assert updated_config_entry.version == 2 + + +@pytest.mark.usefixtures("mock_deprecated_lhm_client") +async def test_deprecated_version_blocks_setup( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that a deprecated LHM version prevents setup with an error.""" + await init_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert mock_config_entry.error_reason_translation_domain == DOMAIN + assert mock_config_entry.error_reason_translation_key == "deprecated_version" + + +async def test_downgrade_to_deprecated_version_fails_entry( + hass: HomeAssistant, + mock_lhm_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that downgrading to a deprecated LHM version while running fails the entry.""" + await init_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_lhm_client.get_data.return_value = replace( + mock_lhm_client.get_data.return_value, is_deprecated_version=True + ) + + freezer.tick(timedelta(seconds=DEFAULT_SCAN_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert mock_config_entry.error_reason_translation_domain == DOMAIN + assert mock_config_entry.error_reason_translation_key == "deprecated_version" diff --git a/tests/components/libre_hardware_monitor/test_sensor.py b/tests/components/libre_hardware_monitor/test_sensor.py index 17b43d461212bb..11ba0abe669cc8 100644 --- a/tests/components/libre_hardware_monitor/test_sensor.py +++ b/tests/components/libre_hardware_monitor/test_sensor.py @@ -27,11 +27,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, - issue_registry as ir, -) +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry from . import init_integration @@ -346,53 +342,3 @@ async def test_integration_dynamically_adds_new_devices( assert "sensor.gaming_pc_generic_memory_test_sensor" in [ entry.entity_id for entry in entity_entries ] - - -async def test_non_deprecated_version_does_not_raise_issue( - hass: HomeAssistant, - mock_lhm_client: AsyncMock, - mock_config_entry: MockConfigEntry, - issue_registry: ir.IssueRegistry, -) -> None: - """Test non-deprecated LHM version does not raise an issue.""" - await init_integration(hass, mock_config_entry) - - assert ( - DOMAIN, - f"deprecated_api_{mock_config_entry.entry_id}", - ) not in issue_registry.issues - - -async def test_deprecated_version_raises_issue_and_is_removed_after_update( - hass: HomeAssistant, - mock_lhm_client: AsyncMock, - mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, - issue_registry: ir.IssueRegistry, -) -> None: - """Test deprecated LHM version raises issue removed after update.""" - mock_lhm_client.get_data.return_value = replace( - mock_lhm_client.get_data.return_value, - is_deprecated_version=True, - ) - - await init_integration(hass, mock_config_entry) - - assert ( - DOMAIN, - f"deprecated_api_{mock_config_entry.entry_id}", - ) in issue_registry.issues - - mock_lhm_client.get_data.return_value = replace( - mock_lhm_client.get_data.return_value, - is_deprecated_version=False, - ) - - freezer.tick(timedelta(DEFAULT_SCAN_INTERVAL)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - assert ( - DOMAIN, - f"deprecated_api_{mock_config_entry.entry_id}", - ) not in issue_registry.issues diff --git a/tests/components/modbus/test_services.py b/tests/components/modbus/test_services.py index 52a96de3e78eb3..14ea2ce635fb2f 100644 --- a/tests/components/modbus/test_services.py +++ b/tests/components/modbus/test_services.py @@ -28,6 +28,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.setup import async_setup_component from tests.common import get_fixture_path @@ -162,3 +163,50 @@ async def test_service_after_failed_reload_raises( await hass.services.async_call(DOMAIN, service, data, blocking=True) assert err.value.translation_key == "not_loaded" + + +TEST_HUB_A = "hub_a" +TEST_HUB_B = "hub_b" + + +def _two_hub_config() -> dict: + """Return a config with two hubs, each exposing one sensor.""" + return { + DOMAIN: [ + { + CONF_NAME: name, + CONF_TYPE: "tcp", + CONF_HOST: "modbusHost", + CONF_PORT: port, + CONF_SENSORS: [{CONF_NAME: f"sensor_{name}", CONF_ADDRESS: 1}], + } + for name, port in ((TEST_HUB_A, 5501), (TEST_HUB_B, 5502)) + ] + } + + +@pytest.mark.usefixtures("mock_pymodbus") +async def test_stop_only_disables_the_selected_hub(hass: HomeAssistant) -> None: + """Test stopping one hub leaves the other hub's entities alone.""" + assert await async_setup_component(hass, DOMAIN, _two_hub_config()) + await hass.async_block_till_done() + + # async_disable only flips availability, it does not write the state, so the + # entity objects rather than the state machine show the effect. + entities = { + entity.entity_id: entity + for platform in async_get_platforms(hass, DOMAIN) + for entity in platform.entities.values() + } + entity_a = entities[f"sensor.sensor_{TEST_HUB_A}"] + entity_b = entities[f"sensor.sensor_{TEST_HUB_B}"] + assert entity_a.available + assert entity_b.available + + await hass.services.async_call( + DOMAIN, SERVICE_STOP, {ATTR_HUB: TEST_HUB_A}, blocking=True + ) + await hass.async_block_till_done() + + assert not entity_a.available + assert entity_b.available diff --git a/tests/components/mqtt/test_lawn_mower.py b/tests/components/mqtt/test_lawn_mower.py index 534ade49f51f39..ebe9b15d3f2917 100644 --- a/tests/components/mqtt/test_lawn_mower.py +++ b/tests/components/mqtt/test_lawn_mower.py @@ -13,6 +13,7 @@ SERVICE_DOCK, SERVICE_PAUSE, SERVICE_START_MOWING, + SERVICE_STOP, LawnMowerEntityFeature, ) from homeassistant.components.mqtt.const import DOMAIN @@ -61,6 +62,7 @@ LawnMowerEntityFeature.START_MOWING | LawnMowerEntityFeature.PAUSE | LawnMowerEntityFeature.DOCK + | LawnMowerEntityFeature.STOP ) DEFAULT_CONFIG = { @@ -70,6 +72,7 @@ "dock_command_topic": "dock-test-topic", "pause_command_topic": "pause-test-topic", "start_mowing_command_topic": "start_mowing-test-topic", + "stop_command_topic": "stop-test-topic", } } } @@ -111,6 +114,13 @@ async def test_run_lawn_mower_setup_and_state_updates( state = hass.states.get("lawn_mower.test_lawn_mower") assert state.state == "returning" + async_fire_mqtt_message(hass, "test/lawn_mower_stat", "idle") + + await hass.async_block_till_done() + + state = hass.states.get("lawn_mower.test_lawn_mower") + assert state.state == "idle" + async_fire_mqtt_message(hass, "test/lawn_mower_stat", "docked") await hass.async_block_till_done() @@ -157,6 +167,17 @@ async def test_run_lawn_mower_setup_and_state_updates( }, LawnMowerEntityFeature.START_MOWING | LawnMowerEntityFeature.DOCK, ), + ( + { + DOMAIN: { + lawn_mower.DOMAIN: { + "stop_command_topic": "stop-test-topic", + "name": "test", + } + } + }, + LawnMowerEntityFeature.STOP, + ), ], ) async def test_supported_features( @@ -285,6 +306,20 @@ async def test_run_lawn_mower_service_optimistic( state = hass.states.get("lawn_mower.test") assert state.state == "docked" + await hass.services.async_call( + lawn_mower.DOMAIN, + SERVICE_STOP, + {ATTR_ENTITY_ID: "lawn_mower.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "stop-test-topic", "stop", 0, False, message_expiry_interval=None + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("lawn_mower.test") + assert state.state == "idle" + @pytest.mark.parametrize( "hass_config", @@ -325,6 +360,8 @@ async def test_restore_lawn_mower_from_invalid_state( "pause_command_template": '{"action": "{{ value }}"}', "start_mowing_command_topic": "test/lawn_mower_start_mowing_cmd", "start_mowing_command_template": '{"action": "{{ value }}"}', + "stop_command_topic": "test/lawn_mower_stop_cmd", + "stop_command_template": '{"action": "{{ value }}"}', } } } @@ -397,6 +434,24 @@ async def test_run_lawn_mower_service_optimistic_with_command_templates( state = hass.states.get("lawn_mower.test_lawn_mower") assert state.state == "docked" + await hass.services.async_call( + lawn_mower.DOMAIN, + SERVICE_STOP, + {ATTR_ENTITY_ID: "lawn_mower.test_lawn_mower"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "test/lawn_mower_stop_cmd", + '{"action": "stop"}', + 0, + False, + message_expiry_interval=None, + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("lawn_mower.test_lawn_mower") + assert state.state == "idle" + @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( @@ -675,6 +730,13 @@ async def test_entity_id_update_discovery_update( "test/lawn_mower_stat", "dock-test-topic", ), + ( + SERVICE_STOP, + "stop", + "idle", + "test/lawn_mower_stat", + "stop-test-topic", + ), ], ) async def test_entity_debug_info_message( @@ -694,6 +756,7 @@ async def test_entity_debug_info_message( "dock_command_topic": "dock-test-topic", "pause_command_topic": "pause-test-topic", "start_mowing_command_topic": "start_mowing-test-topic", + "stop_command_topic": "stop-test-topic", "name": "test", } } @@ -741,8 +804,8 @@ async def test_mqtt_payload_not_a_valid_activity_warning( assert ( "Invalid activity for lawn_mower.test_lawn_mower: 'painting' " - "(valid activities: ['error', 'paused', 'mowing', 'docked', 'returning'])" - in caplog.text + "(valid activities: ['error', 'paused', 'mowing', 'docked', 'returning', " + "'idle'])" in caplog.text ) @@ -770,6 +833,13 @@ async def test_mqtt_payload_not_a_valid_activity_warning( "dock", "dock_command_template", ), + ( + SERVICE_STOP, + "stop_command_topic", + {}, + "stop", + "stop_command_template", + ), ], ) async def test_publishing_with_custom_encoding( @@ -816,6 +886,7 @@ async def test_reloadable( ("activity_state_topic", "docked", None, "docked"), ("activity_state_topic", "returning", None, "returning"), ("activity_state_topic", "mowing", None, "mowing"), + ("activity_state_topic", "idle", None, "idle"), ], ) async def test_encoding_subscribable_topics( diff --git a/tests/components/nest/test_climate.py b/tests/components/nest/test_climate.py index 09afe6ee814b1d..d9d411dd6ae910 100644 --- a/tests/components/nest/test_climate.py +++ b/tests/components/nest/test_climate.py @@ -1155,16 +1155,25 @@ async def test_set_fan_timer_hvac_off( ) await setup_platform() - with pytest.raises(HomeAssistantError, match="Cannot turn on fan"): - await hass.services.async_call( - DOMAIN, - "set_fan_timer", - { - "entity_id": "climate.my_thermostat", - "duration": {"minutes": 15}, - }, - blocking=True, - ) + await hass.services.async_call( + DOMAIN, + "set_fan_timer", + { + "entity_id": "climate.my_thermostat", + "duration": {"minutes": 15}, + }, + blocking=True, + ) + + assert auth.method == "post" + assert auth.url == DEVICE_COMMAND + assert auth.json == { + "command": "sdm.devices.commands.Fan.SetTimer", + "params": { + "duration": "900s", + "timerMode": "ON", + }, + } async def test_set_fan_timer_no_fan( @@ -1313,9 +1322,30 @@ async def test_thermostat_set_fan_when_off( | ClimateEntityFeature.TURN_ON ) - # Fan cannot be turned on when HVAC is off - with pytest.raises(ValueError): - await common.async_set_fan_mode(hass, FAN_ON, entity_id="climate.my_thermostat") + # Turn off fan mode + await common.async_set_fan_mode(hass, FAN_OFF, entity_id="climate.my_thermostat") + await hass.async_block_till_done() + + assert auth.method == "post" + assert auth.url == DEVICE_COMMAND + assert auth.json == { + "command": "sdm.devices.commands.Fan.SetTimer", + "params": {"timerMode": "OFF"}, + } + + # Turn on fan mode + await common.async_set_fan_mode(hass, FAN_ON, entity_id="climate.my_thermostat") + await hass.async_block_till_done() + + assert auth.method == "post" + assert auth.url == DEVICE_COMMAND + assert auth.json == { + "command": "sdm.devices.commands.Fan.SetTimer", + "params": { + "duration": "43200s", + "timerMode": "ON", + }, + } async def test_thermostat_fan_empty( diff --git a/tests/components/rejseplanen/__init__.py b/tests/components/rejseplanen/__init__.py new file mode 100644 index 00000000000000..268b3e3eee93af --- /dev/null +++ b/tests/components/rejseplanen/__init__.py @@ -0,0 +1 @@ +"""Tests for the Rejseplanen component.""" diff --git a/tests/components/rejseplanen/conftest.py b/tests/components/rejseplanen/conftest.py new file mode 100644 index 00000000000000..2c6df3e842aefd --- /dev/null +++ b/tests/components/rejseplanen/conftest.py @@ -0,0 +1,255 @@ +"""Test configuration for Rejseplanen component.""" + +from collections.abc import AsyncGenerator, Generator +from datetime import datetime, timedelta +from typing import Any +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from py_rejseplan.api.departures import DeparturesAPIClient +from py_rejseplan.dataclasses.departure import Departure +from py_rejseplan.dataclasses.product_type import ProductType +from py_rejseplan.enums import TransportClass +import pytest + +from homeassistant.components.rejseplanen.const import ( + CONF_DEPARTURE_TYPE, + CONF_DIRECTION, + CONF_STOP_ID, + DOMAIN, +) +from homeassistant.config_entries import ConfigSubentryDataWithId +from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +def make_mock_departures(stop_id: int) -> list[Departure]: + """Create mock departures for a specific stop.""" + # Use a fixed base time for deterministic test data + base_time = datetime(2024, 1, 1, 12, 0, 0) + departures = [] + if stop_id == 123456: + # Example: 2 departures for "Work" + for i, (name, line) in enumerate([("Bus 207", "207"), ("Bus 216", "216")]): + mock_departure = MagicMock(spec=Departure) + mock_departure.name = name + mock_departure.line = line + mock_departure.direction = "End Point St." + mock_departure.stop = "Test Stop" + mock_departure.time = ( + (base_time + timedelta(minutes=5 + i)) + .time() + .replace(second=0, microsecond=0) + ) + mock_departure.date = base_time.date() + mock_departure.track = f"{i + 1}A" + mock_departure.rtTrack = f"{i + 1}A" + mock_departure.final_stop = "End Station" + mock_departure.messages = ["On time"] + mock_departure.rtTime = ( + (base_time + timedelta(minutes=7 + i)) + .time() + .replace(second=0, microsecond=0) + ) + mock_departure.rtDate = base_time.date() + mock_departure.stopExtId = 123456 + + mock_product = MagicMock(spec=ProductType) + mock_product.cls_id = TransportClass.BUS.value + mock_product.line = name + mock_product.operator = "Test Operator" + mock_product.name = name + mock_departure.product = mock_product + + departures.append(mock_departure) + return departures + if stop_id == 456789: + # Past departure + + past_product = MagicMock(spec=ProductType) + past_product.cls_id = TransportClass.BUS.value + + past_dep = MagicMock(spec=Departure) + past_dep.name = "A" + past_dep.type = TransportClass.BUS + past_dep.product = past_product + past_dep.direction = "South" + past_dep.stop = "Gym Stop" + past_dep.time = ( + (base_time - timedelta(minutes=10)).time().replace(second=0, microsecond=0) + ) + past_dep.date = base_time.date() + past_dep.track = "2B" + past_dep.final_stop = "North Station" + past_dep.messages = ["Delayed"] + past_dep.rtTime = ( + (base_time - timedelta(minutes=8)).time().replace(second=0, microsecond=0) + ) + past_dep.rtDate = base_time.date() + past_dep.rtTrack = "2B" + past_dep.stopExtId = 456789 + departures.append(past_dep) + + # Buffer departure (just inside buffer) + buffer_dep = MagicMock(spec=Departure) + buffer_dep.name = "A" + buffer_dep.type = TransportClass.TOG + + buffer_product = MagicMock(spec=ProductType) + buffer_product.cls_id = TransportClass.TOG.value + buffer_dep.product = buffer_product + + buffer_dep.direction = "North" + buffer_dep.stop = "Gym Stop" + buffer_dep.time = ( + (base_time - timedelta(minutes=1)).time().replace(second=0, microsecond=0) + ) + buffer_dep.date = base_time.date() + buffer_dep.track = "2B" + buffer_dep.rtTrack = "2B" + buffer_dep.final_stop = "North Station" + buffer_dep.messages = ["Delayed"] + buffer_dep.rtTime = ( + (base_time - timedelta(seconds=30)).time().replace(second=0, microsecond=0) + ) + buffer_dep.rtDate = base_time.date() + buffer_dep.stopExtId = 456789 + departures.append(buffer_dep) + + # Future departure + future_dep = MagicMock(spec=Departure) + future_dep.name = "A" + future_dep.type = TransportClass.ICL + + future_product = MagicMock(spec=ProductType) + future_product.cls_id = TransportClass.ICL.value + future_dep.product = future_product + + future_dep.direction = "North" + future_dep.stop = "Gym Stop" + future_dep.time = ( + (base_time + timedelta(minutes=10)).time().replace(second=0, microsecond=0) + ) + future_dep.date = base_time.date() + future_dep.track = "2B" + future_dep.rtTrack = "2B" + future_dep.final_stop = "North Station" + future_dep.messages = ["Delayed"] + future_dep.rtTime = ( + (base_time + timedelta(minutes=12)).time().replace(second=0, microsecond=0) + ) + future_dep.rtDate = base_time.date() + future_dep.stopExtId = 456789 + departures.append(future_dep) + + return departures + # No departures for other stops + return [] + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.rejseplanen.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_subentries() -> list[ConfigSubentryDataWithId]: + """Fixture for config subentries.""" + return [ + ConfigSubentryDataWithId( + data={ + CONF_STOP_ID: 123456, + CONF_NAME: "Work", + CONF_DIRECTION: [], + CONF_DEPARTURE_TYPE: [], + }, + subentry_type="stop", + title="Work", + subentry_id="work-subentry-id", + unique_id=None, + ), + ConfigSubentryDataWithId( + data={ + CONF_STOP_ID: 456789, + CONF_NAME: "Gym", + CONF_DIRECTION: ["North"], + CONF_DEPARTURE_TYPE: [], + }, + subentry_type="stop", + title="Gym", + subentry_id="gym-subentry-id", + unique_id=None, + ), + ConfigSubentryDataWithId( + data={ + CONF_STOP_ID: 123789, + CONF_NAME: "Home Location", + CONF_DIRECTION: [], + CONF_DEPARTURE_TYPE: [TransportClass.IC, TransportClass.BUS], + }, + subentry_type="location", + title="Home", + subentry_id="home-subentry-id", + unique_id=None, + ), + ] + + +@pytest.fixture +def mock_config_entry( + hass: HomeAssistant, mock_subentries: list[ConfigSubentryDataWithId] +) -> MockConfigEntry: + """Fixture for a config entry with subentries.""" + return MockConfigEntry( + domain=DOMAIN, + title=DOMAIN, + data={CONF_API_KEY: "test-api-key"}, + entry_id="123456789", + subentries_data=[*mock_subentries], + ) + + +@pytest.fixture(name="mock_api") +def mock_rejseplanen_coordinator(hass: HomeAssistant) -> Generator[Mock]: + """Fixture to mock Rejseplanen API client.""" + with patch( + "homeassistant.components.rejseplanen.coordinator.DeparturesAPIClient", + spec=DeparturesAPIClient, + ) as mock_api_class: + mock_api = mock_api_class.return_value + + async def get_departures_async(stop_ids, *args, **kwargs): + all_departures = [] + for stop_id in stop_ids: + all_departures.extend(make_mock_departures(int(stop_id))) + mock_board = MagicMock() + mock_board.departures = all_departures + return (mock_board, None) + + mock_api.get_departures_async = AsyncMock(side_effect=get_departures_async) + mock_api.validate_auth_key_async = AsyncMock(return_value=True) + mock_api.calculate_departure_type_bitflag = Mock(return_value=0) + yield mock_api + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: Mock, +) -> AsyncGenerator[Any, Any]: + """Fixture to set up the integration.""" + mock_config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.rejseplanen.coordinator.DeparturesAPIClient", + return_value=mock_api, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + yield diff --git a/tests/components/rejseplanen/snapshots/test_sensor.ambr b/tests/components/rejseplanen/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..ad678dbfe12fee --- /dev/null +++ b/tests/components/rejseplanen/snapshots/test_sensor.ambr @@ -0,0 +1,637 @@ +# serializer version: 1 +# name: test_sensor_snapshot[sensor.gym_delayed_by-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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gym_delayed_by', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Delayed by', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:bus', + 'original_name': 'Delayed by', + 'platform': 'rejseplanen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'delay', + 'unique_id': 'gym-subentry-id_delay', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.gym_delayed_by-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by rejseplanen.dk', + : 'duration', + : 'Gym Delayed by', + : 'mdi:bus', + : , + }), + 'context': , + 'entity_id': 'sensor.gym_delayed_by', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensor_snapshot[sensor.gym_departing_from_track-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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gym_departing_from_track', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Departing from track', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:bus', + 'original_name': 'Departing from track', + 'platform': 'rejseplanen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'track', + 'unique_id': 'gym-subentry-id_track', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.gym_departing_from_track-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by rejseplanen.dk', + : 'Gym Departing from track', + : 'mdi:bus', + }), + 'context': , + 'entity_id': 'sensor.gym_departing_from_track', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2B', + }) +# --- +# name: test_sensor_snapshot[sensor.gym_departing_in-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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gym_departing_in', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Departing in', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:bus', + 'original_name': 'Departing in', + 'platform': 'rejseplanen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'departure_time', + 'unique_id': 'gym-subentry-id_departure_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.gym_departing_in-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by rejseplanen.dk', + : 'timestamp', + : 'Gym Departing in', + : 'mdi:bus', + }), + 'context': , + 'entity_id': 'sensor.gym_departing_in', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-01-01T11:12:00+00:00', + }) +# --- +# name: test_sensor_snapshot[sensor.gym_line-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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gym_line', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Line', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:bus', + 'original_name': 'Line', + 'platform': 'rejseplanen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'line', + 'unique_id': 'gym-subentry-id_line', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.gym_line-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by rejseplanen.dk', + : 'Gym Line', + : 'mdi:bus', + }), + 'context': , + 'entity_id': 'sensor.gym_line', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'A', + }) +# --- +# name: test_sensor_snapshot[sensor.gym_number_of_departures-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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gym_number_of_departures', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Number of departures', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:bus', + 'original_name': 'Number of departures', + 'platform': 'rejseplanen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'no_departures', + 'unique_id': 'gym-subentry-id_departures', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.gym_number_of_departures-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by rejseplanen.dk', + : 'Gym Number of departures', + : 'mdi:bus', + }), + 'context': , + 'entity_id': 'sensor.gym_number_of_departures', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor_snapshot[sensor.gym_towards-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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gym_towards', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Towards', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:bus', + 'original_name': 'Towards', + 'platform': 'rejseplanen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'direction', + 'unique_id': 'gym-subentry-id_direction', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.gym_towards-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by rejseplanen.dk', + : 'Gym Towards', + : 'mdi:bus', + }), + 'context': , + 'entity_id': 'sensor.gym_towards', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'North', + }) +# --- +# name: test_sensor_snapshot[sensor.work_delayed_by-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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.work_delayed_by', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Delayed by', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:bus', + 'original_name': 'Delayed by', + 'platform': 'rejseplanen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'delay', + 'unique_id': 'work-subentry-id_delay', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.work_delayed_by-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by rejseplanen.dk', + : 'duration', + : 'Work Delayed by', + : 'mdi:bus', + : , + }), + 'context': , + 'entity_id': 'sensor.work_delayed_by', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensor_snapshot[sensor.work_departing_from_track-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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.work_departing_from_track', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Departing from track', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:bus', + 'original_name': 'Departing from track', + 'platform': 'rejseplanen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'track', + 'unique_id': 'work-subentry-id_track', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.work_departing_from_track-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by rejseplanen.dk', + : 'Work Departing from track', + : 'mdi:bus', + }), + 'context': , + 'entity_id': 'sensor.work_departing_from_track', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1A', + }) +# --- +# name: test_sensor_snapshot[sensor.work_departing_in-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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.work_departing_in', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Departing in', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:bus', + 'original_name': 'Departing in', + 'platform': 'rejseplanen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'departure_time', + 'unique_id': 'work-subentry-id_departure_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.work_departing_in-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by rejseplanen.dk', + : 'timestamp', + : 'Work Departing in', + : 'mdi:bus', + }), + 'context': , + 'entity_id': 'sensor.work_departing_in', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-01-01T11:07:00+00:00', + }) +# --- +# name: test_sensor_snapshot[sensor.work_line-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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.work_line', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Line', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:bus', + 'original_name': 'Line', + 'platform': 'rejseplanen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'line', + 'unique_id': 'work-subentry-id_line', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.work_line-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by rejseplanen.dk', + : 'Work Line', + : 'mdi:bus', + }), + 'context': , + 'entity_id': 'sensor.work_line', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Bus 207', + }) +# --- +# name: test_sensor_snapshot[sensor.work_number_of_departures-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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.work_number_of_departures', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Number of departures', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:bus', + 'original_name': 'Number of departures', + 'platform': 'rejseplanen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'no_departures', + 'unique_id': 'work-subentry-id_departures', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.work_number_of_departures-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by rejseplanen.dk', + : 'Work Number of departures', + : 'mdi:bus', + }), + 'context': , + 'entity_id': 'sensor.work_number_of_departures', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensor_snapshot[sensor.work_towards-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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.work_towards', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Towards', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:bus', + 'original_name': 'Towards', + 'platform': 'rejseplanen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'direction', + 'unique_id': 'work-subentry-id_direction', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.work_towards-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by rejseplanen.dk', + : 'Work Towards', + : 'mdi:bus', + }), + 'context': , + 'entity_id': 'sensor.work_towards', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'End Point St.', + }) +# --- diff --git a/tests/components/rejseplanen/test_config_flow.py b/tests/components/rejseplanen/test_config_flow.py new file mode 100644 index 00000000000000..e311edc08dd21d --- /dev/null +++ b/tests/components/rejseplanen/test_config_flow.py @@ -0,0 +1,238 @@ +"""Test the Rejseplanen config flow.""" + +import logging +from typing import Any +from unittest.mock import AsyncMock, patch + +from py_rejseplan import enums +import pytest + +from homeassistant import config_entries +from homeassistant.components.rejseplanen.const import CONF_STOP_ID, DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry, get_schema_suggested_value + +LOGGER = logging.getLogger(__name__) + +TEST_API_KEY = "api_key" + + +async def test_form_user_step( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_api: AsyncMock +) -> None: + """Test the user step of the Rejseplanen config flow. + + This test verifies that: + - The initial form is presented to the user when starting the config flow. + - Submitting a valid API key results in the creation of a config entry with the correct title and data. + - The authentication key validation is properly mocked to simulate a successful validation. + """ + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.rejseplanen.config_flow.Rejseplanen.validate_auth_key_async", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: TEST_API_KEY}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Rejseplanen" + assert result["data"] == {"api_key": TEST_API_KEY} + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("return_value", "expected_errors"), + [ + # Invalid authentication + (False, {"base": "invalid_auth"}), + ], +) +async def test_config_flow_error_cases( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + return_value: bool, + expected_errors: dict[str, str], +) -> None: + """Test invalid authentication handling.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.rejseplanen.config_flow.Rejseplanen" + ) as mock_client: + mock_client.return_value.validate_auth_key_async = AsyncMock( + return_value=return_value + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: TEST_API_KEY}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == expected_errors + + data_schema = result["data_schema"].schema + assert get_schema_suggested_value(data_schema, CONF_API_KEY) == TEST_API_KEY + + with patch( + "homeassistant.components.rejseplanen.config_flow.Rejseplanen" + ) as mock_client: + mock_client.return_value.validate_auth_key_async = AsyncMock(return_value=True) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: TEST_API_KEY}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_singleton_prevention( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test singleton integration prevents multiple entries.""" + + mock_config_entry.add_to_hass(hass) + + # Try to start new config flow - should abort for singleton + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + +@pytest.mark.parametrize( + ("user_input", "expected_title", "expected_data"), + [ + # Minimal input, only required fields + ( + {CONF_STOP_ID: "12345", CONF_NAME: "Central Station"}, + "Central Station", + { + CONF_STOP_ID: 12345, + CONF_NAME: "Central Station", + "departure_type": [], + "direction": [], + }, + ), + # All optional fields provided, single values + ( + { + CONF_STOP_ID: "67890", + CONF_NAME: "Airport", + "departure_type": ["bus"], + "direction": ["North"], + }, + "Airport", + { + CONF_STOP_ID: 67890, + CONF_NAME: "Airport", + "departure_type": [enums.TransportClass.BUS], + "direction": ["North"], + }, + ), + # All optional fields provided, multiple values + ( + { + CONF_STOP_ID: "24680", + CONF_NAME: "Harbor", + "departure_type": ["bus", "tog"], + "direction": ["East", "West"], + }, + "Harbor", + { + CONF_STOP_ID: 24680, + CONF_NAME: "Harbor", + "departure_type": [ + enums.TransportClass.BUS, + enums.TransportClass.TOG, + ], + "direction": ["East", "West"], + }, + ), + # Special characters in name and direction + ( + { + CONF_STOP_ID: "13579", + CONF_NAME: "Østerport", + "departure_type": [], + "direction": ["Syd", "Nord"], + }, + "Østerport", + { + CONF_STOP_ID: 13579, + CONF_NAME: "Østerport", + "departure_type": [], + "direction": ["Syd", "Nord"], + }, + ), + # No optional fields provided (should default to empty lists) + ( + { + CONF_STOP_ID: "11223", + CONF_NAME: "NoOptions", + }, + "NoOptions", + { + CONF_STOP_ID: 11223, + CONF_NAME: "NoOptions", + "departure_type": [], + "direction": [], + }, + ), + ], +) +async def test_stop_subentry_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: AsyncMock, + user_input: dict[str, Any], + expected_title: str, + expected_data: dict[str, Any], +) -> None: + """Test adding a stop as a subentry under the main Rejseplanen entry.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + LOGGER.debug("Config Entry ID: %s", mock_config_entry.entry_id) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "stop"), + context={"source": "user"}, + ) + + LOGGER.debug("Subentry Flow Init Result: %s", result) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], user_input + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == expected_title + assert result["data"] == expected_data + + assert len(mock_config_entry.subentries) == 4 diff --git a/tests/components/rejseplanen/test_init.py b/tests/components/rejseplanen/test_init.py new file mode 100644 index 00000000000000..2bb1276285e6f4 --- /dev/null +++ b/tests/components/rejseplanen/test_init.py @@ -0,0 +1,192 @@ +"""Test the Rejseplanen integration setup and coordinator behavior.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from py_rejseplan.enums import TransportClass +from py_rejseplan.exceptions.api_error import APIError +from py_rejseplan.exceptions.connection_error import ConnectionError +from py_rejseplan.exceptions.http_error import HTTPError +import pytest + +from homeassistant.components.rejseplanen.const import ( + CONF_DEPARTURE_TYPE, + CONF_DIRECTION, + CONF_STOP_ID, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState, ConfigSubentryDataWithId +from homeassistant.const import CONF_API_KEY, CONF_NAME, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed + +TEST_ENTITY_ID = "sensor.work_line" + + +@pytest.mark.freeze_time("2024-01-01 11:00:00+00:00") +@pytest.mark.usefixtures("setup_integration") +async def test_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the integration sets up and entities become available.""" + assert mock_config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.state == "Bus 207" + + +@pytest.mark.parametrize( + "error", + [ + APIError("api error"), + HTTPError("http error", status_code=500), + ConnectionError("connection error"), + TypeError("type error"), + ], +) +async def test_setup_first_refresh_fails( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: AsyncMock, + error: Exception, +) -> None: + """Test that a failing first refresh results in a setup retry.""" + mock_api.get_departures_async.side_effect = error + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_first_refresh_auth_fails( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: AsyncMock, +) -> None: + """Test that an auth error during first refresh puts the entry in an error state.""" + mock_api.get_departures_async.side_effect = HTTPError( + "unauthorized", status_code=401 + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +@pytest.mark.freeze_time("2024-01-01 11:00:00+00:00") +@pytest.mark.parametrize( + "error", + [ + APIError("api error"), + HTTPError("http error", status_code=500), + ConnectionError("connection error"), + TypeError("type error"), + ], +) +@pytest.mark.usefixtures("setup_integration") +async def test_update_failure_marks_entities_unavailable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: AsyncMock, + freezer: FrozenDateTimeFactory, + error: Exception, +) -> None: + """Test that a failed refresh marks entities unavailable.""" + assert hass.states.get(TEST_ENTITY_ID).state == "Bus 207" + + mock_api.get_departures_async.side_effect = error + freezer.tick(timedelta(minutes=6)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state == STATE_UNAVAILABLE + + +@pytest.mark.freeze_time("2024-01-01 11:00:00+00:00") +@pytest.mark.usefixtures("setup_integration") +async def test_update_auth_failure_marks_entities_unavailable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that an auth error during a refresh marks entities unavailable.""" + assert hass.states.get(TEST_ENTITY_ID).state == "Bus 207" + + mock_api.get_departures_async.side_effect = HTTPError( + "unauthorized", status_code=401 + ) + freezer.tick(timedelta(minutes=6)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state == STATE_UNAVAILABLE + + +@pytest.mark.freeze_time("2024-01-01 11:00:00+00:00") +async def test_setup_without_stops( + hass: HomeAssistant, + mock_api: AsyncMock, +) -> None: + """Test that an entry with no stop subentries loads without entities.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title=DOMAIN, + data={CONF_API_KEY: "test-api-key"}, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + assert not hass.states.async_entity_ids("sensor") + mock_api.get_departures_async.assert_not_called() + + +@pytest.mark.freeze_time("2024-01-01 11:00:00+00:00") +async def test_departure_type_filter( + hass: HomeAssistant, + mock_api: AsyncMock, +) -> None: + """Test that departures are filtered by the configured departure type. + + Stop 456789 has BUS, TOG and ICL departures. Filtering on ICL leaves + only the future ICL departure. + """ + mock_api.calculate_departure_type_bitflag.return_value = int(TransportClass.ICL) + + config_entry = MockConfigEntry( + domain=DOMAIN, + title=DOMAIN, + data={CONF_API_KEY: "test-api-key"}, + subentries_data=[ + ConfigSubentryDataWithId( + data={ + CONF_STOP_ID: 456789, + CONF_NAME: "Gym", + CONF_DIRECTION: [], + CONF_DEPARTURE_TYPE: [TransportClass.ICL], + }, + subentry_type="stop", + title="Gym", + subentry_id="gym-subentry-id", + unique_id=None, + ), + ], + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("sensor.gym_number_of_departures").state == "1" + assert ( + hass.states.get("sensor.gym_departing_in").state == "2024-01-01T11:12:00+00:00" + ) diff --git a/tests/components/rejseplanen/test_sensor.py b/tests/components/rejseplanen/test_sensor.py new file mode 100644 index 00000000000000..45b6c25e277c31 --- /dev/null +++ b/tests/components/rejseplanen/test_sensor.py @@ -0,0 +1,131 @@ +"""Test the Rejseplanen sensor.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock + +from freezegun.api import FrozenDateTimeFactory +from py_rejseplan.dataclasses.departure import Departure +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.rejseplanen.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +def _board(departures: list[Departure]) -> MagicMock: + """Wrap departures in a mock departure board.""" + board = MagicMock() + board.departures = departures + return board + + +@pytest.mark.freeze_time("2024-01-01 11:00:00+00:00") +@pytest.mark.usefixtures("setup_integration") +async def test_sensor_snapshot( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Snapshot test of the sensors.""" + assert mock_config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.freeze_time("2024-01-01 11:00:00+00:00") +@pytest.mark.usefixtures("setup_integration") +async def test_past_departures_are_filtered( + hass: HomeAssistant, +) -> None: + """Test that departures in the past are not reflected in the state. + + Stop 456789 has a past, a buffer and a future departure but the Gym + subentry filters on direction "North", leaving only the future one. + """ + assert hass.states.get("sensor.gym_number_of_departures").state == "1" + assert hass.states.get("sensor.gym_line").state == "A" + assert ( + hass.states.get("sensor.gym_departing_in").state == "2024-01-01T11:12:00+00:00" + ) + + +@pytest.mark.freeze_time("2024-01-01 11:00:00+00:00") +@pytest.mark.usefixtures("setup_integration") +async def test_delay( + hass: HomeAssistant, +) -> None: + """Test that the delay is exposed through the delay sensor.""" + # Work: planned 12:05, realtime 12:07 -> 2 minutes delay. + assert hass.states.get("sensor.work_delayed_by").state == "2" + + +@pytest.mark.freeze_time("2024-01-01 11:00:00+00:00") +async def test_no_departures( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: AsyncMock, +) -> None: + """Test the sensor states when there are no departures.""" + mock_api.get_departures_async.side_effect = None + mock_api.get_departures_async.return_value = (_board([]), None) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("sensor.work_number_of_departures").state == "0" + assert hass.states.get("sensor.work_line").state == STATE_UNKNOWN + + +@pytest.mark.freeze_time("2024-01-01 11:00:00+00:00") +@pytest.mark.usefixtures("setup_integration") +async def test_departure_cleanup_trigger( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a departure is dropped once its cleanup time is reached.""" + # Work has departures at 12:07 and 12:08 CET (11:07 and 11:08 UTC). + assert hass.states.get("sensor.work_number_of_departures").state == "2" + assert ( + hass.states.get("sensor.work_departing_in").state == "2024-01-01T11:07:00+00:00" + ) + + # Advance just past the first departure + cleanup buffer (15s). + freezer.tick(timedelta(minutes=7, seconds=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.work_number_of_departures").state == "1" + assert ( + hass.states.get("sensor.work_departing_in").state == "2024-01-01T11:08:00+00:00" + ) + + +async def test_async_setup_platform_creates_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test that YAML configuration creates a deprecation issue.""" + assert await async_setup_component( + hass, + "sensor", + { + "sensor": { + "platform": DOMAIN, + "authentication": "test-api-key", + "stop_id": 123456, + } + }, + ) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, "yaml_deprecated") + assert issue is not None + assert issue.translation_key == "yaml_deprecated" diff --git a/tests/components/remote_calendar/test_config_flow.py b/tests/components/remote_calendar/test_config_flow.py index b6e59f6f967b1b..e89c3ae1820b19 100644 --- a/tests/components/remote_calendar/test_config_flow.py +++ b/tests/components/remote_calendar/test_config_flow.py @@ -13,7 +13,7 @@ from . import setup_integration from .conftest import CALENDAR_NAME, CALENDER_URL -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value @respx.mock @@ -77,6 +77,61 @@ async def test_form_import_webcal(hass: HomeAssistant, ics_content: str) -> None } +@respx.mock +async def test_form_import_webcal_error(hass: HomeAssistant, ics_content: str) -> None: + """Test webcal URL error re-displays form with suggested values.""" + respx.get(CALENDER_URL).mock(side_effect=HTTPError("Connection failed")) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: "webcal://some.calendar.com/calendar.ics", + CONF_VERIFY_SSL: False, + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + assert ( + get_schema_suggested_value(result2["data_schema"].schema, CONF_CALENDAR_NAME) + == CALENDAR_NAME + ) + assert ( + get_schema_suggested_value(result2["data_schema"].schema, CONF_URL) + == CALENDER_URL + ) + assert ( + get_schema_suggested_value(result2["data_schema"].schema, CONF_VERIFY_SSL) + is False + ) + + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=ics_content, + ) + ) + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + CONF_VERIFY_SSL: False, + }, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == CALENDAR_NAME + assert result3["data"] == { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + CONF_VERIFY_SSL: False, + } + + @pytest.mark.parametrize( ("side_effect", "base_error"), [ @@ -108,6 +163,14 @@ async def test_form_invalid_url( ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": base_error} + assert ( + get_schema_suggested_value(result2["data_schema"].schema, CONF_CALENDAR_NAME) + == CALENDAR_NAME + ) + assert ( + get_schema_suggested_value(result2["data_schema"].schema, CONF_URL) + == "invalid-url.com" + ) respx.get(CALENDER_URL).mock( return_value=Response( status_code=200, @@ -167,6 +230,11 @@ async def test_unsupported_inputs( assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} + assert ( + get_schema_suggested_value(result2["data_schema"].schema, CONF_CALENDAR_NAME) + == CALENDAR_NAME + ) + assert get_schema_suggested_value(result2["data_schema"].schema, CONF_URL) == url assert log_message in caplog.text ## It's not possible to test a successful config flow because, ## we need to mock httpx.get here and then the exception isn't @@ -204,6 +272,14 @@ async def test_form_http_status_error( ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": error} + assert ( + get_schema_suggested_value(result2["data_schema"].schema, CONF_CALENDAR_NAME) + == CALENDAR_NAME + ) + assert ( + get_schema_suggested_value(result2["data_schema"].schema, CONF_URL) + == CALENDER_URL + ) respx.get(CALENDER_URL).mock( return_value=Response( status_code=200, @@ -252,6 +328,18 @@ async def test_no_valid_calendar(hass: HomeAssistant, ics_content: str) -> None: assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "invalid_ics_file"} + assert ( + get_schema_suggested_value(result2["data_schema"].schema, CONF_CALENDAR_NAME) + == CALENDAR_NAME + ) + assert ( + get_schema_suggested_value(result2["data_schema"].schema, CONF_URL) + == CALENDER_URL + ) + assert ( + get_schema_suggested_value(result2["data_schema"].schema, CONF_VERIFY_SSL) + is True + ) respx.get(CALENDER_URL).mock( return_value=Response( status_code=200, diff --git a/tests/components/reolink/test_util.py b/tests/components/reolink/test_util.py index 1ebeaf902c847e..76d6af2948fac5 100644 --- a/tests/components/reolink/test_util.py +++ b/tests/components/reolink/test_util.py @@ -157,6 +157,8 @@ async def test_get_device_uid_and_ch( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - result = get_device_uid_and_ch(dev_entry, config_entry.runtime_data.host) + result = get_device_uid_and_ch( + dev_entry.identifiers, config_entry.runtime_data.host + ) # always get the uid and channel form the DEV_ID_NVR since is_nvr = True assert result == ([TEST_UID, TEST_UID_CAM], 0, False) diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 58a474ddbe66fb..c994c053cbe342 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -102,17 +102,22 @@ _LOGGER = logging.getLogger(__name__) +DYAD_VALUES: dict[RoborockDyadDataProtocol, Any] = { + RoborockDyadDataProtocol.STATUS: RoborockDyadStateCode.drying.name, + RoborockDyadDataProtocol.POWER: 100, + RoborockDyadDataProtocol.MESH_LEFT: 111, + RoborockDyadDataProtocol.BRUSH_LEFT: 222, + RoborockDyadDataProtocol.ERROR: DyadError.none.name, + RoborockDyadDataProtocol.TOTAL_RUN_TIME: 213, +} + + def create_dyad_trait() -> Mock: """Create dyad trait for A01 devices.""" dyad_trait = AsyncMock() - dyad_trait.query_values.return_value = { - RoborockDyadDataProtocol.STATUS: RoborockDyadStateCode.drying.name, - RoborockDyadDataProtocol.POWER: 100, - RoborockDyadDataProtocol.MESH_LEFT: 111, - RoborockDyadDataProtocol.BRUSH_LEFT: 222, - RoborockDyadDataProtocol.ERROR: DyadError.none.name, - RoborockDyadDataProtocol.TOTAL_RUN_TIME: 213, - } + dyad_trait.add_update_listener = Mock(return_value=Mock()) + dyad_trait.values = dict(DYAD_VALUES) + dyad_trait.last_message_time = None return dyad_trait diff --git a/tests/components/roborock/test_binary_sensor.py b/tests/components/roborock/test_binary_sensor.py index 7eb19a554d6f5a..8204e5b0839d02 100644 --- a/tests/components/roborock/test_binary_sensor.py +++ b/tests/components/roborock/test_binary_sensor.py @@ -7,11 +7,12 @@ from roborock.data import RoborockDockTypeCode from roborock.device_features import RoborockDockFeatures from roborock.exceptions import RoborockException +from roborock.roborock_message import RoborockZeoProtocol from syrupy.assertion import SnapshotAssertion from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN from homeassistant.components.roborock.const import DOMAIN -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component @@ -129,6 +130,22 @@ async def test_zeo_request_protocols_filtered_by_schema( assert hass.states.get("binary_sensor.zeo_two_softener") is None +async def test_zeo_unreported_protocol_is_unknown( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + fake_devices: list[FakeDevice], +) -> None: + """Test a protocol the device has not reported yet reads as unknown.""" + zeo = next(device.zeo for device in fake_devices if device.zeo is not None) + del zeo.query_values.return_value[RoborockZeoProtocol.DETERGENT_EMPTY] + + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.zeo_one_detergent").state == STATE_UNKNOWN + assert hass.states.get("binary_sensor.zeo_one_softener").state == "off" + + @pytest.fixture def dock_type(request: pytest.FixtureRequest, fake_vacuum: FakeDevice) -> None: """Report the parametrized dock type for the fake vacuum.""" diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index d6ba272d2e521e..c6397950e9351c 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -1,21 +1,26 @@ """Test Roborock Sensors.""" +from datetime import timedelta from typing import Any +from freezegun.api import FrozenDateTimeFactory import pytest from roborock.data.v1 import RoborockDockTypeCode from roborock.device_features import RoborockDockFeatures from roborock.exceptions import RoborockException +from roborock.roborock_message import RoborockDyadDataProtocol from syrupy.assertion import SnapshotAssertion -from homeassistant.components.roborock.const import DOMAIN -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.components.roborock.const import A01_UPDATE_INTERVAL, DOMAIN +from homeassistant.components.roborock.coordinator import MIN_UNAVAILABLE_DURATION +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util from .conftest import FakeDevice -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.fixture @@ -156,3 +161,98 @@ async def test_dock_cleaning_brush_sensor_created_when_supported( state = hass.states.get("sensor.roborock_s7_maxv_dock_maintenance_brush_time_left") assert state is not None assert state.state == "235" + + +async def test_dyad_follows_reported_state( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + fake_devices: list[FakeDevice], +) -> None: + """Test the device state is applied as the library reports it.""" + dyad = next(device.dyad for device in fake_devices if device.dyad is not None) + assert hass.states.get("sensor.dyad_pro_battery").state == "100" + + dyad.values = {**dyad.values, RoborockDyadDataProtocol.POWER: 50} + dyad.add_update_listener.call_args[0][0]() + await hass.async_block_till_done() + + assert hass.states.get("sensor.dyad_pro_battery").state == "50" + + +async def test_dyad_unsubscribed_on_unload( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + fake_devices: list[FakeDevice], +) -> None: + """Test the update listener is removed when the config entry unloads.""" + dyad = next(device.dyad for device in fake_devices if device.dyad is not None) + unsub = dyad.add_update_listener.return_value + + assert await hass.config_entries.async_unload(setup_entry.entry_id) + await hass.async_block_till_done() + + unsub.assert_called_once() + + +async def test_dyad_unreported_protocol_is_unknown( + hass: HomeAssistant, + fake_devices: list[FakeDevice], + mock_roborock_entry: MockConfigEntry, +) -> None: + """Test a protocol the device has not reported yet reads as unknown.""" + dyad = next(device.dyad for device in fake_devices if device.dyad is not None) + dyad.values = {RoborockDyadDataProtocol.POWER: 50} + + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("sensor.dyad_pro_battery").state == "50" + assert hass.states.get("sensor.dyad_pro_status").state == STATE_UNKNOWN + + +async def test_dyad_update_does_not_postpone_poll( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + fake_devices: list[FakeDevice], + freezer: FrozenDateTimeFactory, +) -> None: + """Test the fallback poll keeps its schedule while the device reports state.""" + dyad = next(device.dyad for device in fake_devices if device.dyad is not None) + dyad.query_values.reset_mock() + + freezer.tick(A01_UPDATE_INTERVAL / 2) + dyad.add_update_listener.call_args[0][0]() + await hass.async_block_till_done() + + freezer.tick(A01_UPDATE_INTERVAL / 2 + timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert dyad.query_values.call_count == 1 + + +@pytest.mark.parametrize( + ("last_message_age", "expected_state"), + [ + pytest.param(timedelta(0), "100", id="still_talking"), + pytest.param(MIN_UNAVAILABLE_DURATION, STATE_UNAVAILABLE, id="gone_silent"), + ], +) +async def test_dyad_availability_follows_last_message( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + fake_devices: list[FakeDevice], + freezer: FrozenDateTimeFactory, + last_message_age: timedelta, + expected_state: str, +) -> None: + """Test a failed poll only reports unavailable once the device stops talking.""" + dyad = next(device.dyad for device in fake_devices if device.dyad is not None) + dyad.query_values.side_effect = RoborockException("Simulated failure") + dyad.last_message_time = dt_util.utcnow() - last_message_age + + freezer.tick(A01_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.dyad_pro_battery").state == expected_state diff --git a/tests/components/smartthings/snapshots/test_media_player.ambr b/tests/components/smartthings/snapshots/test_media_player.ambr index 00abfab3498df5..a64e3132a383de 100644 --- a/tests/components/smartthings/snapshots/test_media_player.ambr +++ b/tests/components/smartthings/snapshots/test_media_player.ambr @@ -247,6 +247,12 @@ ]), 'area_id': None, 'capabilities': dict({ + : list([ + 'standard', + 'surround', + 'game', + 'adaptive_sound', + ]), }), 'config_entry_id': , 'config_subentry_id': , @@ -272,7 +278,7 @@ 'platform': 'smartthings', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'media_player', 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac_main', 'unit_of_measurement': None, @@ -286,8 +292,14 @@ : False, : '', : '', + : list([ + 'standard', + 'surround', + 'game', + 'adaptive_sound', + ]), : 'hdmi1', - : , + : , : 0.17, }), 'context': , diff --git a/tests/components/smartthings/test_media_player.py b/tests/components/smartthings/test_media_player.py index d2d7613a88a4e7..287d51a891e5ed 100644 --- a/tests/components/smartthings/test_media_player.py +++ b/tests/components/smartthings/test_media_player.py @@ -13,7 +13,10 @@ ATTR_MEDIA_SHUFFLE, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, + ATTR_SOUND_MODE, + ATTR_SOUND_MODE_LIST, DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE, MediaPlayerEntityFeature, RepeatMode, @@ -513,6 +516,60 @@ async def test_media_repeat_mode( ) +@pytest.mark.parametrize("device_fixture", ["vd_network_audio_002s"]) +async def test_select_sound_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test media player select sound mode command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOUND_MODE, + { + ATTR_ENTITY_ID: "media_player.theater_soundbar_living", + ATTR_SOUND_MODE: "adaptive_sound", + }, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "0d94e5db-8501-2355-eb4f-214163702cac", + Capability.EXECUTE, + Command.EXECUTE, + MAIN, + argument=[ + "/sec/networkaudio/soundmode", + {"x.com.samsung.networkaudio.soundmode": "adaptive sound"}, + ], + ) + assert ( + hass.states.get("media_player.theater_soundbar_living").attributes[ + ATTR_SOUND_MODE + ] + == "adaptive_sound" + ) + + +@pytest.mark.parametrize("device_fixture", ["im_speaker_ai_0001"]) +async def test_no_sound_mode_on_non_soundbar( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test non-soundbar network audio devices don't expose sound mode.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("media_player.galaxy_home_mini") + assert state is not None + assert ATTR_SOUND_MODE_LIST not in state.attributes + assert not ( + state.attributes[ATTR_SUPPORTED_FEATURES] + & MediaPlayerEntityFeature.SELECT_SOUND_MODE + ) + + @pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) async def test_state_update( hass: HomeAssistant, diff --git a/tests/components/tesla_fleet/test_init.py b/tests/components/tesla_fleet/test_init.py index c1097db7159c6f..4aa8b8b44c44e8 100644 --- a/tests/components/tesla_fleet/test_init.py +++ b/tests/components/tesla_fleet/test_init.py @@ -5,6 +5,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, Mock, PropertyMock, patch +from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -57,6 +58,8 @@ (OAuthExpired, ConfigEntryState.SETUP_ERROR), (LoginRequired, ConfigEntryState.SETUP_ERROR), (TeslaFleetError, ConfigEntryState.SETUP_RETRY), + (ClientError, ConfigEntryState.SETUP_RETRY), + (TimeoutError, ConfigEntryState.SETUP_RETRY), ] RUNTIME_ERRORS = [InvalidToken, OAuthExpired, LoginRequired, TeslaFleetError] @@ -87,7 +90,7 @@ async def test_init_error( hass: HomeAssistant, normal_config_entry: MockConfigEntry, mock_products: AsyncMock, - side_effect: type[TeslaFleetError], + side_effect: type[Exception], state: ConfigEntryState, ) -> None: """Test init with errors.""" diff --git a/tests/components/tesla_fleet/test_models.py b/tests/components/tesla_fleet/test_models.py new file mode 100644 index 00000000000000..eac52bdec0c8ea --- /dev/null +++ b/tests/components/tesla_fleet/test_models.py @@ -0,0 +1,20 @@ +"""Test the Tesla Fleet models.""" + +from unittest.mock import Mock + +from homeassistant.components.tesla_fleet.models import TeslaFleetVehicleData + + +def test_wakelock_is_per_instance() -> None: + """Each vehicle must get its own wakelock, not a lock shared across the account.""" + kwargs = { + "api": Mock(), + "coordinator": Mock(), + "vin": "VIN", + "device": Mock(), + "signing": False, + } + a = TeslaFleetVehicleData(**kwargs) + b = TeslaFleetVehicleData(**kwargs) + + assert a.wakelock is not b.wakelock diff --git a/tests/components/teslemetry/test_config_flow.py b/tests/components/teslemetry/test_config_flow.py index 5cc5491a3994ab..8fb91324687970 100644 --- a/tests/components/teslemetry/test_config_flow.py +++ b/tests/components/teslemetry/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Teslemetry config flow.""" +import asyncio from collections.abc import Generator from copy import deepcopy import time @@ -13,16 +14,24 @@ PowerwallConnectionError, PowerwallFaultError, ) +from bleak.exc import BleakError from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa import pytest from tesla_fleet_api.const import AuthorizedClientState from tesla_fleet_api.exceptions import ( + BluetoothTimeout, + BluetoothTransportError, InvalidResponse, InvalidToken, + NotOnWhitelistFault, + PrivateKeyError, SubscriptionRequired, TeslaFleetError, + WhitelistOperationAttemptingToAddExistingKey, ) +from tesla_fleet_api.tesla import VehicleRouter +from tesla_fleet_api.tesla.bluetooth import TeslaBluetooth from tesla_fleet_api.teslemetry.energysite import AuthorizedClient, AuthorizedClients import voluptuous as vol @@ -34,20 +43,27 @@ AUTHORIZE_URL, CLIENT_ID, CONF_SITE_ID, + CONF_VIN, DOMAIN, SUBENTRY_TYPE_ENERGY_SITE, + SUBENTRY_TYPE_VEHICLE, TOKEN_URL, ) from homeassistant.config_entries import ( SOURCE_USER, ConfigEntryState, + ConfigSubentry, ConfigSubentryData, SubentryFlowResult, ) -from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import config_entry_oauth2_flow, device_registry as dr +from homeassistant.helpers import ( + config_entry_oauth2_flow, + device_registry as dr, + entity_registry as er, +) from homeassistant.setup import async_setup_component from . import mock_config_entry, setup_platform @@ -139,7 +155,7 @@ async def test_reauth( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - flows = hass.config_entries.flow.async_progress() + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) assert len(flows) == 1 # Progress from reauth_confirm to external OAuth step @@ -219,10 +235,7 @@ async def test_reauth_loaded_schedules_reload( hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, ) -> None: - """A data-only reauth schedules the reload itself to apply the token. - - The subentry set is unchanged, so the update listener never reloads. - """ + """A data-only reauth schedules the reload itself to apply the token.""" mock_entry = await setup_platform(hass, []) assert mock_entry.state is ConfigEntryState.LOADED @@ -680,6 +693,718 @@ async def test_migrate_error_from_future( assert entry.state is ConfigEntryState.MIGRATION_ERROR +VIN = "LRW3F7EK4NC700000" +ADDRESS = "AA:BB:CC:DD:EE:FF" + + +def _entry_with_ble() -> MockConfigEntry: + """Return a config entry whose vehicle subentry is already BLE-paired.""" + entry = mock_config_entry() + return MockConfigEntry( + domain=entry.domain, + version=entry.version, + minor_version=entry.minor_version, + unique_id=entry.unique_id, + data=dict(entry.data), + subentries_data=[ + ConfigSubentryData( + subentry_type=SUBENTRY_TYPE_VEHICLE, + unique_id=VIN, + title="Test", + data={CONF_VIN: VIN, CONF_ADDRESS: ADDRESS}, + ) + ], + ) + + +def _discovered_info() -> MagicMock: + """Return a fake discovered service info matching the test VIN.""" + info = MagicMock() + info.name = TeslaBluetooth().get_name(VIN) + info.address = ADDRESS + info.device = MagicMock() + return info + + +def _mock_vehicle(*, on_whitelist: bool = True) -> AsyncMock: + """Return a mock VehicleBluetooth for the pairing flow.""" + vehicle = AsyncMock() + if on_whitelist: + vehicle.handshakeVehicleSecurity = AsyncMock() + else: + vehicle.handshakeVehicleSecurity = AsyncMock( + side_effect=[NotOnWhitelistFault(), None] + ) + return vehicle + + +def _mock_ble_parent(vehicle: AsyncMock) -> MagicMock: + """Return a mock shared TeslaBluetooth parent for the pairing flow.""" + parent = MagicMock() + parent.get_name.return_value = TeslaBluetooth().get_name(VIN) + parent.vehicles.createBluetooth.return_value = vehicle + return parent + + +async def _setup_account_entry(hass: HomeAssistant) -> MockConfigEntry: + """Set up an account entry with no vehicle subentry.""" + entry = mock_config_entry() + entry.add_to_hass(hass) + with patch("homeassistant.components.teslemetry.PLATFORMS", []): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry + + +async def _setup_paired_entry(hass: HomeAssistant) -> MockConfigEntry: + """Set up an entry whose only account vehicle is already BLE-paired.""" + entry = _entry_with_ble() + entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.teslemetry.async_ble_device_from_address", + return_value=None, + ), + patch( + "homeassistant.components.teslemetry.helpers.TeslaBluetooth" + ) as mock_parent, + patch("homeassistant.components.teslemetry.PLATFORMS", []), + ): + mock_parent.return_value.get_private_key = AsyncMock() + mock_parent.return_value.vehicles.createBluetooth.return_value = AsyncMock() + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry + + +async def _start_pairing_at_scan( + hass: HomeAssistant, entry: MockConfigEntry +) -> SubentryFlowResult: + """Open the add flow and advance past VIN selection to the scan step.""" + result = await hass.config_entries.subentries.async_init( + (entry.entry_id, SUBENTRY_TYPE_VEHICLE), + context={"source": "user"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {CONF_VIN: VIN} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "scan" + return result + + +async def test_subentry_pairing_already_whitelisted(hass: HomeAssistant) -> None: + """The add flow creates the subentry when the key is already whitelisted.""" + entry = await _setup_account_entry(hass) + vehicle = _mock_vehicle(on_whitelist=True) + + with ( + patch( + "homeassistant.components.teslemetry.config_flow.async_discovered_service_info", + return_value=[_discovered_info()], + ), + patch( + "homeassistant.components.teslemetry.config_flow.async_get_ble_parent", + return_value=_mock_ble_parent(vehicle), + ), + patch.object(hass.config_entries, "async_schedule_reload"), + ): + result = await _start_pairing_at_scan(hass, entry) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE) + assert len(subentries) == 1 + # The subentry is created atomically with its credentials, never identity-only. + assert subentries[0].unique_id == VIN + assert subentries[0].data == {CONF_VIN: VIN, CONF_ADDRESS: ADDRESS} + vehicle.connect.assert_awaited_once() + vehicle.disconnect.assert_awaited_once() + + +async def test_subentry_pairing_duplicate_vin_aborts(hass: HomeAssistant) -> None: + """A second flow racing on the same VIN aborts with already_configured.""" + entry = await _setup_account_entry(hass) + vehicle = _mock_vehicle(on_whitelist=True) + + with ( + patch( + "homeassistant.components.teslemetry.config_flow.async_discovered_service_info", + return_value=[_discovered_info()], + ), + patch( + "homeassistant.components.teslemetry.config_flow.async_get_ble_parent", + return_value=_mock_ble_parent(vehicle), + ), + patch.object(hass.config_entries, "async_schedule_reload"), + ): + result = await _start_pairing_at_scan(hass, entry) + # Simulate a concurrent flow that paired the same VIN first. + hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data={CONF_VIN: VIN, CONF_ADDRESS: ADDRESS}, + subentry_type=SUBENTRY_TYPE_VEHICLE, + title="Test", + unique_id=VIN, + ), + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + # The pre-existing subentry from the winning flow is left untouched. + assert len(entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)) == 1 + + +async def test_subentry_pairing_requires_key_approval(hass: HomeAssistant) -> None: + """Pairing walks through instructions and key install when not whitelisted.""" + entry = await _setup_account_entry(hass) + vehicle = _mock_vehicle(on_whitelist=False) + release = asyncio.Event() + + async def _pair() -> None: + await release.wait() + + vehicle.pair = AsyncMock(side_effect=_pair) + + with ( + patch( + "homeassistant.components.teslemetry.config_flow.async_discovered_service_info", + return_value=[_discovered_info()], + ), + patch( + "homeassistant.components.teslemetry.config_flow.async_get_ble_parent", + return_value=_mock_ble_parent(vehicle), + ), + patch.object(hass.config_entries, "async_schedule_reload"), + ): + result = await _start_pairing_at_scan(hass, entry) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "instructions" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "pair" + + release.set() + await hass.async_block_till_done() + result = await hass.config_entries.subentries.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE) + assert len(subentries) == 1 + assert subentries[0].data == {CONF_VIN: VIN, CONF_ADDRESS: ADDRESS} + vehicle.pair.assert_awaited_once() + + +async def test_subentry_scan_connect_fails(hass: HomeAssistant) -> None: + """The scan step re-shows the form with an error when BLE connect fails.""" + entry = await _setup_account_entry(hass) + vehicle = _mock_vehicle() + vehicle.connect = AsyncMock(side_effect=BleakError("nope")) + + with ( + patch( + "homeassistant.components.teslemetry.config_flow.async_discovered_service_info", + return_value=[_discovered_info()], + ), + patch( + "homeassistant.components.teslemetry.config_flow.async_get_ble_parent", + return_value=_mock_ble_parent(vehicle), + ), + ): + result = await _start_pairing_at_scan(hass, entry) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "scan" + assert result["errors"] == {"base": "cannot_connect"} + # A failed pairing never creates a subentry. + assert not entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE) + vehicle.disconnect.assert_awaited_once() + + +@pytest.mark.parametrize( + ("error", "expected"), + [ + (BluetoothTimeout, "timeout"), + (BluetoothTransportError, "cannot_connect"), + (TeslaFleetError, "pair_failed"), + ], + ids=["timeout", "transport", "rejected"], +) +async def test_subentry_authorize_failure( + hass: HomeAssistant, error: type[TeslaFleetError], expected: str +) -> None: + """Each pairing failure surfaces its own error, not a blanket timeout.""" + entry = await _setup_account_entry(hass) + vehicle = _mock_vehicle(on_whitelist=False) + release = asyncio.Event() + + async def _pair() -> None: + await release.wait() + raise error + + vehicle.pair = AsyncMock(side_effect=_pair) + + with ( + patch( + "homeassistant.components.teslemetry.config_flow.async_discovered_service_info", + return_value=[_discovered_info()], + ), + patch( + "homeassistant.components.teslemetry.config_flow.async_get_ble_parent", + return_value=_mock_ble_parent(vehicle), + ), + ): + result = await _start_pairing_at_scan(hass, entry) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + assert result["step_id"] == "instructions" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + + release.set() + await hass.async_block_till_done() + result = await hass.config_entries.subentries.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "instructions" + assert result["errors"] == {"base": expected} + assert not entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE) + # pair() is a single bounded op; it is never re-sent. + vehicle.pair.assert_awaited_once() + + +async def test_subentry_authorize_existing_key_finishes(hass: HomeAssistant) -> None: + """Approving the key after a timeout, then retrying, completes the pairing.""" + entry = await _setup_account_entry(hass) + vehicle = _mock_vehicle(on_whitelist=False) + releases = [asyncio.Event(), asyncio.Event()] + attempts = iter( + zip( + releases, + [BluetoothTimeout(), WhitelistOperationAttemptingToAddExistingKey()], + strict=True, + ) + ) + + async def _pair() -> None: + release, error = next(attempts) + await release.wait() + raise error + + vehicle.pair = AsyncMock(side_effect=_pair) + + with ( + patch( + "homeassistant.components.teslemetry.config_flow.async_discovered_service_info", + return_value=[_discovered_info()], + ), + patch( + "homeassistant.components.teslemetry.config_flow.async_get_ble_parent", + return_value=_mock_ble_parent(vehicle), + ), + patch.object(hass.config_entries, "async_schedule_reload"), + ): + result = await _start_pairing_at_scan(hass, entry) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + assert result["step_id"] == "instructions" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + + releases[0].set() + await hass.async_block_till_done() + result = await hass.config_entries.subentries.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "timeout"} + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + + releases[1].set() + await hass.async_block_till_done() + result = await hass.config_entries.subentries.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE) + assert len(subentries) == 1 + assert subentries[0].data == {CONF_VIN: VIN, CONF_ADDRESS: ADDRESS} + assert vehicle.pair.await_count == 2 + vehicle.disconnect.assert_awaited_once() + + +@pytest.mark.parametrize( + "handshake_error", + [ + pytest.param(TeslaFleetError(), id="tesla_fleet_error"), + pytest.param(BleakError("boom"), id="bleak_error"), + pytest.param(TimeoutError(), id="timeout_error"), + ], +) +async def test_subentry_handshake_error_recovers( + hass: HomeAssistant, handshake_error: Exception +) -> None: + """A handshake failure re-shows the scan form; retrying then pairs.""" + entry = await _setup_account_entry(hass) + vehicle = _mock_vehicle() + vehicle.handshakeVehicleSecurity = AsyncMock(side_effect=[handshake_error, None]) + vehicle.disconnect = AsyncMock(side_effect=BleakError("boom")) + + with ( + patch( + "homeassistant.components.teslemetry.config_flow.async_discovered_service_info", + return_value=[_discovered_info()], + ), + patch( + "homeassistant.components.teslemetry.config_flow.async_get_ble_parent", + return_value=_mock_ble_parent(vehicle), + ), + patch.object(hass.config_entries, "async_schedule_reload"), + ): + result = await _start_pairing_at_scan(hass, entry) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "scan" + assert result["errors"] == {"base": "cannot_connect"} + assert not entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE) + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE) + assert len(subentries) == 1 + assert subentries[0].data == {CONF_VIN: VIN, CONF_ADDRESS: ADDRESS} + # Both the failed and successful attempts disconnected; the disconnect error is swallowed. + assert vehicle.disconnect.await_count == 2 + + +async def test_subentry_pairing_abandoned(hass: HomeAssistant) -> None: + """Abandoning the flow mid-pairing cancels the pair task and disconnects.""" + entry = await _setup_account_entry(hass) + vehicle = _mock_vehicle(on_whitelist=False) + cancelled = asyncio.Event() + + async def _pair() -> None: + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cancelled.set() + raise + + vehicle.pair = AsyncMock(side_effect=_pair) + + with ( + patch( + "homeassistant.components.teslemetry.config_flow.async_discovered_service_info", + return_value=[_discovered_info()], + ), + patch( + "homeassistant.components.teslemetry.config_flow.async_get_ble_parent", + return_value=_mock_ble_parent(vehicle), + ), + ): + result = await _start_pairing_at_scan(hass, entry) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + + hass.config_entries.subentries.async_abort(result["flow_id"]) + await hass.async_block_till_done() + + assert cancelled.is_set() + vehicle.disconnect.assert_awaited_once() + # An abandoned pairing never creates a subentry. + assert not entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE) + + +async def test_subentry_scan_device_not_found(hass: HomeAssistant) -> None: + """The scan step re-shows the form with an error when no device is found.""" + entry = await _setup_account_entry(hass) + + with ( + patch( + "homeassistant.components.teslemetry.config_flow.async_discovered_service_info", + return_value=[], + ), + patch( + "homeassistant.components.teslemetry.config_flow.async_get_ble_parent", + return_value=MagicMock(), + ), + ): + result = await _start_pairing_at_scan(hass, entry) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "scan" + assert result["errors"] == {"base": "device_not_found"} + assert not entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE) + + +@pytest.mark.parametrize( + "key_error", + [ + pytest.param(OSError("disk gone"), id="os_error"), + pytest.param(ValueError("bad key"), id="value_error"), + # PrivateKeyError is the wrapped existing-key-file shape the scan step must recover from too. + pytest.param( + PrivateKeyError("malformed", "Not a valid PEM private key"), + id="private_key_error", + ), + ], +) +async def test_subentry_scan_key_load_recovers( + hass: HomeAssistant, key_error: Exception +) -> None: + """A Bluetooth key-load failure re-shows the scan form; a loadable key then pairs.""" + entry = await _setup_account_entry(hass) + vehicle = _mock_vehicle(on_whitelist=True) + + with ( + patch( + "homeassistant.components.teslemetry.config_flow.async_discovered_service_info", + return_value=[_discovered_info()], + ), + patch( + "homeassistant.components.teslemetry.config_flow.async_get_ble_parent", + side_effect=[key_error, _mock_ble_parent(vehicle)], + ) as mock_ble_parent, + patch.object(hass.config_entries, "async_schedule_reload"), + ): + result = await _start_pairing_at_scan(hass, entry) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "scan" + assert result["errors"] == {"base": "cannot_connect"} + assert not entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE) + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE) + assert len(subentries) == 1 + assert subentries[0].data == {CONF_VIN: VIN, CONF_ADDRESS: ADDRESS} + # Both attempts ran the key load; only the second one loaded a usable key. + assert mock_ble_parent.call_count == 2 + vehicle.connect.assert_awaited_once() + + +async def test_subentry_scan_finds_device_after_active_scan( + hass: HomeAssistant, +) -> None: + """An awake in-range car only in scan responses is found via active scan.""" + entry = await _setup_account_entry(hass) + vehicle = _mock_vehicle() + mock_discovered = MagicMock(return_value=[]) + + async def _active_scan(hass: HomeAssistant) -> None: + mock_discovered.return_value = [_discovered_info()] + + with ( + patch( + "homeassistant.components.teslemetry.config_flow.async_discovered_service_info", + mock_discovered, + ), + patch( + "homeassistant.components.teslemetry.config_flow.async_request_active_scan", + AsyncMock(side_effect=_active_scan), + ) as mock_active_scan, + patch( + "homeassistant.components.teslemetry.config_flow.async_get_ble_parent", + return_value=_mock_ble_parent(vehicle), + ), + patch.object(hass.config_entries, "async_schedule_reload"), + ): + result = await _start_pairing_at_scan(hass, entry) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + await hass.async_block_till_done() + + mock_active_scan.assert_awaited_once() + assert result["type"] is FlowResultType.CREATE_ENTRY + subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE) + assert len(subentries) == 1 + assert subentries[0].data == {CONF_VIN: VIN, CONF_ADDRESS: ADDRESS} + vehicle.connect.assert_awaited_once() + + +async def test_subentry_add_flow_keeps_device_on_parent( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """The add flow pairs an account vehicle without moving its device off the parent entry.""" + entry = mock_config_entry() + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # No Bluetooth subentry exists until the user adds one. + assert not entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE) + existing_device = device_registry.async_get_device_by_identifier( + (DOMAIN, VIN), entry.entry_id + ) + assert existing_device is not None + # The device and its entities start on the parent entry, owned by no subentry. + assert existing_device.config_subentry_id is None + vehicle_entities = er.async_entries_for_device( + entity_registry, existing_device.id, include_disabled_entities=True + ) + assert vehicle_entities + assert all(entity.config_subentry_id is None for entity in vehicle_entities) + + vehicle = _mock_vehicle(on_whitelist=True) + + result = await hass.config_entries.subentries.async_init( + (entry.entry_id, SUBENTRY_TYPE_VEHICLE), + context={"source": "user"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # async_schedule_reload is left unpatched so the real reload runs here with the + # committed BLE address; keep the setup-time Bluetooth mocks active so it neither + # writes the vehicle key file nor opens a real connection. + with ( + patch( + "homeassistant.components.teslemetry.config_flow.async_discovered_service_info", + return_value=[_discovered_info()], + ), + patch( + "homeassistant.components.teslemetry.config_flow.async_get_ble_parent", + return_value=_mock_ble_parent(vehicle), + ), + patch( + "homeassistant.components.teslemetry.async_ble_device_from_address", + return_value=MagicMock(), + ), + patch( + "homeassistant.components.teslemetry.helpers.TeslaBluetooth" + ) as mock_parent, + ): + mock_parent.return_value.get_private_key = AsyncMock() + mock_parent.return_value.vehicles.createBluetooth.return_value = MagicMock() + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {CONF_VIN: VIN} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "scan" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + # The subentry commits after the flow step returns; its change listener + # then schedules the reload, which runs to completion here. + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + + subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE) + assert len(subentries) == 1 + subentry = subentries[0] + assert subentry.unique_id == VIN + assert subentry.data == {CONF_VIN: VIN, CONF_ADDRESS: ADDRESS} + + # The real reload picked up the stored address: the reloaded vehicle now + # routes over Bluetooth instead of staying cloud-only. + assert isinstance(entry.runtime_data.vehicles[0].api, VehicleRouter) + + # The pairing reuses the vehicle's existing device, never a duplicate. + bound_device = device_registry.async_get_device_by_identifier( + (DOMAIN, VIN), entry.entry_id + ) + assert bound_device is not None + # The same device ID is kept and it stays on the parent entry, not the + # subentry, so removing the pairing never deletes the cloud vehicle. + assert bound_device.id == existing_device.id + assert bound_device.config_subentry_id is None + + # The vehicle entities keep their unique IDs and stay on the parent entry. + bound_entities = er.async_entries_for_device( + entity_registry, bound_device.id, include_disabled_entities=True + ) + assert {entity.unique_id for entity in bound_entities} == { + entity.unique_id for entity in vehicle_entities + } + assert all(entity.config_subentry_id is None for entity in bound_entities) + + +async def test_subentry_add_flow_no_available_vehicles(hass: HomeAssistant) -> None: + """The add flow aborts when every account vehicle is already added.""" + entry = await _setup_paired_entry(hass) + + result = await hass.config_entries.subentries.async_init( + (entry.entry_id, SUBENTRY_TYPE_VEHICLE), + context={"source": "user"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_vehicles" + + +async def test_subentry_add_flow_entry_not_loaded(hass: HomeAssistant) -> None: + """The add flow aborts when the parent entry is not loaded.""" + entry = mock_config_entry() + entry.add_to_hass(hass) + assert entry.state is ConfigEntryState.NOT_LOADED + + result = await hass.config_entries.subentries.async_init( + (entry.entry_id, SUBENTRY_TYPE_VEHICLE), + context={"source": "user"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "entry_not_loaded" + + SITE_ID = 123456 WALL_CONNECTOR_SITE_ID = 555555 HOST = "192.168.91.1" @@ -1458,18 +2183,18 @@ async def test_pair_step_second_lookup_errors( ValueError, id="key_read_valueerror", ), - # An encrypted key PEM surfaces as TypeError from the cryptography loader. + # An encrypted existing key file surfaces as PrivateKeyError("encrypted"). pytest.param( "homeassistant.components.teslemetry.config_flow.Teslemetry.get_rsa_private_key", - TypeError, - id="key_fetch_typeerror", + PrivateKeyError("encrypted", "Private key file is encrypted"), + id="key_fetch_private_key_error", ), ], ) async def test_rsa_key_load_failure_aborts( hass: HomeAssistant, patch_target: str, - error: type[Exception], + error: type[Exception] | Exception, ) -> None: """A failure loading the integration's RSA key aborts site preparation.""" entry = await _setup_account_no_subentry(hass) diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index febc99f0cc5d7a..f8a6fa58f291fd 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -1,5 +1,8 @@ """Test the Teslemetry init.""" +import asyncio +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from copy import deepcopy import logging import time @@ -8,23 +11,28 @@ from aiohttp import ClientResponseError from aiopowerwall import PowerwallError +from bleak.exc import BleakError from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.exceptions import ( + BluetoothCommandFailed, + BluetoothTransportError, + BluetoothUnconfirmedCommand, Forbidden, InsufficientCredits, InvalidResponse, InvalidToken, LoginRequired, + PrivateKeyError, RateLimited, SubscriptionRequired, TeslaFleetError, ) -from tesla_fleet_api.tesla import EnergySiteRouter -from tesla_fleet_api.teslemetry import EnergySite +from tesla_fleet_api.tesla import EnergySiteRouter, VehicleRouter +from tesla_fleet_api.teslemetry import EnergySite, Vehicle from teslemetry_stream import TeslemetryStreamAuthenticationError from homeassistant.components.teslemetry import ( @@ -35,8 +43,10 @@ from homeassistant.components.teslemetry.const import ( CLIENT_ID, CONF_SITE_ID, + CONF_VIN, DOMAIN, SUBENTRY_TYPE_ENERGY_SITE, + SUBENTRY_TYPE_VEHICLE, ) # Coordinator constants @@ -46,6 +56,7 @@ METADATA_INTERVAL, VEHICLE_INTERVAL, ) +from homeassistant.components.teslemetry.helpers import async_get_ble_parent from homeassistant.components.teslemetry.models import TeslemetryData from homeassistant.components.teslemetry.oauth import TeslemetryImplementation from homeassistant.config_entries import ( @@ -54,6 +65,7 @@ ConfigSubentryData, ) from homeassistant.const import ( + CONF_ADDRESS, CONF_HOST, CONF_PASSWORD, STATE_OFF, @@ -882,11 +894,7 @@ async def test_vehicle_polling_stops_when_all_entities_disabled( keep_one_enabled: bool, expected_polled: bool, ) -> None: - """Test the vehicle coordinator stops polling once every entity is disabled. - - With no listeners left, core unschedules the coordinator so the charged - vehicle_data poll stops entirely; a single enabled entity keeps it running. - """ + """Test the vehicle coordinator stops polling once every entity is disabled.""" vin = "LRW3F7EK4NC700000" entry = await setup_platform(hass, [Platform.SENSOR]) @@ -1221,13 +1229,7 @@ def _oauth_session(hass: HomeAssistant, entry: MockConfigEntry) -> OAuth2Session async def test_get_access_token_dead_token_during_setup_triggers_auth_failed( hass: HomeAssistant, ) -> None: - """A dead/revoked refresh token during setup must raise ConfigEntryAuthFailed. - - OAuth servers commonly report a dead refresh token with a non-401 status - (e.g. 400 invalid_grant). Only recognizing status 401 let this fall - through to ConfigEntryNotReady, which retries setup indefinitely without - ever prompting the user to reauthenticate. - """ + """A dead/revoked refresh token during setup must raise ConfigEntryAuthFailed.""" mock_entry = mock_config_entry() mock_entry.add_to_hass(hass) mock_entry.mock_state(hass, ConfigEntryState.SETUP_IN_PROGRESS) @@ -1271,10 +1273,7 @@ async def test_get_access_token_rate_limited_during_setup_is_not_fatal( async def test_get_access_token_dead_token_after_setup_starts_reauth( hass: HomeAssistant, ) -> None: - """Test a token dying after setup (re)starts reauth without tearing down. - - The coordinator handles the rest once the exception is re-raised. - """ + """Test a token dying after setup (re)starts reauth without tearing down.""" mock_entry = mock_config_entry() mock_entry.add_to_hass(hass) mock_entry.mock_state(hass, ConfigEntryState.LOADED) @@ -1428,12 +1427,7 @@ async def test_energy_site_cloud_without_powerwall(hass: HomeAssistant) -> None: async def test_energy_site_subentry_without_credentials_uses_cloud( hass: HomeAssistant, ) -> None: - """A subentry that exists but is not yet paired resolves to the cloud API. - - A site whose subentry was created but has no gateway host/password stored - keeps that subentry_id (so it stays opted in) while falling back to the - plain cloud API rather than building an EnergySiteRouter. - """ + """A subentry that exists but is not yet paired resolves to the cloud API.""" entry = mock_config_entry() paired = MockConfigEntry( domain=entry.domain, @@ -1480,6 +1474,10 @@ async def test_no_subentry_created_at_setup(hass: HomeAssistant) -> None: pytest.param(OSError("disk gone"), id="os_error"), pytest.param(ValueError("bad key"), id="value_error"), pytest.param(PowerwallError("client boom"), id="powerwall_error"), + pytest.param( + PrivateKeyError("malformed", "Not a valid PEM private key"), + id="private_key_error", + ), ], ) async def test_local_control_failure_falls_back_to_cloud( @@ -1487,12 +1485,7 @@ async def test_local_control_failure_falls_back_to_cloud( local_error: Exception, caplog: pytest.LogCaptureFixture, ) -> None: - """A failure resolving a paired site's local gateway falls back to cloud. - - Local control is opt-in per site, so one site's bad local config must leave - the entry loaded with cloud functionality intact rather than tearing the - whole integration down. - """ + """A failure resolving a paired site's local gateway falls back to cloud.""" entry = _entry_with_powerwall() entry.add_to_hass(hass) @@ -1519,20 +1512,29 @@ async def test_local_control_failure_falls_back_to_cloud( ) -async def test_local_control_encrypted_key_falls_back_to_cloud( +@pytest.mark.parametrize( + "rsa_key_error", + [ + # PrivateKeyError is the wrapped existing-key-file failure shape. + pytest.param( + PrivateKeyError("encrypted", "Private key file is encrypted"), + id="private_key_error", + ), + ], +) +async def test_local_control_key_load_failure_falls_back_to_cloud( hass: HomeAssistant, + rsa_key_error: Exception, caplog: pytest.LogCaptureFixture, ) -> None: - """Fall back to cloud control when RSA key loading reports an encrypted PEM.""" + """Fall back to cloud control when RSA key loading fails on an existing file.""" entry = _entry_with_powerwall() entry.add_to_hass(hass) with ( patch( "homeassistant.components.teslemetry.Teslemetry.get_rsa_private_key", - side_effect=TypeError( - "Password was not given but private key is encrypted" - ), + side_effect=rsa_key_error, ), patch("homeassistant.components.teslemetry.PLATFORMS", []), caplog.at_level(logging.WARNING), @@ -1551,13 +1553,7 @@ async def test_local_control_encrypted_key_falls_back_to_cloud( async def test_local_control_unexpected_typeerror_is_not_swallowed( hass: HomeAssistant, ) -> None: - """A TypeError outside the key load is a real bug and must not degrade silently. - - ``_LOCAL_CONTROL_ERRORS`` deliberately excludes TypeError: only the key - loader's encrypted-PEM TypeError is converted to ValueError. A TypeError - from anywhere else in the resolve path (here, client construction) must - fail setup rather than silently falling back to cloud control. - """ + """A TypeError outside the key load is a real bug and must not degrade silently.""" entry = _entry_with_powerwall() entry.add_to_hass(hass) @@ -1763,11 +1759,7 @@ async def test_stale_cleanup_preserves_pairing_without_energy_scope( async def test_update_listener_ignores_token_refresh(hass: HomeAssistant) -> None: - """An entry update that only changes token data must not reload the entry. - - OAuth token refreshes call async_update_entry with new token data on every - expiry; reloading on those would needlessly drop the stream and re-fetch. - """ + """An entry update that only changes token data must not reload the entry.""" entry = mock_config_entry() entry.add_to_hass(hass) with patch("homeassistant.components.teslemetry.PLATFORMS", []): @@ -1921,3 +1913,584 @@ async def test_energy_stream_disconnect_marks_unavailable_and_recovers( for flow in hass.config_entries.flow.async_progress() if flow["handler"] == DOMAIN ] + + +VIN = "LRW3F7EK4NC700000" +ADDRESS = "AA:BB:CC:DD:EE:FF" +CLOUD_RESULT = {"response": {"result": True, "reason": "cloud"}} +BLE_RESULT = {"response": {"result": True, "reason": "bluetooth"}} + + +def _entry_with_ble() -> MockConfigEntry: + """Return a config entry whose vehicle subentry is already BLE-paired.""" + entry = mock_config_entry() + return MockConfigEntry( + domain=entry.domain, + version=entry.version, + minor_version=entry.minor_version, + unique_id=entry.unique_id, + data=dict(entry.data), + subentries_data=[ + ConfigSubentryData( + subentry_type=SUBENTRY_TYPE_VEHICLE, + unique_id=VIN, + title="Test", + data={CONF_VIN: VIN, CONF_ADDRESS: ADDRESS}, + ) + ], + ) + + +async def test_vehicle_router_with_bluetooth(hass: HomeAssistant) -> None: + """A BLE-paired vehicle wraps its cloud API in a VehicleRouter.""" + entry = _entry_with_ble() + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.teslemetry.async_ble_device_from_address", + return_value=MagicMock(), + ), + patch( + "homeassistant.components.teslemetry.helpers.TeslaBluetooth" + ) as mock_parent, + patch("homeassistant.components.teslemetry.PLATFORMS", []), + ): + mock_parent.return_value.get_private_key = AsyncMock() + mock_parent.return_value.vehicles.createBluetooth.return_value = MagicMock() + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + vehicle = entry.runtime_data.vehicles[0] + assert isinstance(vehicle.api, VehicleRouter) + # Avoid replaying ambiguous commands or keeping the vehicle awake. + mock_parent.return_value.vehicles.createBluetooth.assert_called_once_with( + VIN, + confirmation="verify", + raise_unconfirmed=False, + keepalive_interval=None, + ) + + +async def test_vehicle_cloud_without_bluetooth(hass: HomeAssistant) -> None: + """A vehicle without a paired address keeps the plain cloud API.""" + entry = mock_config_entry() + entry.add_to_hass(hass) + + with patch("homeassistant.components.teslemetry.PLATFORMS", []): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + vehicle = entry.runtime_data.vehicles[0] + assert isinstance(vehicle.api, Vehicle) + assert not isinstance(vehicle.api, VehicleRouter) + + +@pytest.mark.parametrize( + "key_error", + [ + pytest.param(OSError("disk gone"), id="os_error"), + pytest.param(ValueError("bad key"), id="value_error"), + # A raw TypeError only escapes the key create/generation path now. + pytest.param( + TypeError("unexpected keyword argument"), + id="typeerror", + ), + # PrivateKeyError is the wrapped existing-key-file shape; it must degrade too. + pytest.param( + PrivateKeyError("unreadable", "Could not read private key file"), + id="private_key_unreadable", + ), + pytest.param( + PrivateKeyError("malformed", "Not a valid PEM private key"), + id="private_key_malformed", + ), + pytest.param( + PrivateKeyError("encrypted", "Private key file is encrypted"), + id="private_key_encrypted", + ), + pytest.param( + PrivateKeyError("wrong_type", "Not an EllipticCurvePrivateKey"), + id="private_key_wrong_type", + ), + ], +) +async def test_vehicle_bluetooth_key_load_falls_back_to_cloud( + hass: HomeAssistant, + key_error: Exception, + caplog: pytest.LogCaptureFixture, +) -> None: + """A vehicle whose Bluetooth key fails to load degrades to cloud control.""" + entry = _entry_with_ble() + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.teslemetry.async_ble_device_from_address", + return_value=MagicMock(), + ), + patch( + "homeassistant.components.teslemetry.helpers.TeslaBluetooth" + ) as mock_parent, + patch("homeassistant.components.teslemetry.PLATFORMS", []), + caplog.at_level(logging.WARNING), + ): + mock_parent.return_value.get_private_key = AsyncMock(side_effect=key_error) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + vehicle = entry.runtime_data.vehicles[0] + assert isinstance(vehicle.api, Vehicle) + assert not isinstance(vehicle.api, VehicleRouter) + # The rest of the account is unaffected: the energy site still loads. + assert len(entry.runtime_data.energysites) == 1 + assert "falling back to cloud control" in caplog.text + assert any( + record.levelname == "WARNING" and VIN in record.message + for record in caplog.records + ) + + +async def test_vehicle_bluetooth_key_load_recovers_on_reload( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """A vehicle degraded to cloud by a key-load failure regains BLE control on reload.""" + entry = _entry_with_ble() + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.teslemetry.async_ble_device_from_address", + return_value=MagicMock(), + ), + patch( + "homeassistant.components.teslemetry.helpers.TeslaBluetooth" + ) as mock_parent, + patch("homeassistant.components.teslemetry.PLATFORMS", []), + caplog.at_level(logging.WARNING), + ): + mock_parent.return_value.get_private_key = AsyncMock( + side_effect=OSError("disk gone") + ) + mock_parent.return_value.vehicles.createBluetooth.return_value = MagicMock() + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert not isinstance(entry.runtime_data.vehicles[0].api, VehicleRouter) + assert "falling back to cloud control" in caplog.text + + # The key becomes readable again; a reload must restore local Bluetooth control. + mock_parent.return_value.get_private_key = AsyncMock() + caplog.clear() + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert isinstance(entry.runtime_data.vehicles[0].api, VehicleRouter) + assert "falling back to cloud control" not in caplog.text + + +@asynccontextmanager +async def _paired_entry( + hass: HomeAssistant, ble_lookup: MagicMock +) -> AsyncIterator[tuple[VehicleRouter, AsyncMock, AsyncMock]]: + """Set up a BLE-paired entry, yielding its router and both backends.""" + entry = _entry_with_ble() + entry.add_to_hass(hass) + bluetooth_vehicle = AsyncMock() + bluetooth_vehicle.set_device = MagicMock() + + with ( + patch( + "homeassistant.components.teslemetry.async_ble_device_from_address", + ble_lookup, + ), + patch( + "homeassistant.components.teslemetry.helpers.TeslaBluetooth" + ) as mock_parent, + patch("homeassistant.components.teslemetry.PLATFORMS", []), + ): + mock_parent.return_value.get_private_key = AsyncMock() + mock_parent.return_value.vehicles.createBluetooth.return_value = ( + bluetooth_vehicle + ) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + router = entry.runtime_data.vehicles[0].api + cloud = AsyncMock(return_value=CLOUD_RESULT) + router.secondary.flash_lights = cloud + yield router, bluetooth_vehicle, cloud + + +async def test_vehicle_bluetooth_out_of_range(hass: HomeAssistant) -> None: + """A paired vehicle out of range still gets a router, and skips Bluetooth.""" + async with _paired_entry(hass, MagicMock(return_value=None)) as ( + router, + bluetooth_vehicle, + cloud, + ): + assert isinstance(router, VehicleRouter) + + assert await router.flash_lights() == CLOUD_RESULT + + cloud.assert_awaited_once() + bluetooth_vehicle.flash_lights.assert_not_called() + + +async def test_vehicle_router_resumes_bluetooth_when_vehicle_returns( + hass: HomeAssistant, +) -> None: + """A vehicle away at setup routes locally again once it comes home.""" + ble_lookup = MagicMock(return_value=None) + + async with _paired_entry(hass, ble_lookup) as (router, bluetooth_vehicle, cloud): + bluetooth_vehicle.flash_lights.return_value = BLE_RESULT + + assert await router.flash_lights() == CLOUD_RESULT + bluetooth_vehicle.flash_lights.assert_not_called() + + ble_lookup.return_value = MagicMock() + + assert await router.flash_lights() == BLE_RESULT + bluetooth_vehicle.flash_lights.assert_awaited_once() + cloud.assert_awaited_once() + + +async def test_vehicle_router_falls_back_when_vehicle_leaves( + hass: HomeAssistant, +) -> None: + """A vehicle in range at setup routes to cloud once it drives away.""" + ble_lookup = MagicMock(return_value=MagicMock()) + + async with _paired_entry(hass, ble_lookup) as (router, bluetooth_vehicle, cloud): + bluetooth_vehicle.flash_lights.return_value = BLE_RESULT + + assert await router.flash_lights() == BLE_RESULT + cloud.assert_not_called() + + ble_lookup.return_value = None + + assert await router.flash_lights() == CLOUD_RESULT + cloud.assert_awaited_once() + bluetooth_vehicle.flash_lights.assert_awaited_once() + + +async def test_vehicle_router_refreshes_device_handle(hass: HomeAssistant) -> None: + """Each command refreshes the BLE handle from the cache before connecting.""" + first_device = MagicMock() + second_device = MagicMock() + ble_lookup = MagicMock(return_value=first_device) + + async with _paired_entry(hass, ble_lookup) as (router, bluetooth_vehicle, _cloud): + await router.flash_lights() + bluetooth_vehicle.set_device.assert_called_once_with(first_device) + + ble_lookup.return_value = second_device + await router.flash_lights() + + bluetooth_vehicle.set_device.assert_called_with(second_device) + + +async def test_vehicle_router_fails_over_on_stale_cache_hit( + hass: HomeAssistant, +) -> None: + """A cache entry outliving the vehicle costs one failed attempt, not a failure.""" + async with _paired_entry(hass, MagicMock(return_value=MagicMock())) as ( + router, + bluetooth_vehicle, + cloud, + ): + bluetooth_vehicle.flash_lights.side_effect = BluetoothTransportError() + + assert await router.flash_lights() == CLOUD_RESULT + + bluetooth_vehicle.flash_lights.assert_awaited_once() + cloud.assert_awaited_once() + + +async def test_vehicle_paired_but_never_seen(hass: HomeAssistant) -> None: + """A paired vehicle never seen by Bluetooth is built without a device handle.""" + entry = _entry_with_ble() + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.teslemetry.async_ble_device_from_address", + MagicMock(return_value=None), + ), + patch( + "homeassistant.components.teslemetry.helpers.TeslaBluetooth" + ) as mock_parent, + patch("homeassistant.components.teslemetry.PLATFORMS", []), + ): + mock_parent.return_value.get_private_key = AsyncMock() + mock_parent.return_value.vehicles.createBluetooth.return_value = AsyncMock() + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + "device" + not in mock_parent.return_value.vehicles.createBluetooth.call_args.kwargs + ) + + +@pytest.mark.parametrize( + "disconnect_error", + [None, BleakError("boom")], + ids=["clean", "error_swallowed"], +) +async def test_unload_disconnects_bluetooth( + hass: HomeAssistant, disconnect_error: Exception | None +) -> None: + """Unloading a routed entry disconnects its Bluetooth backend, errors and all.""" + entry = _entry_with_ble() + entry.add_to_hass(hass) + bluetooth_vehicle = AsyncMock() + bluetooth_vehicle.disconnect = AsyncMock(side_effect=disconnect_error) + + with ( + patch( + "homeassistant.components.teslemetry.async_ble_device_from_address", + return_value=MagicMock(), + ), + patch( + "homeassistant.components.teslemetry.helpers.TeslaBluetooth" + ) as mock_parent, + patch("homeassistant.components.teslemetry.PLATFORMS", []), + ): + mock_parent.return_value.get_private_key = AsyncMock() + mock_parent.return_value.vehicles.createBluetooth.return_value = ( + bluetooth_vehicle + ) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert isinstance(entry.runtime_data.vehicles[0].api, VehicleRouter) + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + bluetooth_vehicle.disconnect.assert_awaited_once() + + +async def test_unload_never_connected_bluetooth(hass: HomeAssistant) -> None: + """Unloading a paired vehicle that was never in range does not raise.""" + entry = _entry_with_ble() + entry.add_to_hass(hass) + bluetooth_vehicle = AsyncMock() + + with ( + patch( + "homeassistant.components.teslemetry.async_ble_device_from_address", + return_value=None, + ), + patch( + "homeassistant.components.teslemetry.helpers.TeslaBluetooth" + ) as mock_parent, + patch("homeassistant.components.teslemetry.PLATFORMS", []), + ): + mock_parent.return_value.get_private_key = AsyncMock() + mock_parent.return_value.vehicles.createBluetooth.return_value = ( + bluetooth_vehicle + ) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + bluetooth_vehicle.disconnect.assert_awaited_once() + + +async def test_ble_parent_shared_and_cached(hass: HomeAssistant) -> None: + """The BLE parent (holding the private key) is created once and reused.""" + with patch( + "homeassistant.components.teslemetry.helpers.TeslaBluetooth" + ) as mock_parent: + mock_parent.return_value.get_private_key = AsyncMock() + first = await async_get_ble_parent(hass) + second = await async_get_ble_parent(hass) + + assert first is second + mock_parent.assert_called_once() + mock_parent.return_value.get_private_key.assert_awaited_once() + + +async def test_ble_parent_concurrent_first_init(hass: HomeAssistant) -> None: + """Concurrent first-time callers still create and load the key exactly once.""" + + async def _get_private_key(path: str) -> None: + await asyncio.sleep(0) + + with patch( + "homeassistant.components.teslemetry.helpers.TeslaBluetooth" + ) as mock_parent: + mock_parent.return_value.get_private_key = AsyncMock( + side_effect=_get_private_key + ) + parents = await asyncio.gather(*(async_get_ble_parent(hass) for _ in range(5))) + + assert all(parent is parents[0] for parent in parents) + mock_parent.assert_called_once() + mock_parent.return_value.get_private_key.assert_awaited_once() + + +async def test_router_does_not_fail_over_on_unconfirmed() -> None: + """An unconfirmed BLE command is never replayed on the cloud backend.""" + bluetooth = AsyncMock() + bluetooth.actuate_trunk = AsyncMock(side_effect=BluetoothUnconfirmedCommand()) + cloud = AsyncMock() + cloud.actuate_trunk = AsyncMock(return_value={"response": {"result": True}}) + router = VehicleRouter(bluetooth, cloud) + + with pytest.raises(BluetoothUnconfirmedCommand): + await router.actuate_trunk() + + cloud.actuate_trunk.assert_not_called() + + +async def test_router_fails_over_on_command_failed() -> None: + """A command proven not to have applied over BLE fails over to the cloud.""" + bluetooth = AsyncMock() + bluetooth.actuate_trunk = AsyncMock(side_effect=BluetoothCommandFailed()) + cloud = AsyncMock() + cloud.actuate_trunk = AsyncMock(return_value={"response": {"result": True}}) + router = VehicleRouter(bluetooth, cloud) + + result = await router.actuate_trunk() + + assert result == {"response": {"result": True}} + bluetooth.actuate_trunk.assert_awaited_once() + cloud.actuate_trunk.assert_awaited_once() + + +async def _setup_paired_entry(hass: HomeAssistant) -> MockConfigEntry: + """Set up an entry whose only account vehicle is already BLE-paired.""" + entry = _entry_with_ble() + entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.teslemetry.async_ble_device_from_address", + return_value=None, + ), + patch( + "homeassistant.components.teslemetry.helpers.TeslaBluetooth" + ) as mock_parent, + patch("homeassistant.components.teslemetry.PLATFORMS", []), + ): + mock_parent.return_value.get_private_key = AsyncMock() + mock_parent.return_value.vehicles.createBluetooth.return_value = AsyncMock() + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry + + +async def test_subentry_removal_reloads(hass: HomeAssistant) -> None: + """Removing a vehicle subentry reloads once; later updates do not re-schedule.""" + entry = await _setup_paired_entry(hass) + subentry_id = entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)[0].subentry_id + + with patch.object(hass.config_entries, "async_schedule_reload") as mock_reload: + assert hass.config_entries.async_remove_subentry(entry, subentry_id) + await hass.async_block_till_done() + + # A later entry update before the reload runs must not re-schedule it. + hass.config_entries.async_update_entry( + entry, data={**entry.data, "marker": True} + ) + await hass.async_block_till_done() + + mock_reload.assert_called_once_with(entry.entry_id) + + +async def test_subentry_removal_keeps_vehicle_device_and_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Removing a vehicle subentry leaves the cloud vehicle device and entities intact.""" + entry = _entry_with_ble() + entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.teslemetry.async_ble_device_from_address", + return_value=None, + ), + patch( + "homeassistant.components.teslemetry.helpers.TeslaBluetooth" + ) as mock_parent, + ): + mock_parent.return_value.get_private_key = AsyncMock() + mock_parent.return_value.vehicles.createBluetooth.return_value = AsyncMock() + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + subentry_id = entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)[0].subentry_id + + device = device_registry.async_get_device_by_identifier( + (DOMAIN, VIN), entry.entry_id + ) + assert device is not None + # The device and its entities belong to the parent entry, never the subentry. + assert device.config_subentry_id is None + entities_before = er.async_entries_for_device( + entity_registry, device.id, include_disabled_entities=True + ) + assert entities_before + assert all(entity.config_subentry_id is None for entity in entities_before) + unique_ids_before = {entity.unique_id for entity in entities_before} + + # Patch the reload so only the subentry removal itself is exercised here. + with patch.object(hass.config_entries, "async_schedule_reload"): + assert hass.config_entries.async_remove_subentry(entry, subentry_id) + await hass.async_block_till_done() + + # The vehicle device and every entity on it survive the removal. + device_after = device_registry.async_get_device_by_identifier( + (DOMAIN, VIN), entry.entry_id + ) + assert device_after is not None + assert device_after.id == device.id + entities_after = er.async_entries_for_device( + entity_registry, device_after.id, include_disabled_entities=True + ) + assert {entity.unique_id for entity in entities_after} == unique_ids_before + + +async def test_no_subentry_auto_created_at_setup(hass: HomeAssistant) -> None: + """Setup never auto-creates a Bluetooth subentry for account vehicles.""" + entry = mock_config_entry() + entry.add_to_hass(hass) + + with patch("homeassistant.components.teslemetry.PLATFORMS", []): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert not entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE) + + +async def test_user_subentry_persists_across_reload(hass: HomeAssistant) -> None: + """A paired vehicle subentry survives a reload even if its vehicle leaves the account.""" + entry = await _setup_paired_entry(hass) + subentry_id = entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)[0].subentry_id + + # The vehicle drops off the account, so setup builds no vehicle for it, yet + # the user-added subentry (with its stored credentials) must not be removed. + with ( + patch( + "tesla_fleet_api.teslemetry.Teslemetry.products", + return_value={"response": []}, + ), + patch("homeassistant.components.teslemetry.PLATFORMS", []), + ): + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE) + assert len(subentries) == 1 + assert subentries[0].subentry_id == subentry_id + assert subentries[0].data == {CONF_VIN: VIN, CONF_ADDRESS: ADDRESS} diff --git a/tests/components/test_conftest.py b/tests/components/test_conftest.py new file mode 100644 index 00000000000000..dc91886328b623 --- /dev/null +++ b/tests/components/test_conftest.py @@ -0,0 +1,44 @@ +"""Tests for component test fixtures.""" + +from collections.abc import Callable + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import Unauthorized, UnknownUser + +from . import conftest as components_conftest + +from tests.common import QualityScaleStatus + + +@pytest.mark.parametrize( + "exception_type", + [ + pytest.param(Unauthorized, id="unauthorized"), + pytest.param(UnknownUser, id="unknown-user"), + ], +) +async def test_authorization_errors_do_not_require_integration_translations( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, + exception_type: Callable[[], Unauthorized], +) -> None: + """Test core authorization errors do not require integration translations.""" + monkeypatch.setattr( + components_conftest, + "_get_request_quality_scale", + lambda *_: QualityScaleStatus.DONE, + ) + translation_errors: dict[str, str] = {} + + await components_conftest._check_exception_translation( + hass, + exception_type(), + translation_errors, + request, + set(), + ) + + assert not translation_errors diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index f462d23032e974..1f1f0e54528a9f 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -25,10 +25,7 @@ PLATFORMS, PUBLIC_ONLY_PLATFORMS, ) -from homeassistant.components.unifiprotect.data import ( - ProtectData, - async_ufp_instance_for_config_entry_ids, -) +from homeassistant.components.unifiprotect.data import ProtectData from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry, ConfigEntryState from homeassistant.const import CONF_API_KEY, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant @@ -474,61 +471,6 @@ async def test_remove_config_entry_device_rejects_child_device( assert device_registry.async_get(child_device.id) -@pytest.mark.parametrize( - ("mock_entries", "expected_result"), - [ - pytest.param( - [ - MockConfigEntry( - domain=DOMAIN, - entry_id="1", - data={}, - ), - MockConfigEntry( - domain="other_domain", - entry_id="2", - data={}, - ), - ], - "mock_api_instance_1", - id="one_matching_domain", - ), - pytest.param( - [ - MockConfigEntry( - domain="other_domain", - entry_id="1", - data={}, - ), - MockConfigEntry( - domain="other_domain", - entry_id="2", - data={}, - ), - ], - None, - id="no_matching_domain", - ), - ], -) -async def test_async_ufp_instance_for_config_entry_ids( - hass: HomeAssistant, - mock_entries: list[MockConfigEntry], - expected_result: str | None, -) -> None: - """Test async_ufp_instance_for_config_entry_ids with various configs.""" - - for index, entry in enumerate(mock_entries): - entry.add_to_hass(hass) - entry.runtime_data = Mock(api=f"mock_api_instance_{index + 1}") - - entry_ids = {entry.entry_id for entry in mock_entries} - - result = async_ufp_instance_for_config_entry_ids(hass, entry_ids) - - assert result == expected_result - - @pytest.mark.parametrize("mock_user_can_write_nvr", [True], indirect=True) async def test_setup_creates_api_key_when_missing( hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock diff --git a/tests/components/unifiprotect/test_services.py b/tests/components/unifiprotect/test_services.py index 0fba21282a4019..ef7fb8d62a0248 100644 --- a/tests/components/unifiprotect/test_services.py +++ b/tests/components/unifiprotect/test_services.py @@ -30,7 +30,7 @@ ) from homeassistant.config_entries import ConfigEntryDisabler from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_NAME -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -38,6 +38,8 @@ from .conftest import UNIFI_MAC from .utils import MockUFPFixture, init_entry +from tests.common import MockConfigEntry + @pytest.fixture(name="device") async def device_fixture( @@ -69,12 +71,13 @@ async def test_global_service_bad_device( ) -> None: """Test global service, invalid device ID.""" + await init_entry(hass, ufp, []) nvr = ufp.api.bootstrap.nvr with patch_ufp_method( nvr, "add_custom_doorbell_message", new_callable=AsyncMock ) as mock_method: - with pytest.raises(HomeAssistantError): + with pytest.raises(ServiceValidationError) as error: await hass.services.async_call( DOMAIN, SERVICE_ADD_DOORBELL_TEXT, @@ -83,6 +86,37 @@ async def test_global_service_bad_device( ) assert not mock_method.called + assert error.value.translation_domain == HOMEASSISTANT_DOMAIN + assert error.value.translation_key == "service_device_not_found" + + +async def test_global_service_device_from_other_integration( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + ufp: MockUFPFixture, +) -> None: + """Test a device not owned by a UniFi Protect config entry is rejected.""" + + await init_entry(hass, ufp, []) + other_entry = MockConfigEntry(domain="other") + other_entry.add_to_hass(hass) + other_device = device_registry.async_get_or_create( + config_entry_id=other_entry.entry_id, + identifiers={("other", "other-device")}, + name="Other device", + ) + + with pytest.raises(ServiceValidationError) as error: + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_DOORBELL_TEXT, + {ATTR_DEVICE_ID: other_device.id, ATTR_MESSAGE: "Test Message"}, + blocking=True, + ) + + assert error.value.translation_domain == HOMEASSISTANT_DOMAIN + assert error.value.translation_key == "service_device_wrong_domain" + async def test_global_service_exception( hass: HomeAssistant, device: dr.DeviceEntry, ufp: MockUFPFixture @@ -159,7 +193,7 @@ async def test_add_doorbell_text_disabled_config_entry( with patch_ufp_method( nvr, "add_custom_doorbell_message", new_callable=AsyncMock ) as mock_method: - with pytest.raises(HomeAssistantError): + with pytest.raises(ServiceValidationError) as error: await hass.services.async_call( DOMAIN, SERVICE_ADD_DOORBELL_TEXT, @@ -168,6 +202,9 @@ async def test_add_doorbell_text_disabled_config_entry( ) assert not mock_method.called + assert error.value.translation_domain == HOMEASSISTANT_DOMAIN + assert error.value.translation_key == "service_config_entry_not_loaded" + async def test_set_chime_paired_doorbells( hass: HomeAssistant, diff --git a/tests/components/zhong_hong/test_climate.py b/tests/components/zhong_hong/test_climate.py index 4fe7bc4862ea2f..d6f6d72ff0cf4f 100644 --- a/tests/components/zhong_hong/test_climate.py +++ b/tests/components/zhong_hong/test_climate.py @@ -1,5 +1,8 @@ """Test the zhong_hong climate platform.""" +from datetime import timedelta + +from freezegun.api import FrozenDateTimeFactory import pytest from zhong_hong_hvac.protocol import StatusFanMode, StatusOperation, StatusSwitch @@ -22,6 +25,7 @@ ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_OFF, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant @@ -31,7 +35,11 @@ from . import setup_integration from .conftest import DEVICE_ADDRESS, ENTITY_ID, FakeGateway, build_status -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed + +# Spelled out instead of importing SCAN_INTERVAL, so that changing it in the +# integration makes these tests fail instead of following along. +POLL_INTERVAL = timedelta(seconds=60) async def test_entity_registration( @@ -100,6 +108,98 @@ async def test_push_of_an_unknown_operation( assert hass.states.get(ENTITY_ID).state == STATE_UNKNOWN +async def test_scheduled_poll_queries_the_gateway( + hass: HomeAssistant, + mock_gateway: FakeGateway, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the gateway is polled for the pushes that were missed.""" + await setup_integration(hass, mock_config_entry) + + assert mock_gateway.query_all_status_calls == 1 + + freezer.tick(POLL_INTERVAL) + async_fire_time_changed(hass) + # The coordinator refreshes in a background task. + await hass.async_block_till_done(wait_background_tasks=True) + + assert mock_gateway.query_all_status_calls == 2 + + +async def test_a_push_does_not_postpone_the_poll( + hass: HomeAssistant, + mock_gateway: FakeGateway, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a unit that reports often does not hold off the poll. + + The poll is there for the units whose reports went missing, and a gateway + usually has more than one air conditioner on it. Were a push to put the + next poll a full interval out, one unit reporting every few seconds would + be enough to keep the others from ever being asked about. + """ + await setup_integration(hass, mock_config_entry) + + assert mock_gateway.query_all_status_calls == 1 + + freezer.tick(POLL_INTERVAL / 2) + mock_gateway.push_status(build_status()) + await hass.async_block_till_done() + + freezer.tick(POLL_INTERVAL / 2) + async_fire_time_changed(hass) + # The coordinator refreshes in a background task. + await hass.async_block_till_done(wait_background_tasks=True) + + assert mock_gateway.query_all_status_calls == 2 + + +async def test_unavailable_when_the_gateway_connection_drops( + hass: HomeAssistant, + mock_gateway: FakeGateway, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a dropped gateway connection makes the entity unavailable.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(ENTITY_ID).state == STATE_OFF + + mock_gateway.connected = False + freezer.tick(POLL_INTERVAL) + async_fire_time_changed(hass) + # The coordinator refreshes in a background task. + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE + # The gateway is not talked to while the connection is known to be down. + assert mock_gateway.query_all_status_calls == 1 + + +async def test_unavailable_when_the_query_cannot_be_sent( + hass: HomeAssistant, + mock_gateway: FakeGateway, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a poll that cannot be sent makes the entity unavailable. + + A gateway that goes quiet without dropping the connection is caught by + the connection going stale instead, which is what `connected` reports. + """ + await setup_integration(hass, mock_config_entry) + + mock_gateway.query_all_status_result = False + freezer.tick(POLL_INTERVAL) + async_fire_time_changed(hass) + # The coordinator refreshes in a background task. + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE + + async def test_turn_on_success( hass: HomeAssistant, mock_gateway: FakeGateway, mock_config_entry: MockConfigEntry ) -> None: diff --git a/tests/components/zhong_hong/test_init.py b/tests/components/zhong_hong/test_init.py index 300890f4f26b20..f40cd38a2f0999 100644 --- a/tests/components/zhong_hong/test_init.py +++ b/tests/components/zhong_hong/test_init.py @@ -63,7 +63,7 @@ async def test_setup_retries_without_devices( assert mock_gateway.start_listen_calls == 0 -async def test_setup_stops_listener_when_the_first_query_fails( +async def test_setup_stops_listener_when_first_refresh_fails( hass: HomeAssistant, mock_gateway: FakeGateway, mock_config_entry: MockConfigEntry ) -> None: """Test the listener is stopped when the entry fails after it was started. @@ -86,7 +86,8 @@ async def test_setup_asks_for_the_state_of_every_device( """Test the entities have state without waiting for someone to touch a unit. The gateway reports a unit when it changes and not before, so the first - state of each one has to be asked for. + state of each one has to be asked for. That is what the coordinator's + first refresh is doing. """ await setup_integration(hass, mock_config_entry) diff --git a/tests/components/zwave_js/snapshots/test_endpoint_benchmarks.ambr b/tests/components/zwave_js/snapshots/test_endpoint_benchmarks.ambr new file mode 100644 index 00000000000000..f33f7f5eeed74d --- /dev/null +++ b/tests/components/zwave_js/snapshots/test_endpoint_benchmarks.ambr @@ -0,0 +1,1871 @@ +# serializer version: 1 +# name: test_device_tree[fibaro_fgr223] + dict({ + 'child_devices': list([ + ]), + 'node_device': dict({ + 'entities': list([ + dict({ + 'disabled_by': 'None', + 'entity_id': 'binary_sensor.test_location_fgr_223_test_cover_over_current_detected', + 'original_name': 'Over-current detected', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'binary_sensor.test_location_fgr_223_test_cover_system_hardware_failure_with_failure_code', + 'original_name': 'System hardware failure (with failure code)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'button.test_location_fgr_223_test_cover_idle_power_management_over_current_status', + 'original_name': 'Idle Power Management Over-current status', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'button.test_location_fgr_223_test_cover_idle_system_hardware_status', + 'original_name': 'Idle System Hardware status', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'button.test_location_fgr_223_test_cover_ping', + 'original_name': 'Ping', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'button.test_location_fgr_223_test_cover_reset_accumulated_values', + 'original_name': 'Reset accumulated values', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'cover.test_location_fgr_223_test_cover', + 'original_name': '', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'cover.test_location_fgr_223_test_cover_2', + 'original_name': '(2)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.test_location_fgr_223_test_cover_alarm_1_event_state_parameters', + 'original_name': 'Alarm #1: Event/State Parameters', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.test_location_fgr_223_test_cover_alarm_1_notification_status', + 'original_name': 'Alarm #1: Notification Status', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.test_location_fgr_223_test_cover_alarm_1_notification_type', + 'original_name': 'Alarm #1: Notification Type', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.test_location_fgr_223_test_cover_alarm_2_event_state_parameters', + 'original_name': 'Alarm #2: Event/State Parameters', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.test_location_fgr_223_test_cover_alarm_2_notification_status', + 'original_name': 'Alarm #2: Notification Status', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.test_location_fgr_223_test_cover_alarm_2_notification_type', + 'original_name': 'Alarm #2: Notification Type', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.test_location_fgr_223_test_cover_alarm_3_event_state_parameters', + 'original_name': 'Alarm #3: Event/State Parameters', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.test_location_fgr_223_test_cover_alarm_3_notification_status', + 'original_name': 'Alarm #3: Notification Status', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.test_location_fgr_223_test_cover_alarm_3_notification_type', + 'original_name': 'Alarm #3: Notification Type', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.test_location_fgr_223_test_cover_alarm_4_event_state_parameters', + 'original_name': 'Alarm #4: Event/State Parameters', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.test_location_fgr_223_test_cover_alarm_4_notification_status', + 'original_name': 'Alarm #4: Notification Status', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.test_location_fgr_223_test_cover_alarm_4_notification_type', + 'original_name': 'Alarm #4: Notification Type', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.test_location_fgr_223_test_cover_alarm_5_event_state_parameters', + 'original_name': 'Alarm #5: Event/State Parameters', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.test_location_fgr_223_test_cover_alarm_5_notification_status', + 'original_name': 'Alarm #5: Notification Status', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.test_location_fgr_223_test_cover_alarm_5_notification_type', + 'original_name': 'Alarm #5: Notification Type', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.test_location_fgr_223_test_cover_delay_motor_stop', + 'original_name': 'Delay motor stop', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.test_location_fgr_223_test_cover_energy_reports_on_change', + 'original_name': 'Energy reports - on change', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.test_location_fgr_223_test_cover_energy_reports_periodic', + 'original_name': 'Energy reports - periodic', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.test_location_fgr_223_test_cover_motor_operation_detection', + 'original_name': 'Motor operation detection', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.test_location_fgr_223_test_cover_power_reports_on_change', + 'original_name': 'Power reports - on change', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.test_location_fgr_223_test_cover_power_reports_periodic', + 'original_name': 'Power reports - periodic', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.test_location_fgr_223_test_cover_time_of_down_movement', + 'original_name': 'Time of down movement', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.test_location_fgr_223_test_cover_time_of_up_movement', + 'original_name': 'Time of up movement', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.test_location_fgr_223_test_cover_venetian_blind_time_of_full_turn_of_the_slats', + 'original_name': 'Venetian blind - time of full turn of the slats', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.test_location_fgr_223_test_cover_alarm_1_action', + 'original_name': 'Alarm #1: Action', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.test_location_fgr_223_test_cover_alarm_2_action', + 'original_name': 'Alarm #2: Action', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.test_location_fgr_223_test_cover_alarm_3_action', + 'original_name': 'Alarm #3: Action', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.test_location_fgr_223_test_cover_alarm_4_action', + 'original_name': 'Alarm #4: Action', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.test_location_fgr_223_test_cover_alarm_5_action', + 'original_name': 'Alarm #5: Action', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.test_location_fgr_223_test_cover_force_calibration', + 'original_name': 'Force calibration', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.test_location_fgr_223_test_cover_inputs_orientation', + 'original_name': 'Inputs orientation', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'select.test_location_fgr_223_test_cover_local_protection_state', + 'original_name': 'Local protection state', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.test_location_fgr_223_test_cover_measuring_power_consumed_by_the_device_itself', + 'original_name': 'Measuring power consumed by the device itself', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.test_location_fgr_223_test_cover_operating_mode', + 'original_name': 'Operating mode', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.test_location_fgr_223_test_cover_outputs_orientation', + 'original_name': 'Outputs orientation', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'select.test_location_fgr_223_test_cover_rf_protection_state', + 'original_name': 'RF protection state', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.test_location_fgr_223_test_cover_set_slats_back_to_previous_position', + 'original_name': 'Set slats back to previous position', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.test_location_fgr_223_test_cover_switch_type', + 'original_name': 'Switch type', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.test_location_fgr_223_test_cover_alarm_level', + 'original_name': 'Alarm Level', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.test_location_fgr_223_test_cover_alarm_type', + 'original_name': 'Alarm Type', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.test_location_fgr_223_test_cover_commands_dropped_rx', + 'original_name': 'Commands dropped (RX)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.test_location_fgr_223_test_cover_commands_dropped_tx', + 'original_name': 'Commands dropped (TX)', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.test_location_fgr_223_test_cover_electric_consumption_kwh', + 'original_name': 'Electric Consumption [kWh]', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.test_location_fgr_223_test_cover_electric_consumption_w', + 'original_name': 'Electric Consumption [W]', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.test_location_fgr_223_test_cover_last_seen', + 'original_name': 'Last seen', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.test_location_fgr_223_test_cover_node_status', + 'original_name': 'Node status', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.test_location_fgr_223_test_cover_round_trip_time', + 'original_name': 'Round trip time', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.test_location_fgr_223_test_cover_signal_strength', + 'original_name': 'Signal strength', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.test_location_fgr_223_test_cover_successful_commands_rx', + 'original_name': 'Successful commands (RX)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.test_location_fgr_223_test_cover_successful_commands_tx', + 'original_name': 'Successful commands (TX)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.test_location_fgr_223_test_cover_timed_out_responses', + 'original_name': 'Timed out responses', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'switch.test_location_fgr_223_test_cover_s1_scenes_hold_down_release', + 'original_name': 'S1 scenes: Hold down / Release', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'switch.test_location_fgr_223_test_cover_s1_scenes_pressed_1_time', + 'original_name': 'S1 scenes: Pressed 1 time', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'switch.test_location_fgr_223_test_cover_s1_scenes_pressed_2_times', + 'original_name': 'S1 scenes: Pressed 2 times', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'switch.test_location_fgr_223_test_cover_s1_scenes_pressed_3_time', + 'original_name': 'S1 scenes: Pressed 3 time', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'switch.test_location_fgr_223_test_cover_s2_scenes_hold_down_release', + 'original_name': 'S2 scenes: Hold down / Release', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'switch.test_location_fgr_223_test_cover_s2_scenes_pressed_1_time', + 'original_name': 'S2 scenes: Pressed 1 time', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'switch.test_location_fgr_223_test_cover_s2_scenes_pressed_2_times', + 'original_name': 'S2 scenes: Pressed 2 times', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'switch.test_location_fgr_223_test_cover_s2_scenes_pressed_3_time', + 'original_name': 'S2 scenes: Pressed 3 time', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'update.test_location_fgr_223_test_cover_firmware', + 'original_name': 'Firmware', + }), + ]), + 'identifiers': list([ + 'zwave_js:3245146787-10', + 'zwave_js:3245146787-10-271:771:4096', + ]), + 'name': 'fgr 223 test cover', + }), + }) +# --- +# name: test_device_tree[heatit_z_trm2fx] + dict({ + 'child_devices': list([ + ]), + 'node_device': dict({ + 'entities': list([ + dict({ + 'disabled_by': 'None', + 'entity_id': 'button.floor_thermostat_ping', + 'original_name': 'Ping', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'climate.floor_thermostat', + 'original_name': '', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'light.floor_thermostat_basic', + 'original_name': 'Basic', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'light.floor_thermostat_basic_3', + 'original_name': 'Basic (3)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_air_a2_maximum_temperature_limit_ahi', + 'original_name': 'Air (A2) maximum temperature limit (AHi)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_air_a2_minimum_temperature_limit_alo', + 'original_name': 'Air (A2) minimum temperature limit (ALo)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_button_brightness_active_state', + 'original_name': 'Button brightness - active state', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_button_brightness_dimmed_state', + 'original_name': 'Button brightness - dimmed state', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_cooling_setpoint_cool', + 'original_name': 'Cooling setpoint (COOL)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_display_brightness_active_state', + 'original_name': 'Display brightness - active state', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_display_brightness_dimmed_state', + 'original_name': 'Display brightness - dimmed state', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_energy_saving_mode_setpoint_eco', + 'original_name': 'Energy saving mode setpoint (ECO)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_external_sensor_calibration', + 'original_name': 'External sensor calibration', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_floor_maximum_temperature_fhi', + 'original_name': 'Floor maximum temperature (FHi)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_floor_minimum_temperature_limit_flo', + 'original_name': 'Floor minimum temperature limit (FLo)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_floor_sensor_calibration', + 'original_name': 'Floor sensor calibration', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_heating_mode_setpoint_co', + 'original_name': 'Heating mode setpoint (CO)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_meter_report_delta_value', + 'original_name': 'Meter report delta value', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_meter_report_interval', + 'original_name': 'Meter report interval', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_temperature_control_hysteresis_diff_i', + 'original_name': 'Temperature control hysteresis (DIFF I)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_temperature_report_hysteresis', + 'original_name': 'Temperature report hysteresis', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_temperature_report_interval', + 'original_name': 'Temperature report interval', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.floor_thermostat_floor_sensor_type', + 'original_name': 'Floor sensor type', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.floor_thermostat_operation_mode', + 'original_name': 'Operation mode', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.floor_thermostat_sensor_mode', + 'original_name': 'Sensor mode', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.floor_thermostat_temperature_display', + 'original_name': 'Temperature display', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.floor_thermostat_air_temperature', + 'original_name': 'Air temperature', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.floor_thermostat_air_temperature_3', + 'original_name': 'Air temperature (3)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.floor_thermostat_commands_dropped_rx', + 'original_name': 'Commands dropped (RX)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.floor_thermostat_commands_dropped_tx', + 'original_name': 'Commands dropped (TX)', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.floor_thermostat_electric_consumed_kwh', + 'original_name': 'Electric Consumed [kWh]', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.floor_thermostat_electric_consumed_v', + 'original_name': 'Electric Consumed [V]', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.floor_thermostat_electric_consumed_w', + 'original_name': 'Electric Consumed [W]', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.floor_thermostat_last_seen', + 'original_name': 'Last seen', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.floor_thermostat_node_status', + 'original_name': 'Node status', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.floor_thermostat_round_trip_time', + 'original_name': 'Round trip time', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.floor_thermostat_signal_strength', + 'original_name': 'Signal strength', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.floor_thermostat_successful_commands_rx', + 'original_name': 'Successful commands (RX)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.floor_thermostat_successful_commands_tx', + 'original_name': 'Successful commands (TX)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.floor_thermostat_timed_out_responses', + 'original_name': 'Timed out responses', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'switch.floor_thermostat', + 'original_name': '', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'update.floor_thermostat_firmware', + 'original_name': 'Firmware', + }), + ]), + 'identifiers': list([ + 'zwave_js:3245146787-26', + 'zwave_js:3245146787-26-411:3:514', + ]), + 'name': 'Floor thermostat', + }), + }) +# --- +# name: test_device_tree[heatit_z_trm3] + dict({ + 'child_devices': list([ + ]), + 'node_device': dict({ + 'entities': list([ + dict({ + 'disabled_by': 'None', + 'entity_id': 'button.floor_thermostat_ping', + 'original_name': 'Ping', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'climate.floor_thermostat', + 'original_name': '', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_air_maximum_temperature_limit_ahi', + 'original_name': 'Air maximum temperature limit (AHi)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_air_minimum_temperature_limit_alo', + 'original_name': 'Air minimum temperature limit (ALo)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_button_brightness_active_state', + 'original_name': 'Button brightness - active state', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_button_brightness_dimmed_state', + 'original_name': 'Button brightness - dimmed state', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_display_brightness_active_state', + 'original_name': 'Display brightness - active state', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_display_brightness_dimmed_state', + 'original_name': 'Display brightness - dimmed state', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_external_sensor_calibration', + 'original_name': 'External sensor calibration', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_floor_maximum_temperature_fhi', + 'original_name': 'Floor maximum temperature (FHi)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_floor_minimum_temperature_limit_flo', + 'original_name': 'Floor minimum temperature limit (FLo)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_floor_sensor_calibration', + 'original_name': 'Floor sensor calibration', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_meter_report_delta_value', + 'original_name': 'Meter report delta value', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_meter_report_interval', + 'original_name': 'Meter report interval', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_room_sensor_calibration', + 'original_name': 'Room sensor calibration', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_temperature_control_hysteresis_diff_i', + 'original_name': 'Temperature control hysteresis (DIFF I)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_temperature_report_hysteresis', + 'original_name': 'Temperature report hysteresis', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_temperature_report_interval', + 'original_name': 'Temperature report interval', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.floor_thermostat_floor_sensor_type', + 'original_name': 'Floor sensor type', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.floor_thermostat_sensor_mode', + 'original_name': 'Sensor mode', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.floor_thermostat_temperature_display', + 'original_name': 'Temperature display', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.floor_thermostat_air_temperature', + 'original_name': 'Air temperature', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.floor_thermostat_air_temperature_3', + 'original_name': 'Air temperature (3)', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.floor_thermostat_air_temperature_4', + 'original_name': 'Air temperature (4)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.floor_thermostat_commands_dropped_rx', + 'original_name': 'Commands dropped (RX)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.floor_thermostat_commands_dropped_tx', + 'original_name': 'Commands dropped (TX)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.floor_thermostat_last_seen', + 'original_name': 'Last seen', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.floor_thermostat_node_status', + 'original_name': 'Node status', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.floor_thermostat_round_trip_time', + 'original_name': 'Round trip time', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.floor_thermostat_signal_strength', + 'original_name': 'Signal strength', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.floor_thermostat_successful_commands_rx', + 'original_name': 'Successful commands (RX)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.floor_thermostat_successful_commands_tx', + 'original_name': 'Successful commands (TX)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.floor_thermostat_timed_out_responses', + 'original_name': 'Timed out responses', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.floor_thermostat_value_electric_consumed', + 'original_name': 'Value (Electric, Consumed)', + }), + ]), + 'identifiers': list([ + 'zwave_js:3245146787-24', + 'zwave_js:3245146787-24-411:3:515', + ]), + 'name': 'Floor thermostat', + }), + }) +# --- +# name: test_device_tree[heatit_z_trm6] + dict({ + 'child_devices': list([ + ]), + 'node_device': dict({ + 'entities': list([ + dict({ + 'disabled_by': 'None', + 'entity_id': 'binary_sensor.floor_thermostat_over_load_detected', + 'original_name': 'Over-load detected', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'binary_sensor.floor_thermostat_overheat_detected', + 'original_name': 'Overheat detected', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'button.floor_thermostat_identify', + 'original_name': 'Identify', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'button.floor_thermostat_idle_heat_alarm_heat_sensor_status', + 'original_name': 'Idle Heat Alarm Heat sensor status', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'button.floor_thermostat_idle_power_management_over_load_status', + 'original_name': 'Idle Power Management Over-load status', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'button.floor_thermostat_ping', + 'original_name': 'Ping', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'button.floor_thermostat_reset_accumulated_values', + 'original_name': 'Reset accumulated values', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'climate.floor_thermostat', + 'original_name': '', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_active_display_brightness', + 'original_name': 'Active Display Brightness', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_cooling_setpoint', + 'original_name': 'Cooling Setpoint', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_eco_setpoint', + 'original_name': 'Eco Setpoint', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_external_sensor_calibration', + 'original_name': 'External Sensor Calibration', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_external_sensor_max_temp_limit', + 'original_name': 'External Sensor Max Temp Limit', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_external_sensor_min_temp_limit', + 'original_name': 'External Sensor Min Temp Limit', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_floor_sensor_calibration', + 'original_name': 'Floor Sensor Calibration', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_floor_sensor_max_temp_limit', + 'original_name': 'Floor Sensor Max Temp Limit', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_floor_sensor_min_temp_limit', + 'original_name': 'Floor Sensor Min Temp Limit', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_heating_setpoint', + 'original_name': 'Heating Setpoint', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_indicator_value', + 'original_name': 'Indicator value', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_internal_sensor_calibration', + 'original_name': 'Internal Sensor Calibration', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_internal_sensor_max_temp_limit', + 'original_name': 'Internal Sensor Max Temp Limit', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_internal_sensor_min_temp_limit', + 'original_name': 'Internal Sensor Min Temp Limit', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_load_power', + 'original_name': 'Load Power', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_meter_report_interval', + 'original_name': 'Meter Report Interval', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_power_regulator_active_time', + 'original_name': 'Power Regulator Active Time', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_standby_display_brightness', + 'original_name': 'Standby Display Brightness', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_temperature_control_hysteresis', + 'original_name': 'Temperature Control Hysteresis', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_temperature_report_hysteresis', + 'original_name': 'Temperature Report Hysteresis', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_temperature_report_interval', + 'original_name': 'Temperature Report Interval', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_thermostat_state_report_interval', + 'original_name': 'Thermostat State Report Interval', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.floor_thermostat_turn_on_delay_after_error', + 'original_name': 'Turn On Delay After Error', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.floor_thermostat_external_sensor_resistance', + 'original_name': 'External Sensor Resistance', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.floor_thermostat_local_control', + 'original_name': 'Local Control', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'select.floor_thermostat_local_protection_state', + 'original_name': 'Local protection state', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.floor_thermostat_open_window_detection', + 'original_name': 'Open Window Detection', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.floor_thermostat_operating_mode', + 'original_name': 'Operating Mode', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.floor_thermostat_regulation_mode', + 'original_name': 'Regulation Mode', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.floor_thermostat_sensor_mode', + 'original_name': 'Sensor Mode', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.floor_thermostat_temperature_display', + 'original_name': 'Temperature Display', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.floor_thermostat_air_temperature', + 'original_name': 'Air temperature', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.floor_thermostat_air_temperature_3', + 'original_name': 'Air temperature (3)', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.floor_thermostat_air_temperature_4', + 'original_name': 'Air temperature (4)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.floor_thermostat_alarm_level', + 'original_name': 'Alarm Level', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.floor_thermostat_alarm_type', + 'original_name': 'Alarm Type', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.floor_thermostat_commands_dropped_rx', + 'original_name': 'Commands dropped (RX)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.floor_thermostat_commands_dropped_tx', + 'original_name': 'Commands dropped (TX)', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.floor_thermostat_electric_consumption_kwh', + 'original_name': 'Electric Consumption [kWh]', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.floor_thermostat_electric_consumption_w', + 'original_name': 'Electric Consumption [W]', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.floor_thermostat_last_seen', + 'original_name': 'Last seen', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.floor_thermostat_node_status', + 'original_name': 'Node status', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.floor_thermostat_round_trip_time', + 'original_name': 'Round trip time', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.floor_thermostat_signal_strength', + 'original_name': 'Signal strength', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.floor_thermostat_successful_commands_rx', + 'original_name': 'Successful commands (RX)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.floor_thermostat_successful_commands_tx', + 'original_name': 'Successful commands (TX)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.floor_thermostat_timed_out_responses', + 'original_name': 'Timed out responses', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'update.floor_thermostat_firmware', + 'original_name': 'Firmware', + }), + ]), + 'identifiers': list([ + 'zwave_js:3245146787-101', + 'zwave_js:3245146787-101-411:48:12289', + ]), + 'name': 'Floor Thermostat', + }), + }) +# --- +# name: test_device_tree[inovelli_lzw36] + dict({ + 'child_devices': list([ + ]), + 'node_device': dict({ + 'entities': list([ + dict({ + 'disabled_by': 'None', + 'entity_id': 'button.family_room_combo_ping', + 'original_name': 'Ping', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'button.family_room_combo_reset_accumulated_values', + 'original_name': 'Reset accumulated values', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'fan.family_room_combo_2', + 'original_name': '(2)', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'light.family_room_combo', + 'original_name': '', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_active_power_reports', + 'original_name': 'Active Power Reports', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_auto_off_fan_timer', + 'original_name': 'Auto Off Fan Timer', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_auto_off_light_timer', + 'original_name': 'Auto Off Light Timer', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_default_light_level_local', + 'original_name': 'Default Light Level (Local)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_default_light_level_z_wave', + 'original_name': 'Default Light Level (Z-Wave)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_energy_reports', + 'original_name': 'Energy Reports', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_fan_led_indicator_color', + 'original_name': 'Fan LED Indicator Color', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_fan_led_strip_effect_color', + 'original_name': 'Fan LED Strip Effect Color', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_fan_led_strip_effect_duration', + 'original_name': 'Fan LED Strip Effect Duration', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_fan_led_strip_effect_intensity', + 'original_name': 'Fan LED Strip Effect Intensity', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_fan_led_strip_intensity', + 'original_name': 'Fan LED Strip Intensity', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_fan_led_strip_intensity_when_off', + 'original_name': 'Fan LED Strip Intensity (When OFF)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_fan_led_strip_timeout', + 'original_name': 'Fan LED Strip Timeout', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_fan_state_after_power_restored', + 'original_name': 'Fan State After Power Restored', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_light_dimming_speed', + 'original_name': 'Light Dimming Speed', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_light_dimming_speed_from_switch', + 'original_name': 'Light Dimming Speed (From Switch)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_light_led_indicator_color', + 'original_name': 'Light LED Indicator Color', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_light_led_strip_effect_color', + 'original_name': 'Light LED Strip Effect Color', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_light_led_strip_effect_duration', + 'original_name': 'Light LED Strip Effect Duration', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_light_led_strip_effect_intensity', + 'original_name': 'Light LED Strip Effect Intensity', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_light_led_strip_intensity', + 'original_name': 'Light LED Strip Intensity', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_light_led_strip_intensity_when_off', + 'original_name': 'Light LED Strip Intensity (When OFF)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_light_led_strip_timeout', + 'original_name': 'Light LED Strip Timeout', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_light_ramp_rate', + 'original_name': 'Light Ramp Rate', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_light_ramp_rate_from_switch', + 'original_name': 'Light Ramp Rate (From Switch)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_light_state_after_power_restored', + 'original_name': 'Light State After Power Restored', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_maximum_fan_level', + 'original_name': 'Maximum Fan Level', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_maximum_light_level', + 'original_name': 'Maximum Light Level', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_minimum_fan_level', + 'original_name': 'Minimum Fan Level', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_minimum_light_level', + 'original_name': 'Minimum Light Level', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.family_room_combo_periodic_power_energy_reports', + 'original_name': 'Periodic Power & Energy Reports', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.family_room_combo_default_fan_level_local', + 'original_name': 'Default Fan Level (Local)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.family_room_combo_default_fan_level_z_wave', + 'original_name': 'Default Fan Level (Z-Wave)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.family_room_combo_fan_led_strip_effect', + 'original_name': 'Fan LED Strip Effect', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.family_room_combo_instant_on', + 'original_name': 'Instant On', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.family_room_combo_light_led_strip_effect', + 'original_name': 'Light LED Strip Effect', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.family_room_combo_local_protection', + 'original_name': 'Local Protection', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'select.family_room_combo_local_protection_state', + 'original_name': 'Local protection state', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'select.family_room_combo_rf_protection_state', + 'original_name': 'RF protection state', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.family_room_combo_commands_dropped_rx', + 'original_name': 'Commands dropped (RX)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.family_room_combo_commands_dropped_tx', + 'original_name': 'Commands dropped (TX)', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.family_room_combo_electric_consumed_kwh', + 'original_name': 'Electric Consumed [kWh]', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.family_room_combo_electric_consumed_w', + 'original_name': 'Electric Consumed [W]', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.family_room_combo_last_seen', + 'original_name': 'Last seen', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.family_room_combo_node_status', + 'original_name': 'Node status', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.family_room_combo_round_trip_time', + 'original_name': 'Round trip time', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.family_room_combo_signal_strength', + 'original_name': 'Signal strength', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.family_room_combo_successful_commands_rx', + 'original_name': 'Successful commands (RX)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.family_room_combo_successful_commands_tx', + 'original_name': 'Successful commands (TX)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.family_room_combo_timed_out_responses', + 'original_name': 'Timed out responses', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'update.family_room_combo_firmware', + 'original_name': 'Firmware', + }), + ]), + 'identifiers': list([ + 'zwave_js:3245146787-19', + 'zwave_js:3245146787-19-798:14:1', + ]), + 'name': 'family_room_combo', + }), + }) +# --- +# name: test_device_tree[merten_507801] + dict({ + 'child_devices': list([ + ]), + 'node_device': dict({ + 'entities': list([ + dict({ + 'disabled_by': 'None', + 'entity_id': 'button.connect_roller_shutter_ping', + 'original_name': 'Ping', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'cover.connect_roller_shutter', + 'original_name': '', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'cover.connect_roller_shutter_2', + 'original_name': '(2)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.connect_roller_shutter_changeover_delay', + 'original_name': 'Changeover Delay', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.connect_roller_shutter_travel_time_down_byte_1', + 'original_name': 'Travel Time Down, Byte 1', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.connect_roller_shutter_travel_time_down_byte_2', + 'original_name': 'Travel Time Down, Byte 2', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.connect_roller_shutter_travel_time_up_byte_1', + 'original_name': 'Travel Time Up, Byte 1', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.connect_roller_shutter_travel_time_up_byte_2', + 'original_name': 'Travel Time Up, Byte 2', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'select.connect_roller_shutter_local_protection_state', + 'original_name': 'Local protection state', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.connect_roller_shutter_local_protection_state_2', + 'original_name': 'Local protection state (2)', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'select.connect_roller_shutter_rf_protection_state', + 'original_name': 'RF protection state', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.connect_roller_shutter_rf_protection_state_2', + 'original_name': 'RF protection state (2)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.connect_roller_shutter_commands_dropped_rx', + 'original_name': 'Commands dropped (RX)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.connect_roller_shutter_commands_dropped_tx', + 'original_name': 'Commands dropped (TX)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.connect_roller_shutter_last_seen', + 'original_name': 'Last seen', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.connect_roller_shutter_node_status', + 'original_name': 'Node status', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.connect_roller_shutter_round_trip_time', + 'original_name': 'Round trip time', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.connect_roller_shutter_signal_strength', + 'original_name': 'Signal strength', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.connect_roller_shutter_successful_commands_rx', + 'original_name': 'Successful commands (RX)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.connect_roller_shutter_successful_commands_tx', + 'original_name': 'Successful commands (TX)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.connect_roller_shutter_timed_out_responses', + 'original_name': 'Timed out responses', + }), + ]), + 'identifiers': list([ + 'zwave_js:3245146787-41', + 'zwave_js:3245146787-41-122:32771:1', + ]), + 'name': 'Connect Roller Shutter', + }), + }) +# --- +# name: test_device_tree[shelly_qnsh_001p10] + dict({ + 'child_devices': list([ + ]), + 'node_device': dict({ + 'entities': list([ + dict({ + 'disabled_by': 'None', + 'entity_id': 'binary_sensor.wave_shutter_over_current_detected', + 'original_name': 'Over-current detected', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'binary_sensor.wave_shutter_over_current_detected_2', + 'original_name': 'Over-current detected (2)', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'binary_sensor.wave_shutter_overheat_detected', + 'original_name': 'Overheat detected', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'binary_sensor.wave_shutter_overheat_detected_2', + 'original_name': 'Overheat detected (2)', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'binary_sensor.wave_shutter_system_hardware_failure', + 'original_name': 'System hardware failure', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'binary_sensor.wave_shutter_system_hardware_failure_2', + 'original_name': 'System hardware failure (2)', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'button.wave_shutter_identify', + 'original_name': 'Identify', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'button.wave_shutter_idle_heat_alarm_heat_sensor_status', + 'original_name': 'Idle Heat Alarm Heat sensor status', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'button.wave_shutter_idle_heat_alarm_heat_sensor_status_2', + 'original_name': 'Idle Heat Alarm Heat sensor status (2)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'button.wave_shutter_idle_power_management_over_current_status', + 'original_name': 'Idle Power Management Over-current status', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'button.wave_shutter_idle_power_management_over_current_status_2', + 'original_name': 'Idle Power Management Over-current status (2)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'button.wave_shutter_idle_system_hardware_status', + 'original_name': 'Idle System Hardware status', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'button.wave_shutter_idle_system_hardware_status_2', + 'original_name': 'Idle System Hardware status (2)', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'button.wave_shutter_ping', + 'original_name': 'Ping', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'button.wave_shutter_reset_accumulated_values', + 'original_name': 'Reset accumulated values', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'button.wave_shutter_reset_accumulated_values_2', + 'original_name': 'Reset accumulated values (2)', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'button.wave_shutter_reset_electric', + 'original_name': 'Reset (Electric)', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'button.wave_shutter_reset_electric_2', + 'original_name': 'Reset (Electric) (2)', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'cover.wave_shutter', + 'original_name': '', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'cover.wave_shutter_2', + 'original_name': '(2)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.wave_shutter_alarm_conf_co', + 'original_name': 'Alarm conf. - CO', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.wave_shutter_alarm_conf_heat', + 'original_name': 'Alarm conf. - Heat', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.wave_shutter_alarm_conf_smoke', + 'original_name': 'Alarm conf. - Smoke', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.wave_shutter_alarm_conf_water', + 'original_name': 'Alarm conf. - Water', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.wave_shutter_delay_motor_stop', + 'original_name': 'Delay Motor Stop', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.wave_shutter_down_time', + 'original_name': 'Down time', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.wave_shutter_factory_reset', + 'original_name': 'Factory reset', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.wave_shutter_indicator_value', + 'original_name': 'Indicator value', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.wave_shutter_motor_moving_time', + 'original_name': 'Motor Moving Time', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.wave_shutter_motor_operation_detection', + 'original_name': 'Motor Operation Detection', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.wave_shutter_next_move_delay', + 'original_name': 'Next move delay', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.wave_shutter_power_change_report_threshold', + 'original_name': 'Power Change Report Threshold', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.wave_shutter_power_consumption_measurement_delay', + 'original_name': 'Power Consumption Measurement Delay', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.wave_shutter_slats_turning_time_offset', + 'original_name': 'Slats turning time offset', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.wave_shutter_up_time', + 'original_name': 'Up time', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'number.wave_shutter_venetian_mode_turning_time', + 'original_name': 'Venetian Mode: Turning Time', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.wave_shutter_operating_mode', + 'original_name': 'Operating Mode', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.wave_shutter_shutter_calibration', + 'original_name': 'Shutter Calibration', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.wave_shutter_sw1_switch_type', + 'original_name': 'SW1 Switch Type', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.wave_shutter_swap_inputs', + 'original_name': 'Swap Inputs', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.wave_shutter_swap_outputs', + 'original_name': 'Swap Outputs', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'select.wave_shutter_venetian_mode_restore_slats_position_after_moving', + 'original_name': 'Venetian Mode: Restore Slats Position After Moving', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.wave_shutter_alarm_level', + 'original_name': 'Alarm Level', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.wave_shutter_alarm_level_2', + 'original_name': 'Alarm Level (2)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.wave_shutter_alarm_type', + 'original_name': 'Alarm Type', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.wave_shutter_alarm_type_2', + 'original_name': 'Alarm Type (2)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.wave_shutter_commands_dropped_rx', + 'original_name': 'Commands dropped (RX)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.wave_shutter_commands_dropped_tx', + 'original_name': 'Commands dropped (TX)', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.wave_shutter_electric_consumption_kwh', + 'original_name': 'Electric Consumption [kWh]', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.wave_shutter_electric_consumption_kwh_2', + 'original_name': 'Electric Consumption [kWh] (2)', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.wave_shutter_electric_consumption_w', + 'original_name': 'Electric Consumption [W]', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.wave_shutter_electric_consumption_w_2', + 'original_name': 'Electric Consumption [W] (2)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.wave_shutter_last_seen', + 'original_name': 'Last seen', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.wave_shutter_node_status', + 'original_name': 'Node status', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.wave_shutter_round_trip_time', + 'original_name': 'Round trip time', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.wave_shutter_signal_strength', + 'original_name': 'Signal strength', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.wave_shutter_successful_commands_rx', + 'original_name': 'Successful commands (RX)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.wave_shutter_successful_commands_tx', + 'original_name': 'Successful commands (TX)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.wave_shutter_timed_out_responses', + 'original_name': 'Timed out responses', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'update.wave_shutter_firmware', + 'original_name': 'Firmware', + }), + ]), + 'identifiers': list([ + 'zwave_js:3245146787-5', + 'zwave_js:3245146787-5-1120:3:130', + ]), + 'name': 'Wave Shutter', + }), + }) +# --- +# name: test_device_tree[vision_zl7432] + dict({ + 'child_devices': list([ + ]), + 'node_device': dict({ + 'entities': list([ + dict({ + 'disabled_by': 'None', + 'entity_id': 'button.in_wall_dual_relay_switch_ping', + 'original_name': 'Ping', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'light.in_wall_dual_relay_switch_basic', + 'original_name': 'Basic', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'light.in_wall_dual_relay_switch_basic_2', + 'original_name': 'Basic (2)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.in_wall_dual_relay_switch_commands_dropped_rx', + 'original_name': 'Commands dropped (RX)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.in_wall_dual_relay_switch_commands_dropped_tx', + 'original_name': 'Commands dropped (TX)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.in_wall_dual_relay_switch_last_seen', + 'original_name': 'Last seen', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'sensor.in_wall_dual_relay_switch_node_status', + 'original_name': 'Node status', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.in_wall_dual_relay_switch_round_trip_time', + 'original_name': 'Round trip time', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.in_wall_dual_relay_switch_signal_strength', + 'original_name': 'Signal strength', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.in_wall_dual_relay_switch_successful_commands_rx', + 'original_name': 'Successful commands (RX)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.in_wall_dual_relay_switch_successful_commands_tx', + 'original_name': 'Successful commands (TX)', + }), + dict({ + 'disabled_by': 'integration', + 'entity_id': 'sensor.in_wall_dual_relay_switch_timed_out_responses', + 'original_name': 'Timed out responses', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'switch.in_wall_dual_relay_switch', + 'original_name': '', + }), + dict({ + 'disabled_by': 'None', + 'entity_id': 'switch.in_wall_dual_relay_switch_2', + 'original_name': '(2)', + }), + ]), + 'identifiers': list([ + 'zwave_js:3245146787-7', + 'zwave_js:3245146787-7-265:8215:5911', + ]), + 'name': 'In Wall Dual Relay Switch', + }), + }) +# --- diff --git a/tests/components/zwave_js/test_endpoint_benchmarks.py b/tests/components/zwave_js/test_endpoint_benchmarks.py new file mode 100644 index 00000000000000..d5bb2aaeb0f7bc --- /dev/null +++ b/tests/components/zwave_js/test_endpoint_benchmarks.py @@ -0,0 +1,153 @@ +"""Snapshot benchmarks for multi-endpoint Z-Wave devices. + +These tests snapshot the full device-and-entity tree produced by the zwave_js +integration for devices that expose multiple endpoints, documenting how the +integration groups endpoints into devices and entities. +""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion +from zwave_js_server.model.node import Node + +from homeassistant.components.zwave_js.helpers import get_device_id +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + + +def _snapshot_device_tree( + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + node_device: dr.DeviceEntry, +) -> dict: + """Build a stable, snapshot-friendly representation of a node's device tree. + + Returns a dict with the node device name, its identifier suffixes, all + entities on the node device, and a list of child devices (if any), each + with their name, identifiers, and entities. The snapshot is stable across + runs because raw device UUIDs are not included. + """ + + def _stable_identifiers(device: dr.DeviceEntry) -> list[str]: + return sorted( + f"{domain}:{identifier}" for domain, identifier in device.identifiers + ) + + def _entities_for_device(device_id: str) -> list[dict]: + entries = er.async_entries_for_device( + entity_registry, device_id, include_disabled_entities=True + ) + return sorted( + [ + { + "entity_id": entry.entity_id, + "original_name": entry.original_name, + "disabled_by": str(entry.disabled_by), + } + for entry in entries + ], + key=lambda e: e["entity_id"], + ) + + child_devices = sorted( + dr.async_entries_for_parent_device(device_registry, node_device.id), + key=_stable_identifiers, + ) + + return { + "node_device": { + "name": node_device.name, + "identifiers": _stable_identifiers(node_device), + "entities": _entities_for_device(node_device.id), + }, + "child_devices": [ + { + "name": child.name, + "identifiers": _stable_identifiers(child), + "entities": _entities_for_device(child.id), + } + for child in child_devices + ], + } + + +@pytest.fixture +def node(request: pytest.FixtureRequest) -> Node: + """Resolve the parametrized node fixture before integration setup.""" + return request.getfixturevalue(request.param) + + +@pytest.mark.parametrize( + "node", + [ + # Vision ZL7432 In Wall Dual Relay Switch: two independently controllable + # relay outputs on one in-wall module. Endpoints 1 and 2 each expose + # SWITCH_BINARY currentValue, one per relay output; the manual does not + # state which endpoint maps to which physical load. + pytest.param("vision_security_zl7432", id="vision_zl7432"), + # Fibaro FGR-223 Roller Shutter 3: single motor output controlling a roller + # or venetian shutter. Endpoint 1 is the primary shutter control + # (SWITCH_MULTILEVEL for position). Endpoint 2 exposes slat/tilt control for + # venetian mode; it produces a secondary cover entity disabled by the + # integration by default (disabled_by: integration), so a registry entry + # exists but the entity is off unless the user enables it. + pytest.param("fibaro_fgr223_shutter", id="fibaro_fgr223"), + # Shelly/Qubino QNSH-001P10 Wave Shutter: one bi-directional motor (O1 up, + # O2 down), same topology as the FGR-223. Endpoint 1 is shutter position; + # endpoint 2 is the venetian slat tilt, present only when operating mode + # (param 71) is Venetian. The endpoint-2 cover is disabled by the + # integration, but unlike the FGR-223 this endpoint also mirrors the Meter + # and Notification CCs, yielding a duplicate set of enabled sensor, binary + # sensor and button entities. + pytest.param("shelly_qnsh_001P10_shutter", id="shelly_qnsh_001p10"), + # Merten 507801 Connect Roller Shutter: a 1-gang receiver with a single + # motor output (two interlocked make contacts for up/down). Endpoints 1 + # and 2 have identical capabilities (SWITCH_MULTILEVEL + PROTECTION); the + # manufacturer documents no endpoint semantics at all, and the integration + # discovers endpoint 2 disabled. + pytest.param("merten_507801", id="merten_507801"), + # Inovelli LZW36 Light/Fan Combo: an in-wall switch paired with a canopy + # module in the fan; only the switch is the Z-Wave node, driving the module + # over proprietary RF. Endpoint 1 is the light (SWITCH_MULTILEVEL dimming), + # endpoint 2 the fan motor (SWITCH_MULTILEVEL speed). The two loads are + # physically separate and independently controllable. + pytest.param("inovelli_lzw36", id="inovelli_lzw36"), + # Heatit Z-TRM6 floor thermostat: thermostat input on endpoint 0/1. Three + # separate temperature sensor probes report via SENSOR_MULTILEVEL Air + # temperature: endpoint 2 is the internal (room) air sensor, endpoint 3 is + # an external air sensor, and endpoint 4 is the floor sensor. Each probe is + # a physically distinct input. + pytest.param("climate_heatit_z_trm6", id="heatit_z_trm6"), + # Heatit Z-TRM3 floor thermostat: same multi-sensor topology as the Z-TRM6: + # thermostat input on endpoint 0/1, three separate temperature sensor probes + # reporting Air temperature on endpoints 2 (internal), 3 (external), and 4 + # (floor). + pytest.param("climate_heatit_z_trm3", id="heatit_z_trm3"), + # Heatit Z-TRM2fx floor thermostat: thermostat on endpoint 1. Unlike the + # Z-TRM3 and Z-TRM6, the endpoint order differs and there is no internal + # room sensor: endpoint 2 is the external room sensor and endpoint 3 is the + # floor sensor (it still reports sensor type "Air temperature"). Endpoint 4 + # is the internal relay (Binary Switch + Meter), not a sensor. The BASIC + # currentValue/targetValue on endpoints 2 and 3 are undocumented. + pytest.param("climate_heatit_z_trm2fx", id="heatit_z_trm2fx"), + ], + indirect=True, +) +async def test_device_tree( + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + client: MagicMock, + node: Node, + integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Snapshot the device tree for a multi-endpoint Z-Wave device.""" + node_device = device_registry.async_get_device_by_identifier( + get_device_id(client.driver, node), integration.entry_id + ) + assert node_device + assert ( + _snapshot_device_tree(device_registry, entity_registry, node_device) == snapshot + ) diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index d908c75b9d1ca2..9d654bd7440f67 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -10184,6 +10184,137 @@ async def test_async_get_device_and_config_entry_for_domain_composite( assert device.config_entries == {entry_a.entry_id, entry_b.entry_id} +async def test_async_get_device_and_config_entry_for_domain_child_devices( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test getting the device and config entry of a domain owning a child device.""" + entry = MockConfigEntry(domain="domain_a") + entry.add_to_hass(hass) + parent = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("domain_a", "1")} + ) + child = device_registry.async_get_or_create_child( + config_entry_id=entry.entry_id, + identifiers={("domain_a", "1_1")}, + parent_device_id=parent.id, + ) + + # A child device is paired with the entry owning it + assert dr.async_get_device_and_config_entry_for_domain( + hass, child.id, domain="domain_a" + ) == (child, entry) + # A domain not owning the child still gets the child + assert dr.async_get_device_and_config_entry_for_domain( + hass, child.id, domain="domain_b" + ) == (child, None) + # With include_child_devices=False the child is treated as absent + assert dr.async_get_device_and_config_entry_for_domain( + hass, child.id, domain="domain_a", include_child_devices=False + ) == (None, None) + # A main device is unaffected by include_child_devices + assert dr.async_get_device_and_config_entry_for_domain( + hass, parent.id, domain="domain_a" + ) == (parent, entry) + assert dr.async_get_device_and_config_entry_for_domain( + hass, parent.id, domain="domain_a", include_child_devices=False + ) == (parent, entry) + # An unknown device id + assert dr.async_get_device_and_config_entry_for_domain( + hass, "unknown_id", domain="domain_a" + ) == (None, None) + + +async def test_async_get_device_and_config_entry_for_domain_no_main_devices( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test getting the device and config entry with main devices excluded.""" + entry = MockConfigEntry(domain="domain_a") + entry.add_to_hass(hass) + parent = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("domain_a", "1")} + ) + child = device_registry.async_get_or_create_child( + config_entry_id=entry.entry_id, + identifiers={("domain_a", "1_1")}, + parent_device_id=parent.id, + ) + + # A main device is not resolved + assert dr.async_get_device_and_config_entry_for_domain( + hass, parent.id, domain="domain_a", include_main_devices=False + ) == (None, None) + # A child-only lookup resolves the child + assert dr.async_get_device_and_config_entry_for_domain( + hass, child.id, domain="domain_a", include_main_devices=False + ) == (child, entry) + assert dr.async_get_device_and_config_entry_for_domain( + hass, child.id, domain="domain_b", include_main_devices=False + ) == (child, None) + # Neither main nor child devices are resolved with both flags off + assert dr.async_get_device_and_config_entry_for_domain( + hass, + child.id, + domain="domain_a", + include_child_devices=False, + include_main_devices=False, + ) == (None, None) + assert dr.async_get_device_and_config_entry_for_domain( + hass, + parent.id, + domain="domain_a", + include_child_devices=False, + include_main_devices=False, + ) == (None, None) + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_async_get_device_and_config_entry_for_domain_composite_flags( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test the include_* flags for a composite device id.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b) + + dr.async_setup(hass) + await dr.async_load(hass) + device_registry = dr.async_get(hass) + + split_a = _get_device_for_config_entry( + device_registry, entry_a.entry_id, identifiers={("domain_a", "1")} + ) + + # A composite device id resolves to the domain's split regardless of + # include_child_devices + assert dr.async_get_device_and_config_entry_for_domain( + hass, COMPOSITE_ID, domain="domain_a", include_child_devices=False + ) == (split_a, entry_a) + # A domain owning none of the splits still gets the restored composite + device, config_entry = dr.async_get_device_and_config_entry_for_domain( + hass, COMPOSITE_ID, domain="domain_c", include_child_devices=False + ) + assert config_entry is None + assert device is not None + assert device.id == COMPOSITE_ID + # A composite device and its splits are main devices: include_main_devices=False + # suppresses both the split lookup and the restored composite + assert dr.async_get_device_and_config_entry_for_domain( + hass, COMPOSITE_ID, domain="domain_a", include_main_devices=False + ) == (None, None) + assert dr.async_get_device_and_config_entry_for_domain( + hass, COMPOSITE_ID, domain="domain_c", include_main_devices=False + ) == (None, None) + assert dr.async_get_device_and_config_entry_for_domain( + hass, + COMPOSITE_ID, + domain="domain_a", + include_child_devices=False, + include_main_devices=False, + ) == (None, None) + + @pytest.mark.parametrize("load_registries", [False]) async def test_clear_config_entry_clears_composite_primary_config_entry( hass: HomeAssistant, hass_storage: dict[str, Any] diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 4250e65e65c49c..f9c4ecdc457299 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -3511,3 +3511,109 @@ async def test_get_service_device_and_config_entry( with pytest.raises(exceptions.ServiceValidationError) as err: service.async_get_device_and_config_entry(hass, domain, device.id) assert err.value.translation_key == "service_config_entry_not_loaded" + + +async def test_get_service_device_and_config_entry_child_devices( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test that we can get a child device and its config entry.""" + domain = "mock_integration" + entry = MockConfigEntry(domain=domain) + entry.add_to_hass(hass) + entry.mock_state(hass, config_entries.ConfigEntryState.LOADED) + parent = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(domain, "unique_id")}, + name="Mock device", + ) + child = device_registry.async_get_or_create_child( + config_entry_id=entry.entry_id, + identifiers={(domain, "unique_id_child")}, + parent_device_id=parent.id, + name="Mock child device", + ) + + # A child device is paired with the entry owning it + assert service.async_get_device_and_config_entry(hass, domain, child.id) == ( + child, + entry, + ) + + # With include_child_devices=False the child raises as an unknown device + with pytest.raises(exceptions.ServiceValidationError) as err: + service.async_get_device_and_config_entry( + hass, domain, child.id, include_child_devices=False + ) + assert err.value.translation_key == "service_device_not_found" + assert err.value.translation_placeholders == {"device_id": child.id} + + # The child exists, but is not owned by a config entry of the domain + with pytest.raises(exceptions.ServiceValidationError) as err: + service.async_get_device_and_config_entry(hass, "another_domain", child.id) + assert err.value.translation_key == "service_device_wrong_domain" + assert err.value.translation_placeholders == { + "device_name": "Mock child device", + "domain": "another_domain", + } + + # The child exists, but its config entry is not loaded + entry.mock_state(hass, config_entries.ConfigEntryState.NOT_LOADED) + with pytest.raises(exceptions.ServiceValidationError) as err: + service.async_get_device_and_config_entry(hass, domain, child.id) + assert err.value.translation_key == "service_config_entry_not_loaded" + + +async def test_get_service_device_and_config_entry_no_main_devices( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test getting a device and its config entry with main devices excluded.""" + domain = "mock_integration" + entry = MockConfigEntry(domain=domain) + entry.add_to_hass(hass) + entry.mock_state(hass, config_entries.ConfigEntryState.LOADED) + parent = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(domain, "unique_id")}, + name="Mock device", + ) + child = device_registry.async_get_or_create_child( + config_entry_id=entry.entry_id, + identifiers={(domain, "unique_id_child")}, + parent_device_id=parent.id, + name="Mock child device", + ) + + # A main device raises as an unknown device + with pytest.raises(exceptions.ServiceValidationError) as err: + service.async_get_device_and_config_entry( + hass, domain, parent.id, include_main_devices=False + ) + assert err.value.translation_key == "service_device_not_found" + assert err.value.translation_placeholders == {"device_id": parent.id} + + # A child-only lookup resolves the child + assert service.async_get_device_and_config_entry( + hass, domain, child.id, include_main_devices=False + ) == (child, entry) + + # A child device raises as an unknown device with both flags off + with pytest.raises(exceptions.ServiceValidationError) as err: + service.async_get_device_and_config_entry( + hass, + domain, + child.id, + include_child_devices=False, + include_main_devices=False, + ) + assert err.value.translation_key == "service_device_not_found" + + # Neither does a main device with both flags off + with pytest.raises(exceptions.ServiceValidationError) as err: + service.async_get_device_and_config_entry( + hass, + domain, + parent.id, + include_child_devices=False, + include_main_devices=False, + ) + assert err.value.translation_key == "service_device_not_found"