diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1d5ec6f7581f96..b3b7f22f3475f6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.16.2 + rev: v0.16.3 hooks: - id: ruff-check args: diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index e40eb3afbb51f6..2f0130a4bca385 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -83,6 +83,7 @@ _DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that" _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"] +_DEVICE_REGISTRY_UPDATE_FIELDS = ["name", "name_by_user"] _DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"} @@ -288,6 +289,15 @@ def _filter_entity_registry_changes( field in event_data["changes"] for field in _ENTITY_REGISTRY_UPDATE_FIELDS ) + @callback + def _filter_device_registry_changes( + self, event_data: dr.EventDeviceRegistryUpdatedData + ) -> bool: + """Filter device registry changed events.""" + return event_data["action"] == "update" and any( + field in event_data["changes"] for field in _DEVICE_REGISTRY_UPDATE_FIELDS + ) + @callback def _filter_state_changes(self, event_data: EventStateChangedData) -> bool: """Filter state changed events.""" @@ -312,6 +322,11 @@ def _listen_clear_slot_list(self) -> None: self._async_clear_slot_list, event_filter=self._filter_entity_registry_changes, ), + self.hass.bus.async_listen( + dr.EVENT_DEVICE_REGISTRY_UPDATED, + self._async_clear_slot_list, + event_filter=self._filter_device_registry_changes, + ), self.hass.bus.async_listen( EVENT_STATE_CHANGED, self._async_clear_slot_list, diff --git a/homeassistant/components/ecovacs/icons.json b/homeassistant/components/ecovacs/icons.json index 3b92ee778ffd7f..15d32b02d36888 100644 --- a/homeassistant/components/ecovacs/icons.json +++ b/homeassistant/components/ecovacs/icons.json @@ -176,17 +176,32 @@ "stats_area": { "default": "mdi:floor-plan" }, + "stats_area_mower": { + "default": "mdi:floor-plan" + }, "stats_time": { "default": "mdi:timer-outline" }, + "stats_time_mower": { + "default": "mdi:timer-outline" + }, "total_stats_area": { "default": "mdi:floor-plan" }, + "total_stats_area_mower": { + "default": "mdi:floor-plan" + }, "total_stats_cleanings": { "default": "mdi:counter" }, + "total_stats_cleanings_mower": { + "default": "mdi:counter" + }, "total_stats_time": { "default": "mdi:timer-outline" + }, + "total_stats_time_mower": { + "default": "mdi:timer-outline" } }, "switch": { diff --git a/homeassistant/components/ecovacs/sensor.py b/homeassistant/components/ecovacs/sensor.py index 344f552901c6fc..f9233d349e3b47 100644 --- a/homeassistant/components/ecovacs/sensor.py +++ b/homeassistant/components/ecovacs/sensor.py @@ -1,8 +1,8 @@ """Ecovacs sensor module.""" -from collections.abc import Callable -from dataclasses import dataclass -from typing import Any, override +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field, fields, replace +from typing import Any, Self, override from deebot_client.capabilities import CapabilityEvent, CapabilityLifeSpan, DeviceType from deebot_client.device import Device @@ -33,10 +33,10 @@ UnitOfArea, UnitOfTime, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level -from homeassistant.helpers.typing import StateType +from homeassistant.helpers.typing import UNDEFINED, StateType, UndefinedType from . import EcovacsConfigEntry from .const import LEGACY_SUPPORTED_LIFESPANS, SUPPORTED_LIFESPANS @@ -49,6 +49,14 @@ from .util import get_name_key, get_options, get_supported_entities +@dataclass(kw_only=True, frozen=True) +class EcovacsSensorDeviceTypeOverride: + """Description values, which differ for a specific device type.""" + + native_unit_of_measurement: str | UndefinedType | None = UNDEFINED + translation_key: str | UndefinedType | None = UNDEFINED + + @dataclass(kw_only=True, frozen=True) class EcovacsSensorEntityDescription[EventT: Event]( EcovacsCapabilityEntityDescription, @@ -57,15 +65,23 @@ class EcovacsSensorEntityDescription[EventT: Event]( """Ecovacs sensor entity description.""" value_fn: Callable[[EventT], StateType] - native_unit_of_measurement_fn: Callable[[DeviceType], str | None] | None = None - + device_type_overrides: Mapping[DeviceType, EcovacsSensorDeviceTypeOverride] = field( + default_factory=dict + ) -@callback -def get_area_native_unit_of_measurement(device_type: DeviceType) -> str | None: - """Get the area native unit of measurement based on device type.""" - if device_type is DeviceType.MOWER: - return UnitOfArea.SQUARE_CENTIMETERS - return UnitOfArea.SQUARE_METERS + def get_for(self, device: DeviceType) -> Self: + """Get entity description for specific device type.""" + if (overrides := self.device_type_overrides.get(device)) is None: + return self + + return replace( + self, + **{ + f.name: value + for f in fields(overrides) + if (value := getattr(overrides, f.name)) is not UNDEFINED + }, + ) ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = ( @@ -76,8 +92,14 @@ def get_area_native_unit_of_measurement(device_type: DeviceType) -> str | None: value_fn=lambda e: e.area, translation_key="stats_area", device_class=SensorDeviceClass.AREA, - native_unit_of_measurement_fn=get_area_native_unit_of_measurement, + native_unit_of_measurement=UnitOfArea.SQUARE_METERS, suggested_unit_of_measurement=UnitOfArea.SQUARE_METERS, + device_type_overrides={ + DeviceType.MOWER: EcovacsSensorDeviceTypeOverride( + native_unit_of_measurement=UnitOfArea.SQUARE_CENTIMETERS, + translation_key="stats_area_mower", + ) + }, ), EcovacsSensorEntityDescription[StatsEvent]( key="stats_time", @@ -87,6 +109,11 @@ def get_area_native_unit_of_measurement(device_type: DeviceType) -> str | None: device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, suggested_unit_of_measurement=UnitOfTime.MINUTES, + device_type_overrides={ + DeviceType.MOWER: EcovacsSensorDeviceTypeOverride( + translation_key="stats_time_mower", + ) + }, ), # TotalStats EcovacsSensorEntityDescription[TotalStatsEvent]( @@ -97,6 +124,11 @@ def get_area_native_unit_of_measurement(device_type: DeviceType) -> str | None: device_class=SensorDeviceClass.AREA, native_unit_of_measurement=UnitOfArea.SQUARE_METERS, state_class=SensorStateClass.TOTAL_INCREASING, + device_type_overrides={ + DeviceType.MOWER: EcovacsSensorDeviceTypeOverride( + translation_key="total_stats_area_mower", + ) + }, ), EcovacsSensorEntityDescription[TotalStatsEvent]( capability_fn=lambda caps: caps.stats.total, @@ -107,6 +139,11 @@ def get_area_native_unit_of_measurement(device_type: DeviceType) -> str | None: native_unit_of_measurement=UnitOfTime.SECONDS, suggested_unit_of_measurement=UnitOfTime.HOURS, state_class=SensorStateClass.TOTAL_INCREASING, + device_type_overrides={ + DeviceType.MOWER: EcovacsSensorDeviceTypeOverride( + translation_key="total_stats_time_mower", + ) + }, ), EcovacsSensorEntityDescription[TotalStatsEvent]( capability_fn=lambda caps: caps.stats.total, @@ -114,6 +151,11 @@ def get_area_native_unit_of_measurement(device_type: DeviceType) -> str | None: key="total_stats_cleanings", translation_key="total_stats_cleanings", state_class=SensorStateClass.TOTAL_INCREASING, + device_type_overrides={ + DeviceType.MOWER: EcovacsSensorDeviceTypeOverride( + translation_key="total_stats_cleanings_mower", + ) + }, ), EcovacsSensorEntityDescription[BatteryEvent]( capability_fn=lambda caps: caps.battery, @@ -274,18 +316,12 @@ def __init__( **kwargs: Any, ) -> None: """Initialize entity.""" - super().__init__(device, capability, entity_description, **kwargs) - if ( - entity_description.native_unit_of_measurement_fn - and ( - native_unit_of_measurement - := entity_description.native_unit_of_measurement_fn( - device.capabilities.device_type - ) - ) - is not None - ): - self._attr_native_unit_of_measurement = native_unit_of_measurement + super().__init__( + device, + capability, + entity_description.get_for(device.capabilities.device_type), + **kwargs, + ) @override async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 8c02b59ca3129c..0ae48be55c8edc 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -268,17 +268,32 @@ "stats_area": { "name": "Area cleaned" }, + "stats_area_mower": { + "name": "Area mowed" + }, "stats_time": { "name": "Cleaning duration" }, + "stats_time_mower": { + "name": "Mowing duration" + }, "total_stats_area": { "name": "Total area cleaned" }, + "total_stats_area_mower": { + "name": "Total area mowed" + }, "total_stats_cleanings": { "name": "Total cleanings" }, + "total_stats_cleanings_mower": { + "name": "Total mowings" + }, "total_stats_time": { "name": "Total cleaning duration" + }, + "total_stats_time_mower": { + "name": "Total mowing duration" } }, "switch": { diff --git a/homeassistant/components/forked_daapd/const.py b/homeassistant/components/forked_daapd/const.py index effd4c9454cca9..c1ff2328fe40ec 100644 --- a/homeassistant/components/forked_daapd/const.py +++ b/homeassistant/components/forked_daapd/const.py @@ -83,6 +83,7 @@ | MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.MEDIA_ANNOUNCE | MediaPlayerEntityFeature.MEDIA_ENQUEUE + | MediaPlayerEntityFeature.GROUPING ) SUPPORTED_FEATURES_ZONE = ( MediaPlayerEntityFeature.VOLUME_SET diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index 83bee29138f42b..2457ebd701d9bc 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -27,6 +27,8 @@ ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -51,6 +53,7 @@ DEFAULT_TTS_PAUSE_TIME, DEFAULT_TTS_VOLUME, DEFAULT_UNMUTE_VOLUME, + DOMAIN, FD_NAME, KNOWN_PIPES, PIPE_FUNCTION_MAP, @@ -458,6 +461,74 @@ async def async_toggle(self) -> None: else: await self.async_turn_off() + @override + async def async_join_players(self, group_members: list[str]) -> None: + """Join `group_members` (outputs) to the current playback.""" + entity_registry = er.async_get(self.hass) + known_output_ids = {output["id"] for output in self._outputs} + output_ids: list[str] = [] + for entity_id in group_members: + if entity_id == self.entity_id: + continue + if not (entity_entry := entity_registry.async_get(entity_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entity_not_found", + translation_placeholders={"entity_id": entity_id}, + ) + if ( + entity_entry.platform != DOMAIN + or entity_entry.config_entry_id != self._entry_id + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_forked_daapd_output", + translation_placeholders={"entity_id": entity_id}, + ) + # Zone unique ids are f"{config_entry.entry_id}-{output_id}" + output_id = entity_entry.unique_id.split("-", 1)[1] + # Registry entries persist after an output disappears from the server + if output_id not in known_output_ids: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="output_not_found", + translation_placeholders={"entity_id": entity_id}, + ) + output_ids.append(output_id) + + await asyncio.gather( + *( + self.api.change_output(output_id, selected=True) + for output_id in output_ids + ) + ) + + @override + async def async_unjoin_player(self) -> None: + """Remove all outputs from the current playback.""" + if any(output["selected"] for output in self._outputs): + await self.api.set_enabled_outputs([]) + + @property + @override + def group_members(self) -> list[str]: + """List of players which are currently grouped together.""" + entity_registry = er.async_get(self.hass) + output_id_to_entity_id = { + entry.unique_id.split("-", 1)[1]: entry.entity_id + for entry in er.async_entries_for_config_entry( + entity_registry, self._entry_id + ) + # Skip the master entity, whose unique id is the config entry id + if isinstance(entry.unique_id, str) and "-" in entry.unique_id + } + return [self.entity_id] + [ + entity_id + for output in self._outputs + if output["selected"] + and (entity_id := output_id_to_entity_id.get(output["id"])) is not None + ] + @property @override def name(self) -> str: diff --git a/homeassistant/components/forked_daapd/strings.json b/homeassistant/components/forked_daapd/strings.json index 1dad9eae59fc8a..0ad4a03bbe7829 100644 --- a/homeassistant/components/forked_daapd/strings.json +++ b/homeassistant/components/forked_daapd/strings.json @@ -25,6 +25,17 @@ } } }, + "exceptions": { + "entity_not_found": { + "message": "Entity {entity_id} not found." + }, + "not_forked_daapd_output": { + "message": "Entity {entity_id} is not an output of this OwnTone server." + }, + "output_not_found": { + "message": "The output for entity {entity_id} no longer exists on the OwnTone server." + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/geosphere_austria_warnings/coordinator.py b/homeassistant/components/geosphere_austria_warnings/coordinator.py index 2ac24da2961890..b2b89185bb6f87 100644 --- a/homeassistant/components/geosphere_austria_warnings/coordinator.py +++ b/homeassistant/components/geosphere_austria_warnings/coordinator.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta from typing import override +from propcache.api import cached_property from pygeosphere_warnings import ( GeoSphereApiError, GeoSphereConnectionError, @@ -21,7 +22,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import DOMAIN, LOGGER +from .const import DOMAIN, LOGGER, WARNINGS_URL # Warnings are event driven and updated by GeoSphere Austria as needed. # The cheap HEAD precheck keeps the cost of a poll low, so a relatively @@ -56,6 +57,17 @@ def __init__(self, hass: HomeAssistant, config_entry: GeoSphereConfigEntry) -> N self.client = GeoSphereWarningsClient(async_get_clientsession(hass)) self._last_modified: datetime | None = None + @cached_property + def warnings_portal_url(self) -> str: + """Returns the URL to the configured municipality's details page on the warnings portal.""" + longitude = self.config_entry.data[CONF_LONGITUDE] + latitude = self.config_entry.data[CONF_LATITUDE] + return ( + WARNINGS_URL + # codespell:ignore-next-line alle + + f"wsapp/de/alle/gesamterzeitraum/0/{longitude:.5f},{latitude:.5f}" + ) + @override async def _async_update_data(self) -> GeoSphereData: """Fetch warnings, skipping the full fetch when nothing changed.""" diff --git a/homeassistant/components/geosphere_austria_warnings/entity.py b/homeassistant/components/geosphere_austria_warnings/entity.py index 4053392cd980a8..321e595222cffa 100644 --- a/homeassistant/components/geosphere_austria_warnings/entity.py +++ b/homeassistant/components/geosphere_austria_warnings/entity.py @@ -4,7 +4,7 @@ from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ATTRIBUTION, DOMAIN, MANUFACTURER, WARNINGS_URL +from .const import ATTRIBUTION, DOMAIN, MANUFACTURER from .coordinator import GeoSphereUpdateCoordinator @@ -29,5 +29,5 @@ def __init__( name=municipality.name, manufacturer=MANUFACTURER, entry_type=DeviceEntryType.SERVICE, - configuration_url=WARNINGS_URL, + configuration_url=coordinator.warnings_portal_url, ) diff --git a/homeassistant/components/intelliclima/__init__.py b/homeassistant/components/intelliclima/__init__.py index 22ab24369e15aa..3b212100f5e032 100644 --- a/homeassistant/components/intelliclima/__init__.py +++ b/homeassistant/components/intelliclima/__init__.py @@ -7,9 +7,14 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import LOGGER -from .coordinator import IntelliClimaConfigEntry, IntelliClimaCoordinator +from .coordinator import ( + IntelliClimaConfigEntry, + IntelliClimaCoordinator, + IntelliClimaData, + IntelliClimaFilterCoordinator, +) -PLATFORMS = [Platform.FAN, Platform.SELECT, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.FAN, Platform.SELECT, Platform.SENSOR] async def async_setup_entry( @@ -25,18 +30,24 @@ async def async_setup_entry( ) # Create coordinator - coordinator = IntelliClimaCoordinator(hass, entry, api) + devices_coordinator = IntelliClimaCoordinator(hass, entry, api) # Fetch initial data - await coordinator.async_config_entry_first_refresh() + await devices_coordinator.async_config_entry_first_refresh() LOGGER.debug( "Discovered %d IntelliClima VMC device(s)", - len(coordinator.data.ecocomfort2_devices), + len(devices_coordinator.data.ecocomfort2_devices), ) - # Store coordinator - entry.runtime_data = coordinator + device_serials = [ + device.crono_sn + for device in devices_coordinator.data.ecocomfort2_devices.values() + ] + filter_coordinator = IntelliClimaFilterCoordinator(hass, entry, api, device_serials) + await filter_coordinator.async_refresh() + + entry.runtime_data = IntelliClimaData(devices_coordinator, filter_coordinator) # Set up platforms await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/intelliclima/binary_sensor.py b/homeassistant/components/intelliclima/binary_sensor.py new file mode 100644 index 00000000000000..b40f5db08ecfb1 --- /dev/null +++ b/homeassistant/components/intelliclima/binary_sensor.py @@ -0,0 +1,75 @@ +"""Support for IntelliClima Binary Sensors.""" + +from typing import override + +from pyintelliclima.intelliclima_types import IntelliClimaECO + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import IntelliClimaConfigEntry, IntelliClimaFilterCoordinator +from .entity import eco_device_info + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IntelliClimaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the IntelliClima binary sensor platform.""" + data = entry.runtime_data + + async_add_entities( + IntelliClimaFilterCleaningBinarySensor( + coordinator=data.filter_coordinator, device=ecocomfort2 + ) + for ecocomfort2 in data.devices_coordinator.data.ecocomfort2_devices.values() + ) + + +class IntelliClimaFilterCleaningBinarySensor( + CoordinatorEntity[IntelliClimaFilterCoordinator], BinarySensorEntity +): + """Binary sensor indicating whether the device's filter needs cleaning.""" + + _attr_has_entity_name = True + _attr_translation_key = "filter_cleaning" + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_device_class = BinarySensorDeviceClass.PROBLEM + + def __init__( + self, + coordinator: IntelliClimaFilterCoordinator, + device: IntelliClimaECO, + ) -> None: + """Class initializer.""" + super().__init__(coordinator) + + self._attr_device_info = eco_device_info(device) + self._device_sn = device.crono_sn + self._attr_unique_id = f"{device.id}_filter_cleaning" + + @property + @override + def available(self) -> bool: + """Return if entity is available.""" + device_data = (self.coordinator.data or {}).get(self._device_sn) + return super().available and device_data is not None and device_data.is_active + + @property + @override + def is_on(self) -> bool | None: + """Return true if the filter needs cleaning.""" + device_data = (self.coordinator.data or {}).get(self._device_sn) + if device_data is None or not device_data.is_active: + return None + return device_data.change_filter diff --git a/homeassistant/components/intelliclima/const.py b/homeassistant/components/intelliclima/const.py index 5f643dfe0ce0d3..9515adf713a943 100644 --- a/homeassistant/components/intelliclima/const.py +++ b/homeassistant/components/intelliclima/const.py @@ -9,3 +9,6 @@ # Update interval DEFAULT_SCAN_INTERVAL = timedelta(minutes=1) + +# Filter status is expensive to compute cloud-side, so it's polled far less often. +FILTER_SCAN_INTERVAL = timedelta(days=1) diff --git a/homeassistant/components/intelliclima/coordinator.py b/homeassistant/components/intelliclima/coordinator.py index 148f709dd69ce6..b258cf82cacf92 100644 --- a/homeassistant/components/intelliclima/coordinator.py +++ b/homeassistant/components/intelliclima/coordinator.py @@ -1,16 +1,19 @@ """DataUpdateCoordinator for IntelliClima.""" +import asyncio +from dataclasses import dataclass from typing import override from pyintelliclima import IntelliClimaAPI, IntelliClimaAPIError, IntelliClimaDevices +from pyintelliclima.intelliclima_types import IntelliClimaFilterStatus from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, FILTER_SCAN_INTERVAL, LOGGER -type IntelliClimaConfigEntry = ConfigEntry[IntelliClimaCoordinator] +type IntelliClimaConfigEntry = ConfigEntry[IntelliClimaData] class IntelliClimaCoordinator(DataUpdateCoordinator[IntelliClimaDevices]): @@ -47,3 +50,56 @@ async def _async_update_data(self) -> IntelliClimaDevices: except IntelliClimaAPIError as err: raise UpdateFailed(f"Failed to update data: {err}") from err + + +class IntelliClimaFilterCoordinator( + DataUpdateCoordinator[dict[str, IntelliClimaFilterStatus]] +): + """Coordinator to manage fetching IntelliClima filter status, polled once a day.""" + + def __init__( + self, + hass: HomeAssistant, + entry: IntelliClimaConfigEntry, + api: IntelliClimaAPI, + device_serials: list[str], + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + name=f"{DOMAIN}_filter", + update_interval=FILTER_SCAN_INTERVAL, + config_entry=entry, + ) + self.api = api + self._device_serials = device_serials + + @override + async def _async_update_data(self) -> dict[str, IntelliClimaFilterStatus]: + """Fetch filter status for all devices, isolating per-device failures.""" + results = await asyncio.gather( + *(self.api.get_filter_status(serial) for serial in self._device_serials), + return_exceptions=True, + ) + + statuses: dict[str, IntelliClimaFilterStatus] = {} + for serial, result in zip(self._device_serials, results, strict=True): + if isinstance(result, IntelliClimaAPIError): + LOGGER.warning( + "Failed to update filter status for %s: %s", serial, result + ) + continue + if isinstance(result, BaseException): + raise result + statuses[serial] = result + + return statuses + + +@dataclass +class IntelliClimaData: + """Runtime data for the IntelliClima config entry.""" + + devices_coordinator: IntelliClimaCoordinator + filter_coordinator: IntelliClimaFilterCoordinator diff --git a/homeassistant/components/intelliclima/entity.py b/homeassistant/components/intelliclima/entity.py index d48fb44dc7454c..3a4d2d4993fb19 100644 --- a/homeassistant/components/intelliclima/entity.py +++ b/homeassistant/components/intelliclima/entity.py @@ -4,7 +4,6 @@ from pyintelliclima.intelliclima_types import IntelliClimaC800, IntelliClimaECO -from homeassistant.const import ATTR_CONNECTIONS, ATTR_MODEL, ATTR_SW_VERSION from homeassistant.helpers.device_registry import ( CONNECTION_BLUETOOTH, CONNECTION_NETWORK_MAC, @@ -16,6 +15,22 @@ from .coordinator import IntelliClimaCoordinator +def eco_device_info(device: IntelliClimaECO) -> DeviceInfo: + """Return the device info shared by all entities of an ECOCOMFORT 2.0.""" + return DeviceInfo( + identifiers={(DOMAIN, device.id)}, + manufacturer="Fantini Cosmi", + name=device.name, + serial_number=device.crono_sn, + model="ECOCOMFORT 2.0", + sw_version=device.fw, + connections={ + (CONNECTION_BLUETOOTH, device.mac), + (CONNECTION_NETWORK_MAC, device.macwifi), + }, + ) + + class IntelliClimaEntity(CoordinatorEntity[IntelliClimaCoordinator]): """Define a generic class for IntelliClima entities.""" @@ -53,12 +68,7 @@ def __init__( """Class initializer.""" super().__init__(coordinator, device) - self._attr_device_info[ATTR_MODEL] = "ECOCOMFORT 2.0" - self._attr_device_info[ATTR_SW_VERSION] = device.fw - self._attr_device_info[ATTR_CONNECTIONS] = { - (CONNECTION_BLUETOOTH, device.mac), - (CONNECTION_NETWORK_MAC, device.macwifi), - } + self._attr_device_info = eco_device_info(device) @property def _device_data(self) -> IntelliClimaECO: diff --git a/homeassistant/components/intelliclima/fan.py b/homeassistant/components/intelliclima/fan.py index 8560763a952c9f..969d97cbefedab 100644 --- a/homeassistant/components/intelliclima/fan.py +++ b/homeassistant/components/intelliclima/fan.py @@ -28,7 +28,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up IntelliClima VMC fans.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.devices_coordinator entities: list[IntelliClimaVMCFan] = [ IntelliClimaVMCFan( diff --git a/homeassistant/components/intelliclima/select.py b/homeassistant/components/intelliclima/select.py index a5408059b8df86..2f14444a95747d 100644 --- a/homeassistant/components/intelliclima/select.py +++ b/homeassistant/components/intelliclima/select.py @@ -31,7 +31,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up IntelliClima VMC fan mode select.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.devices_coordinator entities: list[IntelliClimaVMCFanModeSelect] = [ IntelliClimaVMCFanModeSelect( diff --git a/homeassistant/components/intelliclima/sensor.py b/homeassistant/components/intelliclima/sensor.py index 55e242e2c2b592..4a29d6d902da90 100644 --- a/homeassistant/components/intelliclima/sensor.py +++ b/homeassistant/components/intelliclima/sensor.py @@ -61,7 +61,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a IntelliClima Sensors.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.devices_coordinator entities: list[IntelliClimaSensor] = [ IntelliClimaSensor( diff --git a/homeassistant/components/intelliclima/strings.json b/homeassistant/components/intelliclima/strings.json index 7c8e1b25053315..2fad2af309e86f 100644 --- a/homeassistant/components/intelliclima/strings.json +++ b/homeassistant/components/intelliclima/strings.json @@ -24,6 +24,11 @@ } }, "entity": { + "binary_sensor": { + "filter_cleaning": { + "name": "Filter cleaning required" + } + }, "select": { "fan_mode": { "name": "Fan direction mode", diff --git a/homeassistant/components/lyngdorf/const.py b/homeassistant/components/lyngdorf/const.py index 3c019793409c88..47a5cd018126a5 100644 --- a/homeassistant/components/lyngdorf/const.py +++ b/homeassistant/components/lyngdorf/const.py @@ -10,3 +10,4 @@ Platform.SENSOR, ] CONF_SERIAL_NUMBER = "serial_number" +SSDP_ST = "urn:schemas-upnp-org:device:MediaRenderer:2" diff --git a/homeassistant/components/lyngdorf/diagnostics.py b/homeassistant/components/lyngdorf/diagnostics.py new file mode 100644 index 00000000000000..b46987114525ad --- /dev/null +++ b/homeassistant/components/lyngdorf/diagnostics.py @@ -0,0 +1,108 @@ +"""Diagnostics support for the Lyngdorf integration.""" + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.ssdp import async_get_discovery_info_by_st +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL + +from .const import CONF_SERIAL_NUMBER, SSDP_ST +from .models import LyngdorfConfigEntry + +_TRIM_NAMES = tuple( + f"trim_{trim}" for trim in ("bass", "treble", "centre", "height", "lfe", "surround") +) +_RANGE_NAMES = ("lipsync_range", *(f"{name}_range" for name in _TRIM_NAMES)) + +# The serial doubles as the device MAC and as the config entry unique_id, so it +# needs redacting wherever it surfaces, including inside the UPnP description. +TO_REDACT = { + CONF_HOST, + CONF_SERIAL_NUMBER, + "unique_id", + ATTR_UPNP_SERIAL, + "presentationURL", + "ssdp_location", + "ssdp_all_locations", + "ssdp_headers", + "ssdp_usn", + "ssdp_udn", +} + + +async def _async_ssdp_description( + hass: HomeAssistant, serial: str +) -> dict[str, Any] | None: + """Return the UPnP description this device is currently announcing.""" + for info in await async_get_discovery_info_by_st(hass, SSDP_ST): + if (info.upnp.get(ATTR_UPNP_SERIAL) or "").lower() == serial.lower(): + return { + "ssdp_usn": info.ssdp_usn, + "ssdp_st": info.ssdp_st, + "ssdp_udn": info.ssdp_udn, + "ssdp_server": info.ssdp_server, + "ssdp_location": info.ssdp_location, + "upnp": dict(info.upnp), + } + # Null when SSDP has not seen the device, which is worth knowing in itself. + return None + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: LyngdorfConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + receiver = config_entry.runtime_data.receiver + + state: dict[str, Any] = { + "connected": receiver.connected, + "model": receiver.model.name if receiver.model else None, + "power_on": receiver.power_on, + "volume": receiver.volume, + "max_volume": receiver.max_volume, + "mute_enabled": receiver.mute_enabled, + "source": receiver.source, + "available_sources": receiver.available_sources, + "sound_mode": receiver.sound_mode, + "available_sound_modes": receiver.available_sound_modes, + "audio_input": receiver.audio_input, + "available_audio_inputs": receiver.available_audio_inputs, + "video_input": receiver.video_input, + "available_video_inputs": receiver.available_video_inputs, + "audio_information": receiver.audio_information, + "video_information": receiver.video_information, + "streaming_source": receiver.streaming_source, + "available_stream_types": receiver.available_stream_types, + "room_perfect_position": receiver.room_perfect_position, + "available_room_perfect_positions": receiver.available_room_perfect_positions, + "voicing": receiver.voicing, + "available_voicings": receiver.available_voicings, + "lipsync": receiver.lipsync, + "zone_b_power_on": receiver.zone_b_power_on, + "zone_b_volume": receiver.zone_b_volume, + "zone_b_mute_enabled": receiver.zone_b_mute_enabled, + "zone_b_source": receiver.zone_b_source, + "zone_b_audio_input": receiver.zone_b_audio_input, + "zone_b_streaming_source": receiver.zone_b_streaming_source, + } + state |= {name: getattr(receiver, name) for name in _TRIM_NAMES} + + ranges = { + name: asdict(value) if (value := getattr(receiver, name)) is not None else None + for name in _RANGE_NAMES + } + + return async_redact_data( + { + "entry": config_entry.as_dict(), + "ssdp": await _async_ssdp_description( + hass, config_entry.data[CONF_SERIAL_NUMBER] + ), + "state": state, + "ranges": ranges, + }, + TO_REDACT, + ) diff --git a/homeassistant/components/lyngdorf/quality_scale.yaml b/homeassistant/components/lyngdorf/quality_scale.yaml index 215d6c074bbe65..2974ff508323b1 100644 --- a/homeassistant/components/lyngdorf/quality_scale.yaml +++ b/homeassistant/components/lyngdorf/quality_scale.yaml @@ -51,7 +51,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery: done discovery-update-info: todo docs-data-update: todo diff --git a/homeassistant/components/midea/__init__.py b/homeassistant/components/midea/__init__.py index d283668f05ee57..51605275c33051 100644 --- a/homeassistant/components/midea/__init__.py +++ b/homeassistant/components/midea/__init__.py @@ -20,7 +20,7 @@ from .const import CONF_KEY, CONF_SUBTYPE from .entity import MideaConfigEntry -_PLATFORMS: list[Platform] = [Platform.CLIMATE] +_PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.HUMIDIFIER] async def async_setup_entry(hass: HomeAssistant, entry: MideaConfigEntry) -> bool: diff --git a/homeassistant/components/midea/device_catalog.py b/homeassistant/components/midea/device_catalog.py index ab1446a516d2b2..cef8589404aaa4 100644 --- a/homeassistant/components/midea/device_catalog.py +++ b/homeassistant/components/midea/device_catalog.py @@ -8,4 +8,6 @@ DeviceType.CC: "MDV Wi-Fi Controller", DeviceType.CF: "Heat Pump", DeviceType.FB: "Electric Heater", + DeviceType.A1: "Dehumidifier", + DeviceType.FD: "Humidifier", } diff --git a/homeassistant/components/midea/entity.py b/homeassistant/components/midea/entity.py index 02a612592425f1..98a18289cde556 100644 --- a/homeassistant/components/midea/entity.py +++ b/homeassistant/components/midea/entity.py @@ -1,10 +1,14 @@ """Base entity for Midea.""" +from collections.abc import Generator +from contextlib import contextmanager from typing import Any, override from midealocal.device import MideaDevice +from midealocal.exceptions import MideaLocalError from homeassistant.config_entries import ConfigEntry +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription @@ -14,6 +18,19 @@ type MideaConfigEntry = ConfigEntry[MideaDevice] +@contextmanager +def midea_api_call() -> Generator[None]: + """Translate midealocal device-communication errors into HomeAssistantError.""" + try: + yield + except MideaLocalError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_communication_error", + translation_placeholders={"error": str(err)}, + ) from err + + class MideaEntity(Entity): """Base Midea entity.""" diff --git a/homeassistant/components/midea/humidifier.py b/homeassistant/components/midea/humidifier.py new file mode 100644 index 00000000000000..7c1e57cdadc564 --- /dev/null +++ b/homeassistant/components/midea/humidifier.py @@ -0,0 +1,137 @@ +"""Humidifier for Midea.""" + +from dataclasses import dataclass +from typing import Any, override + +from midealocal.const import DeviceType +from midealocal.devices.a1 import MideaA1Device +from midealocal.devices.fd import MideaFDDevice + +from homeassistant.components.humidifier import ( + HumidifierDeviceClass, + HumidifierEntity, + HumidifierEntityDescription, + HumidifierEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import MideaConfigEntry, MideaEntity, midea_api_call + +PARALLEL_UPDATES = 0 + +type MideaHumidifierDevice = MideaA1Device | MideaFDDevice + + +@dataclass(kw_only=True, frozen=True) +class MideaHumidifierEntityDescription(HumidifierEntityDescription): + """Description for a Midea humidifier entity.""" + + models: list[DeviceType] + + +HUMIDIFIERS: list[MideaHumidifierEntityDescription] = [ + MideaHumidifierEntityDescription( + key="humidifier", + models=[DeviceType.A1], + device_class=HumidifierDeviceClass.DEHUMIDIFIER, + ), + MideaHumidifierEntityDescription( + key="humidifier", + models=[DeviceType.FD], + device_class=HumidifierDeviceClass.HUMIDIFIER, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MideaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up humidifiers for device.""" + device = config_entry.runtime_data + + async_add_entities( + MideaHumidifier(device, description) + for description in HUMIDIFIERS + if device.device_type in description.models + ) + + +class MideaHumidifier(MideaEntity, HumidifierEntity): + """Represent a Midea humidifier.""" + + _device: MideaHumidifierDevice + entity_description: MideaHumidifierEntityDescription + + _attr_min_humidity: float = 35 + _attr_max_humidity: float = 85 + _attr_supported_features = HumidifierEntityFeature.MODES + + def _float_attribute(self, attr: str) -> float | None: + """Return a device attribute as float, if convertible.""" + value = self._device.get_attribute(attr) + if not isinstance(value, (int, float, str)): + return None + return float(value) + + @property + @override + def current_humidity(self) -> float | None: + """Midea Humidifier current humidity.""" + return self._float_attribute("current_humidity") + + @property + @override + def target_humidity(self) -> float | None: + """Midea Humidifier target humidity.""" + return self._float_attribute("target_humidity") + + @property + @override + def mode(self) -> str | None: + """Midea Humidifier mode.""" + mode = self._device.get_attribute("mode") + if not isinstance(mode, str): + return None + return mode + + @property + @override + def available_modes(self) -> list[str]: + """Midea Humidifier available modes.""" + return self._device.modes + + @property + @override + def is_on(self) -> bool | None: + """Midea Humidifier is on.""" + power = self._device.get_attribute("power") + if not isinstance(power, bool): + return None + return power + + @override + def set_humidity(self, humidity: int) -> None: + """Midea Humidifier set humidity.""" + with midea_api_call(): + self._device.set_attribute(attr="target_humidity", value=humidity) + + @override + def set_mode(self, mode: str) -> None: + """Midea Humidifier set mode.""" + with midea_api_call(): + self._device.set_attribute(attr="mode", value=mode) + + @override + def turn_on(self, **kwargs: Any) -> None: + """Midea Humidifier turn on.""" + with midea_api_call(): + self._device.set_attribute(attr="power", value=True) + + @override + def turn_off(self, **kwargs: Any) -> None: + """Midea Humidifier turn off.""" + with midea_api_call(): + self._device.set_attribute(attr="power", value=False) diff --git a/homeassistant/components/midea/strings.json b/homeassistant/components/midea/strings.json index d8fe2947570c6e..3ab55b4f63496a 100644 --- a/homeassistant/components/midea/strings.json +++ b/homeassistant/components/midea/strings.json @@ -108,6 +108,9 @@ } }, "exceptions": { + "device_communication_error": { + "message": "Error communicating with the device: {error}" + }, "unsupported_hvac_mode": { "message": "HVAC mode {hvac_mode} is not supported by this device." } diff --git a/homeassistant/components/motioneye/__init__.py b/homeassistant/components/motioneye/__init__.py index e494e7df45a0eb..a7c94a62e2e679 100644 --- a/homeassistant/components/motioneye/__init__.py +++ b/homeassistant/components/motioneye/__init__.py @@ -467,7 +467,10 @@ def _get_media_event_data( # The file_path in the event is the full local filesystem path to the # media. To convert that to the media path that motionEye will # understand, we need to strip the root directory from the path. - if os.path.commonprefix([root_directory, event_file_path]) != root_directory: + try: + if os.path.commonpath([root_directory, event_file_path]) != root_directory: + return {} + except ValueError: return {} file_path = "/" + os.path.relpath(event_file_path, root_directory) diff --git a/homeassistant/components/overkiz/config_flow.py b/homeassistant/components/overkiz/config_flow.py index 7b100bef11943b..6c11e9ea401785 100644 --- a/homeassistant/components/overkiz/config_flow.py +++ b/homeassistant/components/overkiz/config_flow.py @@ -476,18 +476,13 @@ async def async_step_zeroconf( if discovery_info.type == "_kizboxdev._tcp.local.": self._host = f"{discovery_info.hostname[:-1]}:{discovery_info.port}" self._api_type = APIType.LOCAL - return await self._process_discovery( - gateway_id, updates={CONF_HOST: self._host} - ) return await self._process_discovery(gateway_id) - async def _process_discovery( - self, gateway_id: str, *, updates: dict[str, Any] | None = None - ) -> ConfigFlowResult: + async def _process_discovery(self, gateway_id: str) -> ConfigFlowResult: """Handle discovery of a gateway.""" await self.async_set_unique_id(gateway_id) - self._abort_if_unique_id_configured(updates=updates) + self._abort_if_unique_id_configured() self.context["title_placeholders"] = {"gateway_id": gateway_id} return await self.async_step_user() diff --git a/homeassistant/components/overkiz/quality_scale.yaml b/homeassistant/components/overkiz/quality_scale.yaml index 2a93686cf5fce1..ca6dd1b3117827 100644 --- a/homeassistant/components/overkiz/quality_scale.yaml +++ b/homeassistant/components/overkiz/quality_scale.yaml @@ -41,7 +41,7 @@ rules: # Gold docs-examples: todo - discovery-update-info: done + discovery-update-info: todo entity-device-class: done entity-translations: todo docs-data-update: done diff --git a/homeassistant/components/ps4/__init__.py b/homeassistant/components/ps4/__init__.py index b5e8e317b710ed..564a2ef2d24048 100644 --- a/homeassistant/components/ps4/__init__.py +++ b/homeassistant/components/ps4/__init__.py @@ -16,7 +16,7 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LOCKED, CONF_REGION, CONF_TOKEN, Platform -from homeassistant.core import HomeAssistant, split_entity_id +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -101,39 +101,26 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: country, ) - # Migrate Version 2 -> Version 3: Update identifier format. + # Migrate Version 2 -> Version 3: Update unique_id format. if version == 2: - # Prevent changing entity_id. Updates entity registry. + # Update the unique_id. registry = er.async_get(hass) for e_entry in registry.entities.get_entries_for_config_entry_id( entry.entry_id ): - unique_id = e_entry.unique_id - entity_id = e_entry.entity_id - - # Remove old entity entry. - registry.async_remove(entity_id) - - # Format old unique_id. - unique_id = format_unique_id(entry.data[CONF_TOKEN], unique_id) - - # Create new entry with old entity_id. - new_id = split_entity_id(entity_id)[1] - registry.async_get_or_create( - "media_player", - DOMAIN, - unique_id, - config_entry=entry, - device_id=e_entry.device_id, - object_id_base=new_id, + registry.async_update_entity( + e_entry.entity_id, + new_unique_id=format_unique_id( + entry.data[CONF_TOKEN], e_entry.unique_id + ), ) _LOGGER.debug( - "PlayStation 4 identifier for entity: %s has changed", - entity_id, + "PlayStation 4 unique_id for entity %s has been updated", + e_entry.entity_id, ) - config_entries.async_update_entry(entry, version=3) - return True + config_entries.async_update_entry(entry, version=3) + return True msg = f"""{reason[version]} for the PlayStation 4 Integration. Please remove the PS4 Integration and re-configure diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index b2eb1dedf96479..c2fcbe6682ab96 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "SQLAlchemy==2.0.51", + "SQLAlchemy==2.0.52", "fnv-hash-fast==2.0.3", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index c8cfc68f517c90..2a5a70ab1e6486 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -63,11 +63,10 @@ "cool": HVACMode.COOL, "dry": HVACMode.DRY, "heat": HVACMode.HEAT, + "floor_heating": HVACMode.HEAT, "ventilation": HVACMode.FAN_ONLY, } -HA_TO_THERMOSTAT_MODE = {value: key for key, value in THERMOSTAT_TO_HA_MODE.items()} - PRESET_FROST_PROTECTION = "frost_protection" @@ -138,6 +137,9 @@ def __init__( self._attr_hvac_modes = [HVACMode.OFF] + [ THERMOSTAT_TO_HA_MODE[mode] for mode in modes ] + self._ha_to_thermostat_mode = { + THERMOSTAT_TO_HA_MODE[mode]: mode for mode in modes + } @property def _status(self) -> dict[str, Any]: @@ -253,7 +255,7 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: await self.coordinator.device.enum_set( get_rpc_key_id(self._working_mode_key), - HA_TO_THERMOSTAT_MODE[hvac_mode], + self._ha_to_thermostat_mode[hvac_mode], ) @override diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 730f02232477b7..bfaf3458a9e321 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -6,5 +6,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.51", "sqlparse==0.5.5"] + "requirements": ["SQLAlchemy==2.0.52", "sqlparse==0.5.5"] } diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 0df8f8136d4156..e32579c3cac7f1 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -485,17 +485,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - ), ) - # Register listeners for polling vehicle sw_version updates - for vehicle_data in vehicles: - if vehicle_data.poll: - entry.async_on_unload( - vehicle_data.coordinator.async_add_listener( - create_vehicle_polling_listener( - hass, vehicle_data.vin, entry.entry_id, vehicle_data.coordinator - ) - ) - ) - # Setup energy devices with models, versions, and listeners for energysite in energysites: async_setup_energy_device(hass, entry, energysite, device_registry) @@ -663,24 +652,6 @@ def handle_version(value: str | None) -> None: return handle_version -def create_vehicle_polling_listener( - hass: HomeAssistant, - vin: str, - config_entry_id: str, - coordinator: TeslemetryVehicleDataCoordinator, -) -> Callable[[], None]: - """Create a listener for vehicle polling coordinator updates.""" - - def handle_update() -> None: - """Handle coordinator update.""" - if version := coordinator.data.get("vehicle_state_car_version"): - # Remove build from version (e.g., "2024.44.25 abc123" -> "2024.44.25") - sw_version = version.split(" ")[0] - async_update_device_sw_version(hass, vin, config_entry_id, sw_version) - - return handle_update - - def create_energy_info_listener( hass: HomeAssistant, site_id: int, diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index 71722939481634..43f512d5359452 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -25,7 +25,7 @@ from . import TeslemetryConfigEntry from .const import DOMAIN, ENERGY_HISTORY_FIELDS, LOGGER -from .helpers import flatten +from .helpers import async_update_device_sw_version, flatten RETRY_EXCEPTIONS = ( InvalidResponse, @@ -113,6 +113,7 @@ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching data from the Teslemetry API.""" config_entry: TeslemetryConfigEntry + vin: str def __init__( self, @@ -133,6 +134,7 @@ def __init__( self.update_interval = VEHICLE_INTERVAL self.api = api + self.vin = product["vin"] self.data = flatten(product) @override @@ -162,7 +164,15 @@ async def _async_update_data(self) -> dict[str, Any]: translation_placeholders={"message": e.message}, ) from e - return flatten(data) + data = flatten(data) + if version := data.get("vehicle_state_car_version"): + # Consume firmware opportunistically rather than through a listener + # that would keep this coordinator polling after every entity is + # disabled. Drop the build suffix (e.g. "2024.44.25 x" -> "2024.44.25"). + async_update_device_sw_version( + self.hass, self.vin, self.config_entry.entry_id, version.split(" ")[0] + ) + return data class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]): diff --git a/homeassistant/components/transmission/coordinator.py b/homeassistant/components/transmission/coordinator.py index 0c29cd65fa7186..2c37dddf6d5d9c 100644 --- a/homeassistant/components/transmission/coordinator.py +++ b/homeassistant/components/transmission/coordinator.py @@ -69,6 +69,7 @@ def __init__( self._started_torrents: list[transmission_rpc.Torrent] = [] self._event_listeners: dict[str, EventCallback] = {} self.torrents: list[transmission_rpc.Torrent] = [] + self.download_dir_free_space: int | None = None super().__init__( hass, config_entry=entry, @@ -124,6 +125,14 @@ def update(self) -> SessionStats: except transmission_rpc.TransmissionError as err: raise UpdateFailed("Unable to connect to Transmission client") from err + try: + self.download_dir_free_space = self.api.free_space( + self._session.download_dir + ) + except (transmission_rpc.TransmissionError, KeyError) as err: + _LOGGER.debug("Unable to fetch download directory free space: %s", err) + self.download_dir_free_space = None + return data def init_torrent_list(self) -> None: diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index ce218bbdf541ac..15796032e52322 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -79,6 +79,16 @@ def _compute_ratio(uploaded: int | None, downloaded: int | None) -> float | None coordinator.data.upload_speed, coordinator.data.download_speed ), ), + TransmissionSensorEntityDescription( + key="download_dir_free_space", + translation_key="download_dir_free_space", + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + val_func=lambda coordinator: coordinator.download_dir_free_space, + ), TransmissionSensorEntityDescription( key="active_torrents", translation_key="active_torrents", diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json index 34d229b9e6130c..b9e5abb18b5b36 100644 --- a/homeassistant/components/transmission/strings.json +++ b/homeassistant/components/transmission/strings.json @@ -65,6 +65,9 @@ "name": "Completed torrents", "unit_of_measurement": "[%key:component::transmission::entity::sensor::active_torrents::unit_of_measurement%]" }, + "download_dir_free_space": { + "name": "Available disk space" + }, "download_speed": { "name": "Download speed" }, diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index dabe83e0572835..e796922cc4af5c 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -25,5 +25,5 @@ "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["xiaomi-ble==1.11.0"] + "requirements": ["xiaomi-ble==1.16.0"] } diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index abeeed3ffce319..d2c57ccbab6ea2 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -2910,7 +2910,7 @@ def forward_stats(event: dict) -> None: { "event": "statistics updated", "source": "node", - "nodeId": node.node_id, + "node_id": node.node_id, **_get_node_statistics_dict(hass, node.statistics), }, ) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index ce3969549838e0..773b322f3bfec7 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -24,6 +24,7 @@ from homeassistant.config_entries import ( SOURCE_ESPHOME, SOURCE_USB, + SOURCE_ZEROCONF, ConfigEntry, ConfigEntryState, ConfigFlow, @@ -99,6 +100,15 @@ ON_SUPERVISOR_SCHEMA = vol.Schema({vol.Optional(CONF_USE_ADDON, default=True): bool}) MIN_MIGRATION_SDK_VERSION = AwesomeVersion("6.61") +# Steps at which another flow is only showing a discovery prompt and can be +# aborted safely when a config entry is created by a different flow. +DISCOVERY_PROMPT_STEPS = { + "confirm_usb_migration", + "hassio_confirm", + "installation_type", + "zeroconf_confirm", +} + NETWORK_TYPE_NEW = "new" NETWORK_TYPE_EXISTING = "existing" ZWAVE_JS_SERVER_INSTRUCTIONS = ( @@ -220,6 +230,7 @@ def __init__(self) -> None: self._adapter_discovered = False self._recommended_install = False self._rf_region: str | None = None + self._entry_unloaded_by_flow = False async def async_step_install_addon( self, user_input: dict[str, Any] | None = None @@ -503,12 +514,14 @@ async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResu if any( flow for flow in self._async_in_progress() - if flow["context"].get("source") != SOURCE_USB + if flow["context"].get("source") not in (SOURCE_USB, SOURCE_ZEROCONF) ): # Allow multiple USB discovery flows to be in progress. # Migration requires more than one USB stick to be connected, # which can cause more than one discovery flow to be in progress, # at least for a short time. + # Zeroconf flows never touch the add-on, + # so an idle discovery prompt should not block USB discovery. return self.async_abort(reason="already_in_progress") if current_config_entries := self._async_current_entries(include_ignore=False): self._reconfigure_config_entry = next( @@ -650,7 +663,13 @@ async def async_step_hassio( This flow is triggered by the Z-Wave JS add-on. """ - if self._async_in_progress(): + if any( + flow + for flow in self._async_in_progress() + # Zeroconf flows never touch the add-on, so an idle discovery + # prompt should not block the add-on discovery. + if flow["context"].get("source") != SOURCE_ZEROCONF + ): return self.async_abort(reason="already_in_progress") if discovery_info.slug != ADDON_SLUG: @@ -992,6 +1011,21 @@ async def async_step_finish_addon_setup_user( str(self.version_info.home_id), raise_on_progress=False ) + if ( + existing_entry := next( + ( + entry + for entry in self._async_current_entries(include_ignore=False) + if entry.unique_id == self.unique_id + ), + None, + ) + ) and not existing_entry.data.get(CONF_USE_ADDON): + # The controller is already configured against another server, + # e.g. via zeroconf discovery, so don't rewrite that entry + # with add-on data. + return self.async_abort(reason="already_configured") + # When we came from discovery, make sure we update the add-on if self._adapter_discovered and self.use_addon: await self._async_set_addon_config( @@ -1033,9 +1067,13 @@ async def async_step_finish_addon_setup_user( @callback def _async_create_entry_from_vars(self) -> ConfigFlowResult: """Return a config entry for the flow.""" - # Abort any other flows that may be in progress + # Abort other flows that are still at a discovery prompt, since the + # new entry may make them redundant. Flows that have progressed + # further, e.g. a migration that has backed up the network, + # must not be interrupted. for progress in self._async_in_progress(): - self.hass.config_entries.flow.async_abort(progress["flow_id"]) + if progress.get("step_id") in DISCOVERY_PROMPT_STEPS: + self.hass.config_entries.flow.async_abort(progress["flow_id"]) return self.async_create_entry( title=TITLE, @@ -1062,8 +1100,43 @@ def _async_update_entry(self, updates: dict[str, Any]) -> None: self.hass.config_entries.async_update_entry( config_entry, data=config_entry.data | updates ) + self._async_schedule_entry_reload() + + async def _async_unload_entry_for_flow(self) -> None: + """Unload the config entry being reconfigured for this flow. + + The entry is reloaded when the flow is removed, + unless a flow step schedules a reload itself. + """ + config_entry = self._reconfigure_config_entry + assert config_entry is not None + self._entry_unloaded_by_flow = True + await self.hass.config_entries.async_unload(config_entry.entry_id) + + @callback + def _async_schedule_entry_reload(self) -> None: + """Schedule a reload of the config entry being reconfigured.""" + config_entry = self._reconfigure_config_entry + assert config_entry is not None + self._entry_unloaded_by_flow = False self.hass.config_entries.async_schedule_reload(config_entry.entry_id) + @override + @callback + def async_remove(self) -> None: + """Reload the config entry if the flow unloaded it and left it down. + + This recovers the entry when a flow that has unloaded it, + e.g. a migration waiting for the adapter to be unplugged, + is aborted or abandoned. + """ + if not self._entry_unloaded_by_flow: + return + config_entry = self._reconfigure_config_entry + assert config_entry is not None + if config_entry.state is ConfigEntryState.NOT_LOADED: + self.hass.config_entries.async_schedule_reload(config_entry.entry_id) + async def async_step_intent_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -1179,7 +1252,7 @@ async def async_step_instruct_unplug( assert config_entry is not None # Unload the config entry before asking the user to unplug the controller. - await self.hass.config_entries.async_unload(config_entry.entry_id) + await self._async_unload_entry_for_flow() return self.async_show_form( step_id="instruct_unplug", @@ -1256,16 +1329,14 @@ async def async_step_on_supervisor_reconfigure( if not user_input[CONF_USE_ADDON]: if config_entry.data.get(CONF_USE_ADDON): # Unload the config entry before stopping the add-on. - await self.hass.config_entries.async_unload(config_entry.entry_id) + await self._async_unload_entry_for_flow() addon_manager = get_addon_manager(self.hass) _LOGGER.debug("Stopping Z-Wave JS app") try: await addon_manager.async_stop_addon() except AddonError as err: _LOGGER.error(err) - self.hass.config_entries.async_schedule_reload( - config_entry.entry_id - ) + self._async_schedule_entry_reload() raise AbortFlow("addon_stop_failed") from err return await self.async_step_manual_reconfigure() @@ -1330,7 +1401,7 @@ async def async_step_configure_addon_reconfigure( config_entry := self._reconfigure_config_entry ) and config_entry.data.get(CONF_USE_ADDON): # Disconnect integration before restarting add-on. - await self.hass.config_entries.async_unload(config_entry.entry_id) + await self._async_unload_entry_for_flow() return await self.async_step_start_addon() @@ -1658,7 +1729,7 @@ async def async_revert_addon_config(self, reason: str) -> ConfigFlowResult: if self.revert_reason or not self.original_addon_config: config_entry = self._reconfigure_config_entry assert config_entry is not None - self.hass.config_entries.async_schedule_reload(config_entry.entry_id) + self._async_schedule_entry_reload() return self.async_abort(reason=reason) self.revert_reason = reason diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7a621339451073..5fa44636f0055a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -47,7 +47,7 @@ Jinja2==3.1.6 lru-dict==1.4.1 mutagen==1.48.1 openai==2.45.0 -orjson==3.11.9 +orjson==3.12.0 packaging>=23.1 paho-mqtt==2.1.0 Pillow==12.3.0 @@ -64,13 +64,13 @@ PyYAML==6.0.3 requests==2.34.2 securetar==2026.4.1 serialx==1.8.2 -SQLAlchemy==2.0.51 +SQLAlchemy==2.0.52 standard-aifc==3.13.0 standard-telnetlib==3.13.0 typing-extensions>=4.16.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.12.3 +uv==0.12.5 voluptuous-openapi==0.4.1 voluptuous-serialize==2.7.0 voluptuous==0.15.2 diff --git a/pyproject.toml b/pyproject.toml index 4236dd14421d2b..fd9805928ba253 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,20 +61,20 @@ dependencies = [ "Pillow==12.3.0", "propcache==0.5.2", "pyOpenSSL==26.2.0", - "orjson==3.11.9", + "orjson==3.12.0", "packaging>=23.1", "psutil-home-assistant==0.0.1", "python-slugify==8.0.4", "PyYAML==6.0.3", "requests==2.34.2", "securetar==2026.4.1", - "SQLAlchemy==2.0.51", + "SQLAlchemy==2.0.52", "standard-aifc==3.13.0", "standard-telnetlib==3.13.0", "typing-extensions>=4.16.0,<5.0", "ulid-transform==2.2.9", "urllib3>=2.0", - "uv==0.12.3", + "uv==0.12.5", "voluptuous==0.15.2", "voluptuous-serialize==2.7.0", "voluptuous-openapi==0.4.1", @@ -641,7 +641,7 @@ exclude_lines = [ ] [tool.ruff] -required-version = ">=0.16.2" +required-version = ">=0.16.3" [tool.ruff.lint] select = [ diff --git a/requirements.txt b/requirements.txt index 8629fc8512ccfb..4b7543a0d0076c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,7 +34,7 @@ infrared-protocols==9.0.0 Jinja2==3.1.6 lru-dict==1.4.1 mutagen==1.48.1 -orjson==3.11.9 +orjson==3.12.0 packaging>=23.1 Pillow==12.3.0 propcache==0.5.2 @@ -49,13 +49,13 @@ PyYAML==6.0.3 requests==2.34.2 rf-protocols==4.3.0 securetar==2026.4.1 -SQLAlchemy==2.0.51 +SQLAlchemy==2.0.52 standard-aifc==3.13.0 standard-telnetlib==3.13.0 typing-extensions>=4.16.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.12.3 +uv==0.12.5 voluptuous-openapi==0.4.1 voluptuous-serialize==2.7.0 voluptuous==0.15.2 diff --git a/requirements_all.txt b/requirements_all.txt index 18667c582e1fc7..c7b8b0f088f462 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -115,7 +115,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.51 +SQLAlchemy==2.0.52 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 @@ -3412,7 +3412,7 @@ wsdot==0.0.1 wyoming==1.10.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==1.11.0 +xiaomi-ble==1.16.0 # homeassistant.components.knx xknx==3.20.0 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 937c4142ca066d..31ef76e3d12a62 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,6 +1,6 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.4.3 -ruff==0.16.2 +ruff==0.16.3 yamllint==1.38.0 zizmor==1.29.0 diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 3b852d7883fc6b..fcf3212f06081a 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -492,6 +492,54 @@ async def test_duplicated_names_resolved_with_device_area( assert result.response.intent.slots.get("name", {}).get("text") == name +@pytest.mark.usefixtures("init_components") +async def test_device_rename_refreshes_slot_list( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test renaming a device makes the entity matchable by its new computed name.""" + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections=set(), + identifiers={("demo", "device-1")}, + name="Kitchen", + ) + + light = entity_registry.async_get_or_create( + "light", + "demo", + "1234", + device_id=device.id, + has_entity_name=True, + original_name="Light", + ) + hass.states.async_set(light.entity_id, "off") + expose_entity(hass, light.entity_id, True) + + # Populate the slot list cache: the current computed name matches. + calls = async_mock_service(hass, "light", "turn_on") + result = await conversation.async_converse( + hass, "turn on Kitchen Light", None, Context(), None + ) + assert result.response.response_type is intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + + # Renaming the device changes the light's computed name to "Bedroom Light". + device_registry.async_update_device(device.id, name_by_user="Bedroom") + await hass.async_block_till_done() + + # The new name is now matchable. + calls = async_mock_service(hass, "light", "turn_on") + result = await conversation.async_converse( + hass, "turn on Bedroom Light", None, Context(), None + ) + assert result.response.response_type is intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + + @pytest.mark.usefixtures("init_components") async def test_trigger_sentences(hass: HomeAssistant) -> None: """Test registering/unregistering/matching a few trigger sentences.""" diff --git a/tests/components/ecovacs/snapshots/test_sensor.ambr b/tests/components/ecovacs/snapshots/test_sensor.ambr index a015e9f68c9c32..c1041018551383 100644 --- a/tests/components/ecovacs/snapshots/test_sensor.ambr +++ b/tests/components/ecovacs/snapshots/test_sensor.ambr @@ -213,7 +213,7 @@ 'sensor.e1234567890000000003_filter_lifespan', ]) # --- -# name: test_sensors[5xu9h3][sensor.goat_g1_area_cleaned:entity-registry] +# name: test_sensors[5xu9h3][sensor.goat_g1_area_mowed:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -227,7 +227,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.goat_g1_area_cleaned', + 'entity_id': 'sensor.goat_g1_area_mowed', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -235,7 +235,7 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Area cleaned', + 'object_id_base': 'Area mowed', 'options': dict({ 'sensor': dict({ 'suggested_display_precision': 2, @@ -246,25 +246,25 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Area cleaned', + 'original_name': 'Area mowed', 'platform': 'ecovacs', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'stats_area', + 'translation_key': 'stats_area_mower', 'unique_id': '8516fbb1-17f1-4194-0000000_stats_area', 'unit_of_measurement': , }) # --- -# name: test_sensors[5xu9h3][sensor.goat_g1_area_cleaned:state] +# name: test_sensors[5xu9h3][sensor.goat_g1_area_mowed:state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'area', - : 'Goat G1 Area cleaned', + : 'Goat G1 Area mowed', : , }), 'context': , - 'entity_id': 'sensor.goat_g1_area_cleaned', + 'entity_id': 'sensor.goat_g1_area_mowed', 'last_changed': , 'last_reported': , 'last_updated': , @@ -374,7 +374,7 @@ 'state': 'unknown', }) # --- -# name: test_sensors[5xu9h3][sensor.goat_g1_cleaning_duration:entity-registry] +# name: test_sensors[5xu9h3][sensor.goat_g1_error:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -387,8 +387,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.goat_g1_cleaning_duration', + 'entity_category': , + 'entity_id': 'sensor.goat_g1_error', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -396,43 +396,36 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Cleaning duration', + 'object_id_base': 'Error', 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, - 'original_name': 'Cleaning duration', + 'original_name': 'Error', 'platform': 'ecovacs', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'stats_time', - 'unique_id': '8516fbb1-17f1-4194-0000000_stats_time', - 'unit_of_measurement': , + 'translation_key': 'error', + 'unique_id': '8516fbb1-17f1-4194-0000000_error', + 'unit_of_measurement': None, }) # --- -# name: test_sensors[5xu9h3][sensor.goat_g1_cleaning_duration:state] +# name: test_sensors[5xu9h3][sensor.goat_g1_error:state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'duration', - : 'Goat G1 Cleaning duration', - : , + 'description': 'NoError: Robot is operational', + : 'Goat G1 Error', }), 'context': , - 'entity_id': 'sensor.goat_g1_cleaning_duration', + 'entity_id': 'sensor.goat_g1_error', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '5.0', + 'state': '0', }) # --- -# name: test_sensors[5xu9h3][sensor.goat_g1_error:entity-registry] +# name: test_sensors[5xu9h3][sensor.goat_g1_ip_address:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -446,7 +439,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.goat_g1_error', + 'entity_id': 'sensor.goat_g1_ip_address', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -454,36 +447,35 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Error', + 'object_id_base': 'IP address', 'options': dict({ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Error', + 'original_name': 'IP address', 'platform': 'ecovacs', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'error', - 'unique_id': '8516fbb1-17f1-4194-0000000_error', + 'translation_key': 'network_ip', + 'unique_id': '8516fbb1-17f1-4194-0000000_network_ip', 'unit_of_measurement': None, }) # --- -# name: test_sensors[5xu9h3][sensor.goat_g1_error:state] +# name: test_sensors[5xu9h3][sensor.goat_g1_ip_address:state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'description': 'NoError: Robot is operational', - : 'Goat G1 Error', + : 'Goat G1 IP address', }), 'context': , - 'entity_id': 'sensor.goat_g1_error', + 'entity_id': 'sensor.goat_g1_ip_address', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '192.168.0.10', }) # --- -# name: test_sensors[5xu9h3][sensor.goat_g1_ip_address:entity-registry] +# name: test_sensors[5xu9h3][sensor.goat_g1_lens_brush_lifespan:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -497,7 +489,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.goat_g1_ip_address', + 'entity_id': 'sensor.goat_g1_lens_brush_lifespan', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -505,35 +497,36 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'IP address', + 'object_id_base': 'Lens brush lifespan', 'options': dict({ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'IP address', + 'original_name': 'Lens brush lifespan', 'platform': 'ecovacs', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'network_ip', - 'unique_id': '8516fbb1-17f1-4194-0000000_network_ip', - 'unit_of_measurement': None, + 'translation_key': 'lifespan_lens_brush', + 'unique_id': '8516fbb1-17f1-4194-0000000_lifespan_lens_brush', + 'unit_of_measurement': '%', }) # --- -# name: test_sensors[5xu9h3][sensor.goat_g1_ip_address:state] +# name: test_sensors[5xu9h3][sensor.goat_g1_lens_brush_lifespan:state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Goat G1 IP address', + : 'Goat G1 Lens brush lifespan', + : '%', }), 'context': , - 'entity_id': 'sensor.goat_g1_ip_address', + 'entity_id': 'sensor.goat_g1_lens_brush_lifespan', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '192.168.0.10', + 'state': 'unknown', }) # --- -# name: test_sensors[5xu9h3][sensor.goat_g1_lens_brush_lifespan:entity-registry] +# name: test_sensors[5xu9h3][sensor.goat_g1_mowing_duration:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -546,8 +539,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.goat_g1_lens_brush_lifespan', + 'entity_category': None, + 'entity_id': 'sensor.goat_g1_mowing_duration', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -555,36 +548,43 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Lens brush lifespan', + 'object_id_base': 'Mowing duration', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, - 'original_name': 'Lens brush lifespan', + 'original_name': 'Mowing duration', 'platform': 'ecovacs', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'lifespan_lens_brush', - 'unique_id': '8516fbb1-17f1-4194-0000000_lifespan_lens_brush', - 'unit_of_measurement': '%', + 'translation_key': 'stats_time_mower', + 'unique_id': '8516fbb1-17f1-4194-0000000_stats_time', + 'unit_of_measurement': , }) # --- -# name: test_sensors[5xu9h3][sensor.goat_g1_lens_brush_lifespan:state] +# name: test_sensors[5xu9h3][sensor.goat_g1_mowing_duration:state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Goat G1 Lens brush lifespan', - : '%', + : 'duration', + : 'Goat G1 Mowing duration', + : , }), 'context': , - 'entity_id': 'sensor.goat_g1_lens_brush_lifespan', + 'entity_id': 'sensor.goat_g1_mowing_duration', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '5.0', }) # --- -# name: test_sensors[5xu9h3][sensor.goat_g1_total_area_cleaned:entity-registry] +# name: test_sensors[5xu9h3][sensor.goat_g1_total_area_mowed:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -600,7 +600,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.goat_g1_total_area_cleaned', + 'entity_id': 'sensor.goat_g1_total_area_mowed', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -608,7 +608,7 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Total area cleaned', + 'object_id_base': 'Total area mowed', 'options': dict({ 'sensor': dict({ 'suggested_display_precision': 2, @@ -616,33 +616,33 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Total area cleaned', + 'original_name': 'Total area mowed', 'platform': 'ecovacs', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'total_stats_area', + 'translation_key': 'total_stats_area_mower', 'unique_id': '8516fbb1-17f1-4194-0000000_total_stats_area', 'unit_of_measurement': , }) # --- -# name: test_sensors[5xu9h3][sensor.goat_g1_total_area_cleaned:state] +# name: test_sensors[5xu9h3][sensor.goat_g1_total_area_mowed:state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'area', - : 'Goat G1 Total area cleaned', + : 'Goat G1 Total area mowed', : , : , }), 'context': , - 'entity_id': 'sensor.goat_g1_total_area_cleaned', + 'entity_id': 'sensor.goat_g1_total_area_mowed', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '60', }) # --- -# name: test_sensors[5xu9h3][sensor.goat_g1_total_cleaning_duration:entity-registry] +# name: test_sensors[5xu9h3][sensor.goat_g1_total_mowing_duration:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -658,7 +658,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.goat_g1_total_cleaning_duration', + 'entity_id': 'sensor.goat_g1_total_mowing_duration', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -666,7 +666,7 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Total cleaning duration', + 'object_id_base': 'Total mowing duration', 'options': dict({ 'sensor': dict({ 'suggested_display_precision': 2, @@ -677,33 +677,33 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Total cleaning duration', + 'original_name': 'Total mowing duration', 'platform': 'ecovacs', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'total_stats_time', + 'translation_key': 'total_stats_time_mower', 'unique_id': '8516fbb1-17f1-4194-0000000_total_stats_time', 'unit_of_measurement': , }) # --- -# name: test_sensors[5xu9h3][sensor.goat_g1_total_cleaning_duration:state] +# name: test_sensors[5xu9h3][sensor.goat_g1_total_mowing_duration:state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'duration', - : 'Goat G1 Total cleaning duration', + : 'Goat G1 Total mowing duration', : , : , }), 'context': , - 'entity_id': 'sensor.goat_g1_total_cleaning_duration', + 'entity_id': 'sensor.goat_g1_total_mowing_duration', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '40.0', }) # --- -# name: test_sensors[5xu9h3][sensor.goat_g1_total_cleanings:entity-registry] +# name: test_sensors[5xu9h3][sensor.goat_g1_total_mowings:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -719,7 +719,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.goat_g1_total_cleanings', + 'entity_id': 'sensor.goat_g1_total_mowings', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -727,29 +727,29 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Total cleanings', + 'object_id_base': 'Total mowings', 'options': dict({ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Total cleanings', + 'original_name': 'Total mowings', 'platform': 'ecovacs', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'total_stats_cleanings', + 'translation_key': 'total_stats_cleanings_mower', 'unique_id': '8516fbb1-17f1-4194-0000000_total_stats_cleanings', 'unit_of_measurement': None, }) # --- -# name: test_sensors[5xu9h3][sensor.goat_g1_total_cleanings:state] +# name: test_sensors[5xu9h3][sensor.goat_g1_total_mowings:state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Goat G1 Total cleanings', + : 'Goat G1 Total mowings', : , }), 'context': , - 'entity_id': 'sensor.goat_g1_total_cleanings', + 'entity_id': 'sensor.goat_g1_total_mowings', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/ecovacs/test_sensor.py b/tests/components/ecovacs/test_sensor.py index 8ef76e215d4d74..2695f6fe7bd4a2 100644 --- a/tests/components/ecovacs/test_sensor.py +++ b/tests/components/ecovacs/test_sensor.py @@ -77,11 +77,11 @@ async def notify_events(hass: HomeAssistant, event_bus: EventBus): ( "5xu9h3", [ - "sensor.goat_g1_area_cleaned", - "sensor.goat_g1_cleaning_duration", - "sensor.goat_g1_total_area_cleaned", - "sensor.goat_g1_total_cleaning_duration", - "sensor.goat_g1_total_cleanings", + "sensor.goat_g1_area_mowed", + "sensor.goat_g1_mowing_duration", + "sensor.goat_g1_total_area_mowed", + "sensor.goat_g1_total_mowing_duration", + "sensor.goat_g1_total_mowings", "sensor.goat_g1_battery", "sensor.goat_g1_ip_address", "sensor.goat_g1_wi_fi_rssi", diff --git a/tests/components/forked_daapd/test_media_player.py b/tests/components/forked_daapd/test_media_player.py index d3bbcb4ec2bdae..9f8d3d05d87c41 100644 --- a/tests/components/forked_daapd/test_media_player.py +++ b/tests/components/forked_daapd/test_media_player.py @@ -20,6 +20,7 @@ SUPPORTED_FEATURES_ZONE, ) from homeassistant.components.media_player import ( + ATTR_GROUP_MEMBERS, ATTR_INPUT_SOURCE, ATTR_MEDIA_ALBUM_ARTIST, ATTR_MEDIA_ALBUM_NAME, @@ -38,6 +39,7 @@ ATTR_MEDIA_VOLUME_MUTED, DOMAIN as MP_DOMAIN, SERVICE_CLEAR_PLAYLIST, + SERVICE_JOIN, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, @@ -50,6 +52,7 @@ SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, + SERVICE_UNJOIN, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, MediaPlayerEnqueue, @@ -65,6 +68,8 @@ STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant, ServiceResponse +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, async_mock_signal @@ -461,6 +466,89 @@ async def test_zone(hass: HomeAssistant, mock_api_object: Mock) -> None: mock_api_object.change_output.assert_any_call(output_id, selected=True) +async def test_join_players(hass: HomeAssistant, mock_api_object: Mock) -> None: + """Test joining players enables the corresponding outputs.""" + await _service_call( + hass, + TEST_MASTER_ENTITY_NAME, + SERVICE_JOIN, + {ATTR_GROUP_MEMBERS: [TEST_ZONE_ENTITY_NAMES[2], TEST_MASTER_ENTITY_NAME]}, + ) + mock_api_object.change_output.assert_called_once_with( + SAMPLE_OUTPUTS_ON[2]["id"], selected=True + ) + + +async def test_join_players_unknown_entity( + hass: HomeAssistant, mock_api_object: Mock +) -> None: + """Test joining an unknown entity raises.""" + with pytest.raises( + ServiceValidationError, match="media_player.nonexistent not found" + ): + await _service_call( + hass, + TEST_MASTER_ENTITY_NAME, + SERVICE_JOIN, + {ATTR_GROUP_MEMBERS: ["media_player.nonexistent"]}, + ) + mock_api_object.change_output.assert_not_called() + + +async def test_join_players_foreign_entity( + hass: HomeAssistant, + mock_api_object: Mock, + entity_registry: er.EntityRegistry, +) -> None: + """Test joining an entity from another platform raises.""" + entity_registry.async_get_or_create(MP_DOMAIN, "other_platform", "some_unique_id") + with pytest.raises(ServiceValidationError, match="not an output"): + await _service_call( + hass, + TEST_MASTER_ENTITY_NAME, + SERVICE_JOIN, + {ATTR_GROUP_MEMBERS: ["media_player.other_platform_some_unique_id"]}, + ) + mock_api_object.change_output.assert_not_called() + + +async def test_join_players_stale_output( + hass: HomeAssistant, + mock_api_object: Mock, + get_request_return_values: dict[str, Any], +) -> None: + """Test joining a zone whose output disappeared from the server raises.""" + get_request_return_values["outputs"] = SAMPLE_OUTPUTS_UNSELECTED[:2] + updater_update = mock_api_object.start_websocket_handler.call_args[0][2] + await updater_update(["outputs"]) + await hass.async_block_till_done() + with pytest.raises(ServiceValidationError, match="no longer exists"): + await _service_call( + hass, + TEST_MASTER_ENTITY_NAME, + SERVICE_JOIN, + {ATTR_GROUP_MEMBERS: [TEST_ZONE_ENTITY_NAMES[2]]}, + ) + mock_api_object.change_output.assert_not_called() + + +async def test_unjoin_players(hass: HomeAssistant, mock_api_object: Mock) -> None: + """Test unjoining disables all outputs.""" + await _service_call(hass, TEST_MASTER_ENTITY_NAME, SERVICE_UNJOIN) + mock_api_object.set_enabled_outputs.assert_called_once_with([]) + + +@pytest.mark.usefixtures("mock_api_object") +def test_group_members(hass: HomeAssistant) -> None: + """Test group_members lists the master and all selected outputs.""" + state = hass.states.get(TEST_MASTER_ENTITY_NAME) + assert state.attributes[ATTR_GROUP_MEMBERS] == [ + TEST_MASTER_ENTITY_NAME, + TEST_ZONE_ENTITY_NAMES[0], + TEST_ZONE_ENTITY_NAMES[1], + ] + + async def test_last_outputs_master(hass: HomeAssistant, mock_api_object: Mock) -> None: """Test restoration of _last_outputs.""" # Test turning on sends API call diff --git a/tests/components/geonetnz_volcano/test_config_flow.py b/tests/components/geonetnz_volcano/test_config_flow.py index 110fb3b0a9ea3e..694d7f31219d9a 100644 --- a/tests/components/geonetnz_volcano/test_config_flow.py +++ b/tests/components/geonetnz_volcano/test_config_flow.py @@ -18,20 +18,27 @@ async def test_duplicate_error(hass: HomeAssistant, config_entry) -> None: """Test that errors are shown when duplicates are added.""" - conf = {CONF_LATITUDE: -41.2, CONF_LONGITUDE: 174.7, CONF_RADIUS: 25} + hass.config.latitude = -41.2 + hass.config.longitude = 174.7 config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=conf + DOMAIN, + context={"source": SOURCE_USER}, ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "already_configured"} async def test_show_form(hass: HomeAssistant) -> None: """Test that the form is served with no input.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=None + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM @@ -87,14 +94,16 @@ async def test_step_user(hass: HomeAssistant) -> None: ), ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=conf + DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "-41.2, 174.7" - assert result["data"] == { - CONF_LATITUDE: -41.2, - CONF_LONGITUDE: 174.7, - CONF_RADIUS: 25, - CONF_UNIT_SYSTEM: "metric", - CONF_SCAN_INTERVAL: 300.0, - } + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure(result["flow_id"], conf) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "-41.2, 174.7" + assert result["data"] == { + CONF_LATITUDE: -41.2, + CONF_LONGITUDE: 174.7, + CONF_RADIUS: 25, + CONF_UNIT_SYSTEM: "metric", + CONF_SCAN_INTERVAL: 300.0, + } diff --git a/tests/components/geosphere_austria_warnings/snapshots/test_sensor.ambr b/tests/components/geosphere_austria_warnings/snapshots/test_sensor.ambr index 0b18666df47093..5efaa53eaaaeb9 100644 --- a/tests/components/geosphere_austria_warnings/snapshots/test_sensor.ambr +++ b/tests/components/geosphere_austria_warnings/snapshots/test_sensor.ambr @@ -1,4 +1,34 @@ # serializer version: 1 +# name: test_sensors.4 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': 'https://warnungen.zamg.at/wsapp/de/alle/gesamterzeitraum/0/16.35640,48.24860', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'geosphere_austria_warnings', + '30740', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'GeoSphere Austria', + 'model': None, + 'model_id': None, + 'name': 'Schwechat', + 'name_by_user': None, + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_sensors[sensor.schwechat_active_warnings-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/geosphere_austria_warnings/test_sensor.py b/tests/components/geosphere_austria_warnings/test_sensor.py index 5fa5e8cea84322..236e9c59fdda8b 100644 --- a/tests/components/geosphere_austria_warnings/test_sensor.py +++ b/tests/components/geosphere_austria_warnings/test_sensor.py @@ -7,12 +7,13 @@ import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.geosphere_austria_warnings.const import DOMAIN from homeassistant.components.geosphere_austria_warnings.coordinator import ( UPDATE_INTERVAL, ) from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import setup_integration @@ -28,6 +29,7 @@ async def test_sensors( hass: HomeAssistant, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: """Test the state of the sensors while a warning is active.""" @@ -35,6 +37,12 @@ async def test_sensors( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + device_entry = device_registry.async_get_device_by_identifier( + (DOMAIN, "30740"), mock_config_entry.entry_id + ) + assert device_entry + assert device_entry == snapshot + @pytest.mark.freeze_time("2023-03-27 20:00:00+00:00") async def test_sensors_without_active_warning( diff --git a/tests/components/intelliclima/conftest.py b/tests/components/intelliclima/conftest.py index c0925b2042090e..299529b62439ed 100644 --- a/tests/components/intelliclima/conftest.py +++ b/tests/components/intelliclima/conftest.py @@ -8,6 +8,8 @@ from pyintelliclima.intelliclima_types import ( IntelliClimaDevices, IntelliClimaECO, + IntelliClimaFilterStatsEntry, + IntelliClimaFilterStatus, IntelliClimaModelType, ) import pytest @@ -39,17 +41,16 @@ def mock_config_entry() -> MockConfigEntry: ) -@pytest.fixture -def single_eco_device() -> IntelliClimaDevices: - """Create IntelliClimaDevices with one ECOCOMFORT 2.0 and no C800.""" - eco = IntelliClimaECO( - id="56789", - crono_sn="11223344", +def create_eco_device(device_id: str, crono_sn: str, name: str) -> IntelliClimaECO: + """Create an ECOCOMFORT 2.0 device.""" + return IntelliClimaECO( + id=device_id, + crono_sn=crono_sn, status="OK", online="OK", command="OK", model=IntelliClimaModelType(modello="ECO", tipo="wifi"), - name="Test VMC", + name=name, houses_id="12345", mode_set=FanMode.inward, mode_state="1", @@ -109,11 +110,30 @@ def single_eco_device() -> IntelliClimaDevices: online_status_debug="mock", ) + +@pytest.fixture +def single_eco_device() -> IntelliClimaDevices: + """Create IntelliClimaDevices with one ECOCOMFORT 2.0 and no C800.""" + eco = create_eco_device("56789", "11223344", "Test VMC") + return IntelliClimaDevices(ecocomfort2_devices={eco.id: eco}, c800_devices={}) @pytest.fixture -def mock_cloud_interface(single_eco_device) -> Generator[AsyncMock]: +def two_eco_devices() -> IntelliClimaDevices: + """Create IntelliClimaDevices with two ECOCOMFORT 2.0 devices and no C800.""" + first = create_eco_device("56789", "11223344", "Test VMC") + second = create_eco_device("98765", "55667788", "Other VMC") + + return IntelliClimaDevices( + ecocomfort2_devices={first.id: first, second.id: second}, c800_devices={} + ) + + +@pytest.fixture +def mock_cloud_interface( + single_eco_device: IntelliClimaDevices, +) -> Generator[AsyncMock]: """Mock IntelliClimaAPI for tests.""" with ( @@ -132,6 +152,22 @@ def mock_cloud_interface(single_eco_device) -> Generator[AsyncMock]: # Mock other async methods if needed mock_client.authenticate.return_value = True mock_client.get_all_device_status.return_value = single_eco_device + mock_client.get_filter_status.return_value = IntelliClimaFilterStatus( + serial="11223344", + is_active=True, + from_date="2025-11-18 10:22:51", + stats=[ + IntelliClimaFilterStatsEntry( + night_tot_hour="10", + low_tot_hour="20", + medium_tot_hour="30", + high_tot_hour="5", + boost_tot_hour="1", + ) + ], + totale=66.0, + change_filter=True, + ) # Sub-API used by the fan entity mock_client.ecocomfort = SimpleNamespace( diff --git a/tests/components/intelliclima/snapshots/test_binary_sensor.ambr b/tests/components/intelliclima/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000000..e489f9418dd410 --- /dev/null +++ b/tests/components/intelliclima/snapshots/test_binary_sensor.ambr @@ -0,0 +1,90 @@ +# serializer version: 1 +# name: test_all_binary_sensor_entities.2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'bluetooth', + '00:11:22:33:44:55', + ), + tuple( + 'mac', + '00:11:22:33:44:55', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'intelliclima', + '56789', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Fantini Cosmi', + 'model': 'ECOCOMFORT 2.0', + 'model_id': None, + 'name': 'Test VMC', + 'name_by_user': None, + 'serial_number': '11223344', + 'sw_version': '0.6.8', + 'via_device_id': None, + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.test_vmc_filter_cleaning_required-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_vmc_filter_cleaning_required', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Filter cleaning required', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filter cleaning required', + 'platform': 'intelliclima', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_cleaning', + 'unique_id': '56789_filter_cleaning', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.test_vmc_filter_cleaning_required-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : 'Test VMC Filter cleaning required', + }), + 'context': , + 'entity_id': 'binary_sensor.test_vmc_filter_cleaning_required', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/intelliclima/test_binary_sensor.py b/tests/components/intelliclima/test_binary_sensor.py new file mode 100644 index 00000000000000..fe12be9d61f891 --- /dev/null +++ b/tests/components/intelliclima/test_binary_sensor.py @@ -0,0 +1,84 @@ +"""Test IntelliClima Binary Sensors.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +from pyintelliclima.intelliclima_types import IntelliClimaFilterStatus +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +async def setup_intelliclima_binary_sensor_only( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_cloud_interface: AsyncMock, +) -> AsyncGenerator[None]: + """Set up IntelliClima integration with only the binary sensor platform.""" + with ( + patch( + "homeassistant.components.intelliclima.PLATFORMS", [Platform.BINARY_SENSOR] + ), + ): + await setup_integration(hass, mock_config_entry) + yield + + +async def test_all_binary_sensor_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_cloud_interface: AsyncMock, +) -> None: + """Test all entities.""" + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + binary_sensor_entries = [ + entry + for entry in entity_registry.entities.values() + if entry.platform == "intelliclima" and entry.domain == BINARY_SENSOR_DOMAIN + ] + assert len(binary_sensor_entries) == 1 + + for entity_entry in binary_sensor_entries: + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry == snapshot + + +async def test_filter_cleaning_unavailable_when_tracking_disabled( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_cloud_interface: AsyncMock, +) -> None: + """Test the filter_cleaning sensor is unavailable when the vendor disables filter tracking. + + The vendor API keeps returning `change_filter: false` in this state, which + would otherwise misreport a "clean filter" the integration can't actually vouch for. + """ + mock_cloud_interface.get_filter_status.return_value = IntelliClimaFilterStatus( + serial="11223344", + is_active=False, + from_date="2025-11-18 10:22:51", + stats=[], + totale=0, + change_filter=False, + ) + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.test_vmc_filter_cleaning_required") + assert state is not None + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/intelliclima/test_init.py b/tests/components/intelliclima/test_init.py new file mode 100644 index 00000000000000..b5ef8a4f42e9ae --- /dev/null +++ b/tests/components/intelliclima/test_init.py @@ -0,0 +1,82 @@ +"""Test the IntelliClima integration setup.""" + +from unittest.mock import AsyncMock + +from pyintelliclima.api import IntelliClimaAPIError +from pyintelliclima.intelliclima_types import ( + IntelliClimaDevices, + IntelliClimaFilterStatus, +) + +from homeassistant.components.intelliclima.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_setup_succeeds_when_filter_status_unavailable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_cloud_interface: AsyncMock, +) -> None: + """Test the config entry still loads when the filter-status endpoint fails. + + Filter status only backs a diagnostic binary sensor, so a transient + failure of that ancillary endpoint must not block the fan, select, and + sensor platforms from being set up. + """ + mock_cloud_interface.get_filter_status.side_effect = IntelliClimaAPIError( + "cannot compute filter status" + ) + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert hass.states.get("fan.test_vmc") is not None + + state = hass.states.get("binary_sensor.test_vmc_filter_cleaning_required") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_filter_status_failure_isolated_per_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_cloud_interface: AsyncMock, + entity_registry: er.EntityRegistry, + two_eco_devices: IntelliClimaDevices, +) -> None: + """Test a filter-status failure for one device does not affect the others.""" + mock_cloud_interface.get_all_device_status.return_value = two_eco_devices + working_device, failing_device = two_eco_devices.ecocomfort2_devices.values() + filter_status = mock_cloud_interface.get_filter_status.return_value + + def _get_filter_status(serial: str) -> IntelliClimaFilterStatus: + if serial == failing_device.crono_sn: + raise IntelliClimaAPIError("cannot compute filter status") + return filter_status + + mock_cloud_interface.get_filter_status.side_effect = _get_filter_status + + await setup_integration(hass, mock_config_entry) + + working_entity_id = entity_registry.async_get_entity_id( + Platform.BINARY_SENSOR, DOMAIN, f"{working_device.id}_filter_cleaning" + ) + assert working_entity_id is not None + state = hass.states.get(working_entity_id) + assert state is not None + assert state.state == STATE_ON + + failing_entity_id = entity_registry.async_get_entity_id( + Platform.BINARY_SENSOR, DOMAIN, f"{failing_device.id}_filter_cleaning" + ) + assert failing_entity_id is not None + state = hass.states.get(failing_entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/lyngdorf/conftest.py b/tests/components/lyngdorf/conftest.py index a0918a8c6e0ce9..fabe3f63134bd4 100644 --- a/tests/components/lyngdorf/conftest.py +++ b/tests/components/lyngdorf/conftest.py @@ -7,6 +7,7 @@ from lyngdorf.const import LyngdorfModel from lyngdorf.device import Receiver +from lyngdorf.models.base import NumericRange import pytest from homeassistant.components.lyngdorf.const import ( @@ -63,6 +64,23 @@ def mock_receiver() -> Generator[MagicMock]: receiver.name = "Mock Lyngdorf" receiver.connected = True + # Diagnostics reports the whole receiver, so every property it reads + # needs a value here; an unset one is a mock the response cannot encode. + receiver.model = LyngdorfModel.MP_60 + receiver.max_volume = 0.0 + receiver.room_perfect_position = None + receiver.available_room_perfect_positions = [] + receiver.voicing = None + receiver.available_voicings = [] + receiver.lipsync = None + receiver.lipsync_range = NumericRange(0, 500, 1) + for _t in ("bass", "treble"): + setattr(receiver, f"trim_{_t}", None) + setattr(receiver, f"trim_{_t}_range", NumericRange(-12.0, 12.0, 0.1)) + for _t in ("centre", "height", "lfe", "surround"): + setattr(receiver, f"trim_{_t}", None) + setattr(receiver, f"trim_{_t}_range", NumericRange(-10.0, 10.0, 0.1)) + receiver.power_on = False receiver.volume = -40.0 receiver.mute_enabled = False diff --git a/tests/components/lyngdorf/snapshots/test_diagnostics.ambr b/tests/components/lyngdorf/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..729cffe0908790 --- /dev/null +++ b/tests/components/lyngdorf/snapshots/test_diagnostics.ambr @@ -0,0 +1,130 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + 'model': 'MP-60', + 'serial_number': '**REDACTED**', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'lyngdorf', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Mock Lyngdorf', + 'unique_id': '**REDACTED**', + 'version': 1, + }), + 'ranges': dict({ + 'lipsync_range': dict({ + 'max': 500, + 'min': 0, + 'step': 1, + }), + 'trim_bass_range': dict({ + 'max': 12.0, + 'min': -12.0, + 'step': 0.1, + }), + 'trim_centre_range': dict({ + 'max': 10.0, + 'min': -10.0, + 'step': 0.1, + }), + 'trim_height_range': dict({ + 'max': 10.0, + 'min': -10.0, + 'step': 0.1, + }), + 'trim_lfe_range': dict({ + 'max': 10.0, + 'min': -10.0, + 'step': 0.1, + }), + 'trim_surround_range': dict({ + 'max': 10.0, + 'min': -10.0, + 'step': 0.1, + }), + 'trim_treble_range': dict({ + 'max': 12.0, + 'min': -12.0, + 'step': 0.1, + }), + }), + 'ssdp': None, + 'state': dict({ + 'audio_information': 'Stereo', + 'audio_input': 'optical', + 'available_audio_inputs': list([ + 'optical', + 'aux', + ]), + 'available_room_perfect_positions': list([ + ]), + 'available_sound_modes': list([ + ]), + 'available_sources': list([ + ]), + 'available_stream_types': list([ + 'AirPlay', + 'DLNA', + ]), + 'available_video_inputs': list([ + 'hdmi', + ]), + 'available_voicings': list([ + ]), + 'connected': True, + 'lipsync': None, + 'max_volume': 0.0, + 'model': 'MP_60', + 'mute_enabled': False, + 'power_on': False, + 'room_perfect_position': None, + 'sound_mode': None, + 'source': None, + 'streaming_source': 'AirPlay', + 'trim_bass': None, + 'trim_centre': None, + 'trim_height': None, + 'trim_lfe': None, + 'trim_surround': None, + 'trim_treble': None, + 'video_information': '4K HDR', + 'video_input': 'hdmi', + 'voicing': None, + 'volume': -40.0, + 'zone_b_audio_input': 'aux', + 'zone_b_mute_enabled': False, + 'zone_b_power_on': False, + 'zone_b_source': None, + 'zone_b_streaming_source': 'DLNA', + 'zone_b_volume': -40.0, + }), + }) +# --- +# name: test_diagnostics_includes_ssdp_description + dict({ + 'ssdp_location': '**REDACTED**', + 'ssdp_server': None, + 'ssdp_st': 'urn:schemas-upnp-org:device:MediaRenderer:2', + 'ssdp_udn': '**REDACTED**', + 'ssdp_usn': '**REDACTED**', + 'upnp': dict({ + 'deviceType': 'urn:schemas-upnp-org:device:MediaRenderer:2', + 'friendlyName': 'Solar', + 'manufacturer': 'Lyngdorf', + 'modelName': 'MP-60', + 'serialNumber': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/lyngdorf/test_diagnostics.py b/tests/components/lyngdorf/test_diagnostics.py new file mode 100644 index 00000000000000..dd624b59b349c1 --- /dev/null +++ b/tests/components/lyngdorf/test_diagnostics.py @@ -0,0 +1,66 @@ +"""Tests for the Lyngdorf diagnostics.""" + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.lyngdorf.const import SSDP_ST +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.fixture +def platforms() -> list[Platform]: + """Only load the media player platform.""" + return [Platform.MEDIA_PLAYER] + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the diagnostics output.""" + assert await get_diagnostics_for_config_entry( + hass, hass_client, init_integration + ) == snapshot(exclude=props("entry_id", "created_at", "modified_at")) + + +async def test_diagnostics_includes_ssdp_description( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the UPnP description is captured, with the serial redacted.""" + discovery = SsdpServiceInfo( + ssdp_usn="uuid:864ab4c0-0fdb-46a7-84ad-aae23ee0d44f::upnp:rootdevice", + ssdp_st=SSDP_ST, + ssdp_udn="uuid:864ab4c0-0fdb-46a7-84ad-aae23ee0d44f", + ssdp_location="http://127.0.0.1:55088/description.xml", + upnp={ + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2", + "friendlyName": "Solar", + "manufacturer": "Lyngdorf", + "modelName": "MP-60", + "serialNumber": "0050c27c76b2", + }, + ) + + with patch( + "homeassistant.components.lyngdorf.diagnostics.async_get_discovery_info_by_st", + return_value=[discovery], + ): + result = await get_diagnostics_for_config_entry( + hass, hass_client, init_integration + ) + + assert result["ssdp"] == snapshot diff --git a/tests/components/midea/snapshots/test_humidifier.ambr b/tests/components/midea/snapshots/test_humidifier.ambr new file mode 100644 index 00000000000000..348853f2303820 --- /dev/null +++ b/tests/components/midea/snapshots/test_humidifier.ambr @@ -0,0 +1,153 @@ +# serializer version: 1 +# name: test_humidifier_state_snapshot[a1][humidifier.bedroom_ac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'manual', + 'continuous', + 'auto', + 'clothes_dry', + 'shoes_dry', + ]), + : 85, + : 35, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.bedroom_ac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12345678_humidifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_humidifier_state_snapshot[a1][humidifier.bedroom_ac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : list([ + 'manual', + 'continuous', + 'auto', + 'clothes_dry', + 'shoes_dry', + ]), + : 60.0, + : 'dehumidifier', + : 'Bedroom AC', + : 55.0, + : 85, + : 35, + : 'auto', + : , + }), + 'context': , + 'entity_id': 'humidifier.bedroom_ac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_humidifier_state_snapshot[fd][humidifier.bedroom_ac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'manual', + 'auto', + 'continuous', + 'living_room', + 'bed_room', + 'kitchen', + 'sleep', + ]), + : 85, + : 35, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.bedroom_ac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12345678_humidifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_humidifier_state_snapshot[fd][humidifier.bedroom_ac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : list([ + 'manual', + 'auto', + 'continuous', + 'living_room', + 'bed_room', + 'kitchen', + 'sleep', + ]), + : 40.0, + : 'humidifier', + : 'Bedroom AC', + : 45.0, + : 85, + : 35, + : 'continuous', + : , + }), + 'context': , + 'entity_id': 'humidifier.bedroom_ac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/midea/test_entity.py b/tests/components/midea/test_entity.py index 9df02fab6a596b..6ff19da7bdbdf4 100644 --- a/tests/components/midea/test_entity.py +++ b/tests/components/midea/test_entity.py @@ -3,10 +3,13 @@ from collections.abc import Callable from midealocal.devices.ac import DeviceAttributes as ACAttributes +from midealocal.exceptions import SocketException import pytest from homeassistant.components.midea.const import DOMAIN +from homeassistant.components.midea.entity import midea_api_call from homeassistant.core import CoreState, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from . import setup_integration @@ -16,6 +19,16 @@ from tests.common import MockConfigEntry +def test_midea_api_call_translates_midea_local_error() -> None: + """Test midea_api_call turns a midealocal error into a HomeAssistantError.""" + with pytest.raises(HomeAssistantError) as exc_info, midea_api_call(): + raise SocketException("offline") + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "device_communication_error" + assert exc_info.value.translation_placeholders == {"error": "offline"} + + @pytest.mark.parametrize( ( "update", diff --git a/tests/components/midea/test_humidifier.py b/tests/components/midea/test_humidifier.py new file mode 100644 index 00000000000000..faa02c8458069d --- /dev/null +++ b/tests/components/midea/test_humidifier.py @@ -0,0 +1,256 @@ +"""Tests for midea humidifier.py.""" + +from collections.abc import Callable +from unittest.mock import patch + +from midealocal.const import DeviceType +from midealocal.devices.a1 import DeviceAttributes as A1Attributes, MideaA1Device +from midealocal.devices.ac import DeviceAttributes as ACAttributes +from midealocal.devices.fd import DeviceAttributes as FDAttributes, MideaFDDevice +from midealocal.exceptions import SocketException +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.humidifier import ( + ATTR_AVAILABLE_MODES, + ATTR_CURRENT_HUMIDITY, + ATTR_HUMIDITY, + DOMAIN as HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + SERVICE_SET_MODE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.components.midea.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import setup_integration +from .conftest import DummyDevice, entity_entries +from .const import TEST_DEVICE_ID + +from tests.common import MockConfigEntry, snapshot_platform + + +async def _assert_service_call( + hass: HomeAssistant, + entity_id: str, + service: str, + service_data: dict, + expected_calls: list[tuple], + device: DummyDevice, +) -> None: + """Call a humidifier service and assert the fake device recorded the right call.""" + device.calls.clear() + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, **service_data}, + blocking=True, + ) + assert device.calls == expected_calls + + +def _a1_device() -> DummyDevice: + device = DummyDevice( + DeviceType.A1, + attributes={ + A1Attributes.power: True, + A1Attributes.mode: "auto", + A1Attributes.target_humidity: 55, + A1Attributes.current_humidity: 60, + }, + ) + device.modes = list(MideaA1Device._default_modes.values()) + return device + + +def _fd_device() -> DummyDevice: + device = DummyDevice( + DeviceType.FD, + attributes={ + FDAttributes.power: True, + FDAttributes.mode: "continuous", + FDAttributes.target_humidity: 45, + FDAttributes.current_humidity: 40, + }, + ) + device.modes = list(MideaFDDevice._modes) + return device + + +@pytest.mark.parametrize( + "device", + [ + pytest.param(_a1_device(), id="a1"), + pytest.param(_fd_device(), id="fd"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_humidifier_state_snapshot( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + device: DummyDevice, +) -> None: + """Test async_setup_entry creates the right humidifier entity per device type.""" + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.HUMIDIFIER]): + await setup_integration(hass, config_entry, device) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("device", "expected_model"), + [ + pytest.param(_a1_device(), "Dehumidifier", id="a1"), + pytest.param(_fd_device(), "Humidifier", id="fd"), + ], +) +async def test_humidifier_device_info_model( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], + device: DummyDevice, + expected_model: str, +) -> None: + """Test the device registry entry uses the right model name for A1 and FD.""" + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.HUMIDIFIER]): + await setup_integration(hass, config_entry, device) + + assert ( + device_entry := device_registry.async_get_device_by_identifier( + (DOMAIN, str(TEST_DEVICE_ID)), config_entry.entry_id + ) + ) is not None + assert device_entry.model == expected_model + + +async def test_a1_humidifier_services( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test A1 humidifier service calls reach the device.""" + device = _a1_device() + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.HUMIDIFIER]): + await setup_integration(hass, config_entry, device) + + entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_humidifier"] + + assert (state := hass.states.get(entity_entry.entity_id)) is not None + assert state.state == "on" + assert state.attributes[ATTR_HUMIDITY] == 55 + assert state.attributes[ATTR_CURRENT_HUMIDITY] == 60 + assert state.attributes[ATTR_MODE] == "auto" + assert state.attributes[ATTR_AVAILABLE_MODES] == device.modes + + await _assert_service_call( + hass, + entity_entry.entity_id, + SERVICE_SET_HUMIDITY, + {ATTR_HUMIDITY: 65}, + [("set_attribute", "target_humidity", 65)], + device, + ) + await _assert_service_call( + hass, + entity_entry.entity_id, + SERVICE_SET_MODE, + {ATTR_MODE: "continuous"}, + [("set_attribute", "mode", "continuous")], + device, + ) + await _assert_service_call( + hass, + entity_entry.entity_id, + SERVICE_TURN_OFF, + {}, + [("set_attribute", "power", False)], + device, + ) + await _assert_service_call( + hass, + entity_entry.entity_id, + SERVICE_TURN_ON, + {}, + [("set_attribute", "power", True)], + device, + ) + + +async def test_humidifier_not_created_for_other_device_type( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test no humidifier entity is created for a device type without one.""" + device = DummyDevice( + DeviceType.AC, + attributes={ + ACAttributes.power: True, + ACAttributes.mode: 1, + ACAttributes.target_temperature: 22.0, + ACAttributes.indoor_temperature: 21.0, + }, + ) + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.HUMIDIFIER]): + await setup_integration(hass, config_entry, device) + + assert entity_entries(hass, config_entry) == {} + + +async def test_humidifier_unknown_mode_and_power_return_none( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test mode and is_on gracefully report unknown when attributes are unset.""" + device = DummyDevice( + DeviceType.A1, + attributes={ + A1Attributes.power: None, + A1Attributes.mode: None, + A1Attributes.target_humidity: None, + A1Attributes.current_humidity: None, + }, + ) + device.modes = ["Manual", "Continuous", "Auto"] + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.HUMIDIFIER]): + await setup_integration(hass, config_entry, device) + + entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_humidifier"] + assert (state := hass.states.get(entity_entry.entity_id)) is not None + assert state.state == "unknown" + assert state.attributes.get(ATTR_HUMIDITY) is None + assert state.attributes.get(ATTR_CURRENT_HUMIDITY) is None + assert state.attributes.get(ATTR_MODE) is None + + +async def test_humidifier_turn_on_raises_on_device_communication_error( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test a device communication failure surfaces as a HomeAssistantError.""" + device = _fd_device() + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.HUMIDIFIER]): + await setup_integration(hass, config_entry, device) + + entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_humidifier"] + + with ( + patch.object(device, "set_attribute", side_effect=SocketException("offline")), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_entry.entity_id}, + blocking=True, + ) diff --git a/tests/components/overkiz/test_config_flow.py b/tests/components/overkiz/test_config_flow.py index 18460ff1d5451b..44614e5303eb50 100644 --- a/tests/components/overkiz/test_config_flow.py +++ b/tests/components/overkiz/test_config_flow.py @@ -1322,32 +1322,6 @@ async def test_zeroconf_flow_already_configured(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_local_zeroconf_flow_updates_host(hass: HomeAssistant) -> None: - """Test that rediscovery of a local gateway refreshes the stored host.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - unique_id=TEST_GATEWAY_ID, - data={ - "host": "gateway-1234-5678-9123.local:9999", - "token": TEST_TOKEN, - "verify_ssl": False, - "hub": TEST_SERVER, - "api_type": "local", - }, - ) - config_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - data=FAKE_ZERO_CONF_INFO_LOCAL, - context={"source": config_entries.SOURCE_ZEROCONF}, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - assert config_entry.data["host"] == "gateway-1234-5678-9123.local:8443" - - @pytest.fixture async def setup_rexel_credentials(hass: HomeAssistant) -> None: """Set up the application credential used by the Rexel OAuth2 flow.""" diff --git a/tests/components/ps4/test_init.py b/tests/components/ps4/test_init.py index b814799963761e..48aaa72d72d98f 100644 --- a/tests/components/ps4/test_init.py +++ b/tests/components/ps4/test_init.py @@ -142,11 +142,16 @@ async def test_config_flow_entry_migrate( manager = hass.config_entries mock_entry = MOCK_ENTRY_VERSION_1 mock_entry.add_to_manager(manager) + # The integration registers the PS4 device with a name (the console host name), + # so the entity id is derived from the device name. mock_device_entry = device_registry.async_get_or_create( config_entry_id=mock_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={(DOMAIN, MOCK_UNIQUE_ID)}, + manufacturer="Sony Interactive Entertainment Inc.", + model="PlayStation 4", + name="My PS4", ) - mock_entity_id = f"media_player.ps4_{MOCK_UNIQUE_ID}" mock_e_entry = entity_registry.async_get_or_create( "media_player", "ps4", @@ -154,8 +159,9 @@ async def test_config_flow_entry_migrate( config_entry=mock_entry, device_id=mock_device_entry.id, ) + mock_entity_id = mock_e_entry.entity_id assert len(entity_registry.entities) == 1 - assert mock_e_entry.entity_id == mock_entity_id + assert mock_entity_id == "media_player.my_ps4" assert mock_e_entry.unique_id == MOCK_UNIQUE_ID with ( @@ -173,10 +179,9 @@ async def test_config_flow_entry_migrate( await hass.async_block_till_done() assert len(entity_registry.entities) == 1 - for entity in entity_registry.entities.values(): - mock_entity = entity - - # Test that entity_id remains the same. + # The migration must keep the entity_id unchanged. + mock_entity = entity_registry.async_get(mock_entity_id) + assert mock_entity is not None assert mock_entity.entity_id == mock_entity_id assert mock_entity.device_id == mock_device_entry.id diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index e249324a895df9..8312d6dc64141f 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -19,6 +19,7 @@ ATTR_FAN_MODE, ATTR_HVAC_ACTION, ATTR_HVAC_MODE, + ATTR_HVAC_MODES, ATTR_PRESET_MODE, DOMAIN as CLIMATE_DOMAIN, FAN_LOW, @@ -1096,6 +1097,77 @@ async def test_rpc_linkedgo_st802_thermostat( assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 22.4 + # Test HVAC mode heat (not floor_heating) + mock_rpc_device.boolean_set.reset_mock() + mock_rpc_device.enum_set.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + monkeypatch.setitem(mock_rpc_device.status["boolean:201"], "value", True) + monkeypatch.setitem(mock_rpc_device.status["enum:201"], "value", "heat") + mock_rpc_device.mock_update() + + mock_rpc_device.boolean_set.assert_called_once_with(201, True) + mock_rpc_device.enum_set.assert_called_once_with(201, "heat") + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.HEAT + + +async def test_rpc_linkedgo_st802_thermostat_floor_heating( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test LINKEDGO ST802 thermostat in floor heating mode.""" + entity_id = "climate.test_name" + + device_fixture = await async_load_json_object_fixture( + hass, "st802_gen3.json", DOMAIN + ) + device_info = device_fixture["shelly"] + config = device_fixture["config"] + status = device_fixture["status"] + + config["enum:201"]["options"] = [ + "cool", + "dry", + "ventilation", + "floor_heating", + ] + status["enum:201"]["value"] = "floor_heating" + monkeypatch.setattr(mock_rpc_device, "shelly", device_info) + monkeypatch.setattr(mock_rpc_device, "status", status) + monkeypatch.setattr(mock_rpc_device, "config", config) + + await init_integration(hass, 3, model=MODEL_LINKEDGO_ST802_THERMOSTAT) + + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_HVAC_MODES] == [ + HVACMode.OFF, + HVACMode.COOL, + HVACMode.DRY, + HVACMode.FAN_ONLY, + HVACMode.HEAT, + ] + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + monkeypatch.setitem(mock_rpc_device.status["enum:201"], "value", "floor_heating") + mock_rpc_device.mock_update() + + mock_rpc_device.boolean_set.assert_called_once_with(201, True) + mock_rpc_device.enum_set.assert_called_once_with(201, "floor_heating") + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.HEAT + async def test_rpc_linkedgo_st1820_thermostat( hass: HomeAssistant, diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index 738b77056e81b1..87fc7bd9342edb 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -48,7 +48,7 @@ OAuth2TokenRequestReauthError, OAuth2TokenRequestTransientError, ) -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.helpers.update_coordinator import UpdateFailed @@ -857,6 +857,57 @@ async def test_vehicle_polling_version_update( assert device.sw_version == "2026.2.0" +@pytest.mark.parametrize( + ("keep_one_enabled", "expected_polled"), + [ + (False, False), + (True, True), + ], + ids=["all_disabled", "one_enabled"], +) +async def test_vehicle_polling_stops_when_all_entities_disabled( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, + freezer: FrozenDateTimeFactory, + 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. + """ + vin = "LRW3F7EK4NC700000" + entry = await setup_platform(hass, [Platform.SENSOR]) + + vehicle_entities = [ + entity + for entity in er.async_entries_for_config_entry(entity_registry, entry.entry_id) + if entity.unique_id.startswith(vin) + ] + keep = {vehicle_entities[0].unique_id} if keep_one_enabled else set() + for entity in vehicle_entities: + if entity.unique_id not in keep: + entity_registry.async_update_entity( + entity.entity_id, disabled_by=er.RegistryEntryDisabler.USER + ) + + # Flush the debounced reload that disabling entities schedules. + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # A scheduled poll only fires while the coordinator still has a listener. + mock_vehicle_data.reset_mock() + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (mock_vehicle_data.call_count > 0) is expected_polled + + async def test_energy_site_version_update( hass: HomeAssistant, device_registry: dr.DeviceRegistry, diff --git a/tests/components/transmission/conftest.py b/tests/components/transmission/conftest.py index c49d2e2510dfda..84bac1f8572cac 100644 --- a/tests/components/transmission/conftest.py +++ b/tests/components/transmission/conftest.py @@ -66,10 +66,11 @@ def mock_transmission_client() -> Generator[AsyncMock]: } client.session_stats.return_value = SessionStats(fields=session_stats_data) - session_data = {"alt-speed-enabled": False} + session_data = {"alt-speed-enabled": False, "download-dir": "/downloads"} client.get_session.return_value = Session(fields=session_data) client.get_torrents.return_value = [] + client.free_space.return_value = 42949672960 # 40 GiB yield mock_client_class diff --git a/tests/components/transmission/snapshots/test_sensor.ambr b/tests/components/transmission/snapshots/test_sensor.ambr index ab17d00f61f44b..bc38eee54f7c7b 100644 --- a/tests/components/transmission/snapshots/test_sensor.ambr +++ b/tests/components/transmission/snapshots/test_sensor.ambr @@ -52,6 +52,67 @@ 'state': '0', }) # --- +# name: test_sensors[sensor.transmission_available_disk_space-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.transmission_available_disk_space', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Available disk space', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Available disk space', + 'platform': 'transmission', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'download_dir_free_space', + 'unique_id': '01J0BC4QM2YBRP6H5G933AETT7-download_dir_free_space', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.transmission_available_disk_space-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'data_size', + : 'Transmission Available disk space', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.transmission_available_disk_space', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40.0', + }) +# --- # name: test_sensors[sensor.transmission_completed_torrents-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/transmission/test_sensor.py b/tests/components/transmission/test_sensor.py index 7ec0c4a455092b..dd1b37d0598629 100644 --- a/tests/components/transmission/test_sensor.py +++ b/tests/components/transmission/test_sensor.py @@ -73,6 +73,11 @@ async def test_stats_sensors( assert state is not None assert float(state.state) == pytest.approx(0.8, rel=1e-3) + # Free disk space: 40 GiB = 40.0 GiB + state = hass.states.get("sensor.transmission_available_disk_space") + assert state is not None + assert float(state.state) == pytest.approx(40.0, rel=1e-3) + def test_get_state_combinations() -> None: """Test get_state with all upload/download combinations.""" diff --git a/tests/components/xiaomi_ble/test_binary_sensor.py b/tests/components/xiaomi_ble/test_binary_sensor.py index baf55808570969..e48679438a54d7 100644 --- a/tests/components/xiaomi_ble/test_binary_sensor.py +++ b/tests/components/xiaomi_ble/test_binary_sensor.py @@ -299,8 +299,7 @@ async def test_unavailable(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=DOMAIN, - unique_id="A4:C1:38:66:E5:67", - data={"bindkey": "0fdcc30fe9289254876b5ef7c11ef1f0"}, + unique_id="58:2D:34:35:93:21", ) entry.add_to_hass(hass) @@ -311,16 +310,16 @@ async def test_unavailable(hass: HomeAssistant) -> None: inject_bluetooth_service_info_bleak( hass, make_advertisement( - "A4:C1:38:66:E5:67", - b"XY\x89\x18\x9ag\xe5f8\xc1\xa4\x9d\xd9z\xf3&\x00\x00\xc8\xa6\x0b\xd5", + "58:2D:34:35:93:21", + b"P \xf6\x07\xda!\x9354-X\x0f\x00\x03\x01\x00\x00", ), ) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 2 - opening_sensor = hass.states.get("binary_sensor.door_window_sensor_e567_opening") + motion_sensor = hass.states.get("binary_sensor.nightlight_9321_motion") - assert opening_sensor.state == STATE_ON + assert motion_sensor.state == STATE_ON # Fastforward time without BLE advertisements monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 @@ -338,10 +337,10 @@ async def test_unavailable(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - opening_sensor = hass.states.get("binary_sensor.door_window_sensor_e567_opening") + motion_sensor = hass.states.get("binary_sensor.nightlight_9321_motion") # Normal devices should go to unavailable - assert opening_sensor.state == STATE_UNAVAILABLE + assert motion_sensor.state == STATE_UNAVAILABLE assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index d85ba7ab0b007d..2d1dc6aef3508b 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -5149,7 +5149,7 @@ async def test_subscribe_node_statistics( assert msg["event"] == { "source": "node", "event": "statistics updated", - "nodeId": multisensor_6.node_id, + "node_id": multisensor_6.node_id, "commands_tx": 0, "commands_rx": 0, "commands_dropped_tx": 0, diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 44d9a1325273d7..c1f95ba0b200a8 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -2031,6 +2031,131 @@ async def test_usb_discovery_with_existing_usb_flow(hass: HomeAssistant) -> None assert len(hass.config_entries.flow.async_progress()) == 0 +@pytest.mark.usefixtures("supervisor", "addon_installed") +async def test_discovery_not_blocked_by_zeroconf_flow(hass: HomeAssistant) -> None: + """Test USB and add-on discovery are not blocked by a zeroconf prompt.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + hostname="mock_hostname", + name="mock_name", + port=3000, + type="_zwave-js-server._tcp.local.", + properties={"homeId": "5678"}, + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + + # A USB flow does touch the add-on, so it still blocks add-on discovery. + hass.config_entries.flow.async_abort(result["flow_id"]) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=HassioServiceInfo( + config=ADDON_DISCOVERY_INFO, + name="Z-Wave JS", + slug=ADDON_SLUG, + uuid="1234", + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "hassio_confirm" + + hass.config_entries.flow.async_abort(result["flow_id"]) + + # A zeroconf discovery of the same controller as the add-on discovery + # means another server is already connected to it, so it does block + # the add-on discovery, via the unique id in progress check. + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + hostname="mock_hostname", + name="mock_name", + port=3000, + type="_zwave-js-server._tcp.local.", + properties={"homeId": "1234"}, + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=HassioServiceInfo( + config=ADDON_DISCOVERY_INFO, + name="Z-Wave JS", + slug=ADDON_SLUG, + uuid="1234", + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + +@pytest.mark.usefixtures("supervisor", "addon_running") +async def test_usb_discovery_leaves_manual_entry_alone( + hass: HomeAssistant, + addon_options: dict[str, Any], + set_addon_options: AsyncMock, + mock_usb_serial_by_id: MagicMock, +) -> None: + """Test USB discovery does not rewrite a manual entry with add-on data. + + A manual entry for the same controller may be created while the USB + flow is waiting at the installation type menu, e.g. by confirming a + zeroconf discovery of the server that already serves the controller. + """ + addon_options["device"] = "/dev/ttyUSB0" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + + entry = MockConfigEntry( + domain=DOMAIN, + data={"url": "ws://external-server:3000"}, + title=TITLE, + unique_id="1234", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_recommended"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert entry.data == {"url": "ws://external-server:3000"} + set_addon_options.assert_not_called() + + @pytest.mark.usefixtures("supervisor", "addon_info") async def test_abort_usb_discovery_addon_required(hass: HomeAssistant) -> None: """Test usb discovery aborted when existing entry not using add-on.""" @@ -2366,15 +2491,16 @@ async def test_addon_running_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - assert entry.data["url"] == "ws://host1:3001" - assert entry.data["usb_path"] == "/test_new" - assert entry.data["socket_path"] is None - assert entry.data["s0_legacy_key"] == "new123" - assert entry.data["s2_access_control_key"] == "new456" - assert entry.data["s2_authenticated_key"] == "new789" - assert entry.data["s2_unauthenticated_key"] == "new987" - assert entry.data["lr_s2_access_control_key"] == "new654" - assert entry.data["lr_s2_authenticated_key"] == "new321" + # The existing entry is not using the add-on, + # so it must not be rewritten with add-on data. + assert entry.data["url"] == "ws://localhost:3000" + assert entry.data["usb_path"] == "/test" + assert entry.data["s0_legacy_key"] == "old123" + assert entry.data["s2_access_control_key"] == "old456" + assert entry.data["s2_authenticated_key"] == "old789" + assert entry.data["s2_unauthenticated_key"] == "old987" + assert entry.data["lr_s2_access_control_key"] == "old654" + assert entry.data["lr_s2_authenticated_key"] == "old321" @pytest.mark.usefixtures("supervisor", "addon_installed", "addon_info") @@ -2917,15 +3043,16 @@ async def test_addon_installed_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - assert entry.data["url"] == "ws://host1:3001" - assert entry.data["usb_path"] == "/new" - assert entry.data["socket_path"] is None - assert entry.data["s0_legacy_key"] == "new123" - assert entry.data["s2_access_control_key"] == "new456" - assert entry.data["s2_authenticated_key"] == "new789" - assert entry.data["s2_unauthenticated_key"] == "new987" - assert entry.data["lr_s2_access_control_key"] == "new654" - assert entry.data["lr_s2_authenticated_key"] == "new321" + # The existing entry is not using the add-on, + # so it must not be rewritten with add-on data. + assert entry.data["url"] == "ws://localhost:3000" + assert entry.data["usb_path"] == "/test" + assert entry.data["s0_legacy_key"] == "old123" + assert entry.data["s2_access_control_key"] == "old456" + assert entry.data["s2_authenticated_key"] == "old789" + assert entry.data["s2_unauthenticated_key"] == "old987" + assert entry.data["lr_s2_access_control_key"] == "old654" + assert entry.data["lr_s2_authenticated_key"] == "old321" @pytest.mark.usefixtures("supervisor", "addon_info") @@ -5093,14 +5220,148 @@ async def test_choose_serial_port_usb_ports_failure( assert result["step_id"] == "instruct_unplug" assert entry.state is config_entries.ConfigEntryState.NOT_LOADED - with patch( - "homeassistant.components.zwave_js.config_flow.async_get_usb_ports", - side_effect=OSError("test_error"), + with ( + patch( + "homeassistant.components.zwave_js.config_flow.async_get_usb_ports", + side_effect=OSError("test_error"), + ), + patch("homeassistant.components.zwave_js.async_setup_entry", return_value=True), ): result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "usb_ports_failed" + # The aborted flow reloads the config entry it unloaded. + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.LOADED + + +@pytest.mark.usefixtures("supervisor", "addon_running") +async def test_migrate_flow_abandoned_reloads_entry( + hass: HomeAssistant, + integration: MockConfigEntry, + client: MagicMock, +) -> None: + """Test an abandoned migration flow reloads the unloaded entry.""" + entry = integration + hass.config_entries.async_update_entry( + entry, unique_id="1234", data={**entry.data, "use_addon": True} + ) + + async def mock_backup_nvm_raw(): + await asyncio.sleep(0) + return b"test_nvm_data" + + client.driver.controller.async_backup_nvm_raw = AsyncMock( + side_effect=mock_backup_nvm_raw + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch("pathlib.Path.write_bytes"): + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "instruct_unplug" + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + + # The user closes the migration dialog instead of continuing. + with patch( + "homeassistant.components.zwave_js.async_setup_entry", return_value=True + ): + hass.config_entries.flow.async_abort(result["flow_id"]) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.LOADED + + +@pytest.mark.usefixtures("supervisor", "addon_running") +async def test_create_entry_spares_migration_flow( + hass: HomeAssistant, + integration: MockConfigEntry, + client: MagicMock, +) -> None: + """Test entry creation does not abort a migration flow in progress.""" + entry = integration + hass.config_entries.async_update_entry( + entry, unique_id="4321", data={**entry.data, "use_addon": True} + ) + + async def mock_backup_nvm_raw(): + await asyncio.sleep(0) + return b"test_nvm_data" + + client.driver.controller.async_backup_nvm_raw = AsyncMock( + side_effect=mock_backup_nvm_raw + ) + + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch("pathlib.Path.write_bytes"): + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "instruct_unplug" + migration_flow_id = result["flow_id"] + + # A manual flow for a different server creates an entry meanwhile. + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_custom"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": False} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + + with ( + patch("homeassistant.components.zwave_js.async_setup", return_value=True), + patch( + "homeassistant.components.zwave_js.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"url": "ws://localhost:3000"} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + + # The migration flow is still in progress. + assert any( + flow["flow_id"] == migration_flow_id + for flow in hass.config_entries.flow.async_progress() + ) + hass.config_entries.flow.async_abort(migration_flow_id) + await hass.async_block_till_done() + assert not hass.config_entries.flow.async_progress() + @pytest.mark.usefixtures("supervisor", "addon_installed") async def test_configure_addon_usb_ports_failure( diff --git a/tests/e2e/package.json b/tests/e2e/package.json index 0e2d348a9ac17a..236a2c80aa1b06 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "description": "End-to-end browser tests for Home Assistant Core", "private": true, - "packageManager": "pnpm@11.21.0", + "packageManager": "pnpm@11.22.0", "scripts": { "test": "playwright test" }, diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index 978eeaf52d3268..f9a994cd378b4d 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -80,9 +80,7 @@ async def test_async_match_states( suggested_object_id="kitchen", original_name="kitchen light", ) - entity_registry.async_update_entity( - state1.entity_id, area_id=area_kitchen.id, aliases=[er.COMPUTED_NAME] - ) + entity_registry.async_update_entity(state1.entity_id, area_id=area_kitchen.id) entity_registry.async_get_or_create( "switch", @@ -228,7 +226,6 @@ async def test_async_match_targets( kitchen_outlet = entity_registry.async_update_entity( kitchen_outlet.entity_id, name="kitchen outlet", - aliases=[er.COMPUTED_NAME], device_class=switch.SwitchDeviceClass.OUTLET, area_id=area_kitchen.id, ) @@ -262,7 +259,6 @@ async def test_async_match_targets( bedroom_switch_2 = entity_registry.async_update_entity( bedroom_switch_2.entity_id, name="second floor bedroom switch", - aliases=[er.COMPUTED_NAME], area_id=area_bedroom_2.id, ) state_bedroom_switch_2 = State( @@ -298,7 +294,6 @@ async def test_async_match_targets( bedroom_switch_3 = entity_registry.async_update_entity( bedroom_switch_3.entity_id, name="third floor bedroom switch", - aliases=[er.COMPUTED_NAME], area_id=area_bedroom_3.id, ) state_bedroom_switch_3 = State( diff --git a/tests/helpers/test_storage.py b/tests/helpers/test_storage.py index 249dab632fe5e2..60daa8d8ceea09 100644 --- a/tests/helpers/test_storage.py +++ b/tests/helpers/test_storage.py @@ -891,7 +891,7 @@ def _corrupt_store(): assert issue_entry.translation_placeholders["storage_key"] == storage_key assert issue_entry.issue_domain == HOMEASSISTANT_DOMAIN assert ( - "unexpected character: line 1 column 1 (char 0)" + "unexpected character, expected a JSON value: line 1 column 1 (char 0)" in issue_entry.translation_placeholders["error"] )