diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index dac8c13a8edd..6da3f760b46b 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -342,13 +342,13 @@ jobs: - name: Login to DockerHub if: matrix.registry == 'docker.io/homeassistant' - uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4.5.0 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4.5.0 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -521,7 +521,7 @@ jobs: persist-credentials: false - name: Login to GitHub Container Registry - uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4.5.0 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 with: registry: ghcr.io username: ${{ github.repository_owner }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 649bf7b918dd..be8403ef4411 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -736,7 +736,7 @@ jobs: apt-cache-version: ${{ env.APT_CACHE_VERSION }} - name: Restore pytest test counts cache id: cache-pytest-counts - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: pytest_test_counts.json # Primary key is a sentinel; restore-keys pick the most recent @@ -769,7 +769,7 @@ jobs: steps.cache-pytest-counts.outputs.cache-matched-key, steps.cache-pytest-counts-hash.outputs.hash ) - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: pytest_test_counts.json key: >- diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index cf8e3ac596d8..d27706a9c179 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,7 +28,7 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@e0647621c2984b5ed2f768cb892365bf2a616ad1 # v4.37.2 + uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 with: languages: python diff --git a/homeassistant/components/acmeda/entity.py b/homeassistant/components/acmeda/entity.py index e76221881e46..b60a9c3b91b0 100644 --- a/homeassistant/components/acmeda/entity.py +++ b/homeassistant/components/acmeda/entity.py @@ -55,5 +55,4 @@ class AcmedaEntity(entity.Entity): identifiers={(DOMAIN, self.unique_id)}, manufacturer="Rollease Acmeda", name=self.roller.name, - via_device=(DOMAIN, self.roller.hub.id), ) diff --git a/homeassistant/components/actron_air/__init__.py b/homeassistant/components/actron_air/__init__.py index 6691c6fd510b..04d595a3ac2f 100644 --- a/homeassistant/components/actron_air/__init__.py +++ b/homeassistant/components/actron_air/__init__.py @@ -6,6 +6,7 @@ from actron_neo_api.models.system import ActronAirSystemInfo from homeassistant.const import CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from .const import DOMAIN, LOGGER from .coordinator import ( @@ -37,6 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> translation_key="setup_connection_error", ) from err + device_registry = dr.async_get(hass) system_coordinators: dict[str, ActronAirSystemCoordinator] = {} for system in systems: coordinator = ActronAirSystemCoordinator(hass, entry, api, system) @@ -44,6 +46,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> await coordinator.async_config_entry_first_refresh() system_coordinators[system.serial] = coordinator + # Register the AC system device so zone entities can link to it as their + # via device when they are set up. + ac_system = coordinator.data.ac_system + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, system.serial)}, + name=ac_system.system_name, + manufacturer="Actron Air", + model_id=ac_system.master_wc_model, + sw_version=ac_system.master_wc_firmware_version, + serial_number=system.serial, + ) + entry.runtime_data = ActronAirRuntimeData( api=api, system_coordinators=system_coordinators, diff --git a/homeassistant/components/actron_air/coordinator.py b/homeassistant/components/actron_air/coordinator.py index b3c2383197d9..09c17824448a 100644 --- a/homeassistant/components/actron_air/coordinator.py +++ b/homeassistant/components/actron_air/coordinator.py @@ -40,6 +40,8 @@ type ActronAirConfigEntry = ConfigEntry[ActronAirRuntimeData] class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirStatus]): """System coordinator for Actron Air integration.""" + config_entry: ActronAirConfigEntry + def __init__( self, hass: HomeAssistant, diff --git a/homeassistant/components/actron_air/entity.py b/homeassistant/components/actron_air/entity.py index 0b1bb5e759dd..f0c2abe7142c 100644 --- a/homeassistant/components/actron_air/entity.py +++ b/homeassistant/components/actron_air/entity.py @@ -7,6 +7,7 @@ from typing import Any, Concatenate, override from actron_neo_api import ActronAirAPIError, ActronAirZone from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -90,5 +91,9 @@ class ActronAirZoneEntity(ActronAirEntity): manufacturer="Actron Air", model="Zone", suggested_area=zone.title, - via_device=(DOMAIN, self._serial_number), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, self._serial_number), + config_entry_id=coordinator.config_entry.entry_id, + ), ) diff --git a/homeassistant/components/advantage_air/__init__.py b/homeassistant/components/advantage_air/__init__.py index 4114f612fe95..7d2a5515f794 100644 --- a/homeassistant/components/advantage_air/__init__.py +++ b/homeassistant/components/advantage_air/__init__.py @@ -4,7 +4,7 @@ from advantage_air import advantage_air from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType @@ -50,6 +50,18 @@ async def async_setup_entry( entry.runtime_data = coordinator + # Register the system device so child devices can resolve it as their + # via_device parent regardless of platform setup order. + system = coordinator.data["system"] + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, system["rid"])}, + manufacturer="Advantage Air", + model=system["sysType"], + name=system["name"], + sw_version=system["myAppRev"], + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/advantage_air/entity.py b/homeassistant/components/advantage_air/entity.py index c0f4cd5512c2..975cc901871c 100644 --- a/homeassistant/components/advantage_air/entity.py +++ b/homeassistant/components/advantage_air/entity.py @@ -5,6 +5,7 @@ from typing import Any from advantage_air import ApiError from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -49,7 +50,11 @@ class AdvantageAirAcEntity(AdvantageAirEntity): self._attr_unique_id += f"-{ac_key}" self._attr_device_info = DeviceInfo( - via_device=(DOMAIN, self.coordinator.data["system"]["rid"]), + via_device_id=dr.async_get_device_id_by_identifier( + self.coordinator.hass, + (DOMAIN, self.coordinator.data["system"]["rid"]), + config_entry_id=self.coordinator.config_entry.entry_id, + ), identifiers={(DOMAIN, self._attr_unique_id)}, manufacturer="Advantage Air", model=self.coordinator.data["system"]["sysType"], @@ -105,7 +110,11 @@ class AdvantageAirThingEntity(AdvantageAirEntity): self._attr_unique_id += f"-{self._id}" self._attr_device_info = DeviceInfo( - via_device=(DOMAIN, self.coordinator.data["system"]["rid"]), + via_device_id=dr.async_get_device_id_by_identifier( + self.coordinator.hass, + (DOMAIN, self.coordinator.data["system"]["rid"]), + config_entry_id=self.coordinator.config_entry.entry_id, + ), identifiers={(DOMAIN, self._attr_unique_id)}, manufacturer="Advantage Air", model="MyPlace", diff --git a/homeassistant/components/advantage_air/light.py b/homeassistant/components/advantage_air/light.py index d3c94da3fd1a..a76f30a33e9c 100644 --- a/homeassistant/components/advantage_air/light.py +++ b/homeassistant/components/advantage_air/light.py @@ -4,6 +4,7 @@ from typing import Any, override from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -55,7 +56,11 @@ class AdvantageAirLight(AdvantageAirEntity, LightEntity): self._attr_unique_id += f"-{self._id}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._attr_unique_id)}, - via_device=(DOMAIN, self.coordinator.data["system"]["rid"]), + via_device_id=dr.async_get_device_id_by_identifier( + self.coordinator.hass, + (DOMAIN, self.coordinator.data["system"]["rid"]), + config_entry_id=self.coordinator.config_entry.entry_id, + ), manufacturer="Advantage Air", model=light.get("moduleType"), name=light["name"], diff --git a/homeassistant/components/airzone/__init__.py b/homeassistant/components/airzone/__init__.py index af144ed84794..b4234b193c24 100644 --- a/homeassistant/components/airzone/__init__.py +++ b/homeassistant/components/airzone/__init__.py @@ -6,8 +6,12 @@ from typing import Any from aioairzone.const import ( AZD_FIRMWARE, AZD_FULL_NAME, + AZD_HOT_WATER, + AZD_ID, AZD_MAC, AZD_MODEL, + AZD_NAME, + AZD_SYSTEMS, AZD_WEBSERVER, DEFAULT_SYSTEM_ID, ) @@ -93,19 +97,58 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> b device_registry = dr.async_get(hass) - ws_data: dict[str, Any] | None = coordinator.data.get(AZD_WEBSERVER) - if ws_data is not None: - mac = ws_data.get(AZD_MAC, "") + @callback + def _async_register_devices() -> None: + """Register the WebServer, System, and DHW via_device parents. - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, mac)}, - identifiers={(DOMAIN, f"{entry.entry_id}_ws")}, - manufacturer=MANUFACTURER, - model=ws_data.get(AZD_MODEL), - name=ws_data.get(AZD_FULL_NAME), - sw_version=ws_data.get(AZD_FIRMWARE), - ) + The WebServer, Systems, and DHW can appear on later coordinator + updates, so this runs on every update (before the platform + listeners) to keep the via_device parents registered before their + child entities are added. + """ + ws_device_id: str | None = None + ws_data: dict[str, Any] | None = coordinator.data.get(AZD_WEBSERVER) + if ws_data is not None: + mac = ws_data.get(AZD_MAC, "") + + ws_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + identifiers={(DOMAIN, f"{entry.entry_id}_ws")}, + manufacturer=MANUFACTURER, + model=ws_data.get(AZD_MODEL), + name=ws_data.get(AZD_FULL_NAME), + sw_version=ws_data.get(AZD_FIRMWARE), + ) + ws_device_id = ws_device.id + + for system_data in coordinator.data.get(AZD_SYSTEMS, {}).values(): + system_id = system_data[AZD_ID] + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, f"{entry.entry_id}_{system_id}")}, + manufacturer=MANUFACTURER, + model=system_data.get(AZD_MODEL), + name=f"System {system_id}", + sw_version=system_data.get(AZD_FIRMWARE), + via_device_id=ws_device_id, + ) + + dhw_data: dict[str, Any] | None = coordinator.data.get(AZD_HOT_WATER) + if dhw_data is not None: + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, f"{entry.entry_id}_dhw")}, + manufacturer=MANUFACTURER, + model="DHW", + name=dhw_data.get(AZD_NAME), + via_device_id=ws_device_id, + ) + + # Register the parents before forwarding platforms, and keep them registered + # as new devices appear so child entities can resolve their via_device_id. + _async_register_devices() + entry.async_on_unload(coordinator.async_add_listener(_async_register_devices)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/airzone/entity.py b/homeassistant/components/airzone/entity.py index c02cd6ac1674..24b3550cfc5c 100644 --- a/homeassistant/components/airzone/entity.py +++ b/homeassistant/components/airzone/entity.py @@ -67,7 +67,13 @@ class AirzoneSystemEntity(AirzoneEntity): sw_version=self.get_airzone_value(AZD_FIRMWARE), ) if AZD_WEBSERVER in self.coordinator.data: - self._attr_device_info["via_device"] = (DOMAIN, f"{entry.entry_id}_ws") + self._attr_device_info["via_device_id"] = ( + dr.async_get_device_id_by_identifier( + self.coordinator.hass, + (DOMAIN, f"{entry.entry_id}_ws"), + config_entry_id=entry.entry_id, + ) + ) self._attr_unique_id = entry.unique_id or entry.entry_id @property @@ -120,7 +126,13 @@ class AirzoneHotWaterEntity(AirzoneEntity): name=self.get_airzone_value(AZD_NAME), ) if AZD_WEBSERVER in self.coordinator.data: - self._attr_device_info["via_device"] = (DOMAIN, f"{entry.entry_id}_ws") + self._attr_device_info["via_device_id"] = ( + dr.async_get_device_id_by_identifier( + self.coordinator.hass, + (DOMAIN, f"{entry.entry_id}_ws"), + config_entry_id=entry.entry_id, + ) + ) self._attr_unique_id = entry.unique_id or entry.entry_id @override @@ -195,7 +207,11 @@ class AirzoneZoneEntity(AirzoneEntity): model=self.get_airzone_value(AZD_THERMOSTAT_MODEL), name=zone_data[AZD_NAME], sw_version=self.get_airzone_value(AZD_THERMOSTAT_FW), - via_device=(DOMAIN, f"{entry.entry_id}_{self.system_id}"), + via_device_id=dr.async_get_device_id_by_identifier( + self.coordinator.hass, + (DOMAIN, f"{entry.entry_id}_{self.system_id}"), + config_entry_id=entry.entry_id, + ), ) self._attr_unique_id = entry.unique_id or entry.entry_id diff --git a/homeassistant/components/airzone_cloud/__init__.py b/homeassistant/components/airzone_cloud/__init__.py index a1da7e0216ae..50f1b1a71fe2 100644 --- a/homeassistant/components/airzone_cloud/__init__.py +++ b/homeassistant/components/airzone_cloud/__init__.py @@ -2,11 +2,20 @@ from aioairzone_cloud.cloudapi import AirzoneCloudApi from aioairzone_cloud.common import ConnectionOptions +from aioairzone_cloud.const import ( + AZD_FIRMWARE, + AZD_MODEL, + AZD_NAME, + AZD_SYSTEMS, + AZD_WEBSERVER, + AZD_WEBSERVERS, +) from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import aiohttp_client, device_registry as dr +from .const import DOMAIN, MANUFACTURER from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator PLATFORMS: list[Platform] = [ @@ -42,11 +51,53 @@ async def async_setup_entry( entry.runtime_data = coordinator + _async_register_devices(hass, entry, coordinator) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True +@callback +def _async_register_devices( + hass: HomeAssistant, + entry: AirzoneCloudConfigEntry, + coordinator: AirzoneUpdateCoordinator, +) -> None: + """Register WebServer and System devices referenced as via_device parents. + + Child devices resolve their via_device_id at add time, so the parents must + already exist regardless of which platform creates their own entities. + """ + device_registry = dr.async_get(hass) + + for ws_id, ws_data in coordinator.data.get(AZD_WEBSERVERS, {}).items(): + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, ws_id)}, + identifiers={(DOMAIN, ws_id)}, + manufacturer=MANUFACTURER, + model="WebServer", + name=ws_data[AZD_NAME], + sw_version=ws_data[AZD_FIRMWARE], + ) + + for system_id, system_data in coordinator.data.get(AZD_SYSTEMS, {}).items(): + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, system_id)}, + manufacturer=MANUFACTURER, + model=system_data.get(AZD_MODEL), + name=system_data[AZD_NAME], + sw_version=system_data.get(AZD_FIRMWARE), + via_device_id=dr.async_get_device_id_by_identifier( + hass, + (DOMAIN, system_data[AZD_WEBSERVER]), + config_entry_id=entry.entry_id, + ), + ) + + async def async_unload_entry( hass: HomeAssistant, entry: AirzoneCloudConfigEntry ) -> bool: diff --git a/homeassistant/components/airzone_cloud/entity.py b/homeassistant/components/airzone_cloud/entity.py index d0765ea5688b..e025838ef4a8 100644 --- a/homeassistant/components/airzone_cloud/entity.py +++ b/homeassistant/components/airzone_cloud/entity.py @@ -73,7 +73,11 @@ class AirzoneAidooEntity(AirzoneEntity): manufacturer=MANUFACTURER, model=aidoo_data[AZD_MODEL], name=aidoo_data[AZD_NAME], - via_device=(DOMAIN, aidoo_data[AZD_WEBSERVER]), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, aidoo_data[AZD_WEBSERVER]), + config_entry_id=coordinator.config_entry.entry_id, + ), ) @override @@ -164,7 +168,11 @@ class AirzoneHotWaterEntity(AirzoneEntity): manufacturer=MANUFACTURER, model="Hot Water", name=dhw_data[AZD_NAME], - via_device=(DOMAIN, dhw_data[AZD_WEBSERVER]), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, dhw_data[AZD_WEBSERVER]), + config_entry_id=coordinator.config_entry.entry_id, + ), ) @override @@ -257,7 +265,11 @@ class AirzoneSystemEntity(AirzoneEntity): model=system_data.get(AZD_MODEL), manufacturer=MANUFACTURER, name=system_data[AZD_NAME], - via_device=(DOMAIN, system_data[AZD_WEBSERVER]), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, system_data[AZD_WEBSERVER]), + config_entry_id=coordinator.config_entry.entry_id, + ), sw_version=system_data.get(AZD_FIRMWARE), ) @@ -322,7 +334,11 @@ class AirzoneZoneEntity(AirzoneEntity): model=zone_data.get(AZD_THERMOSTAT_MODEL), manufacturer=MANUFACTURER, name=zone_data[AZD_NAME], - via_device=(DOMAIN, self.system_id), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, self.system_id), + config_entry_id=coordinator.config_entry.entry_id, + ), sw_version=zone_data.get(AZD_THERMOSTAT_FW), ) diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index e70e5719894d..67537146fc3d 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -168,17 +168,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) if entry.version == 2 and entry.minor_version == 1: - # Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1 - device_registry = dr.async_get(hass) - for device in dr.async_entries_for_config_entry( - device_registry, entry.entry_id - ): - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - remove_config_subentry_id=None, - ) - + # Devices left in both the config entry and its subentry by Home Assistant Core + # 2025.7.0b0-2025.7.0b1 are collapsed onto the subentry by the device registry + # migration, so there's nothing to correct here. hass.config_entries.async_update_entry(entry, minor_version=2) if entry.version == 2 and entry.minor_version == 2: diff --git a/homeassistant/components/aquacell/config_flow.py b/homeassistant/components/aquacell/config_flow.py index 2ec35bc1f8b7..ddba8fbd23b4 100644 --- a/homeassistant/components/aquacell/config_flow.py +++ b/homeassistant/components/aquacell/config_flow.py @@ -1,8 +1,8 @@ """Config flow for Aquacell integration.""" from collections.abc import Mapping -from datetime import datetime import logging +import time from typing import Any, override from aioaquacell import ApiException, AquacellApi, AuthenticationFailed @@ -76,7 +76,7 @@ class AquaCellConfigFlow(ConfigFlow, domain=DOMAIN): **user_input, CONF_BRAND: user_input[CONF_BRAND], CONF_REFRESH_TOKEN: refresh_token, - CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(), # pylint: disable=home-assistant-enforce-naive-now + CONF_REFRESH_TOKEN_CREATION_TIME: time.time(), }, ) @@ -120,7 +120,7 @@ class AquaCellConfigFlow(ConfigFlow, domain=DOMAIN): data_updates={ CONF_PASSWORD: user_input[CONF_PASSWORD], CONF_REFRESH_TOKEN: refresh_token, - CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(), # pylint: disable=home-assistant-enforce-naive-now + CONF_REFRESH_TOKEN_CREATION_TIME: time.time(), }, ) diff --git a/homeassistant/components/aquacell/coordinator.py b/homeassistant/components/aquacell/coordinator.py index 173b263abac7..a7968c91a93d 100644 --- a/homeassistant/components/aquacell/coordinator.py +++ b/homeassistant/components/aquacell/coordinator.py @@ -1,8 +1,8 @@ """Coordinator to update data from Aquacell API.""" import asyncio -from datetime import datetime import logging +import time from typing import override from aioaquacell import ( @@ -73,7 +73,7 @@ class AquacellCoordinator(DataUpdateCoordinator[dict[str, Softener]]): + REFRESH_TOKEN_EXPIRY_TIME.total_seconds() ) try: - if datetime.now().timestamp() >= expiry_time: # pylint: disable=home-assistant-enforce-naive-now + if time.time() >= expiry_time: await self._reauthenticate() else: await self.aquacell_api.authenticate_refresh(self.refresh_token) @@ -94,7 +94,7 @@ class AquacellCoordinator(DataUpdateCoordinator[dict[str, Softener]]): data = { **self.config_entry.data, CONF_REFRESH_TOKEN: self.refresh_token, - CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(), # pylint: disable=home-assistant-enforce-naive-now + CONF_REFRESH_TOKEN_CREATION_TIME: time.time(), } self.hass.config_entries.async_update_entry(self.config_entry, data=data) diff --git a/homeassistant/components/compit/binary_sensor.py b/homeassistant/components/compit/binary_sensor.py index d4b5eda2e827..bf8397643bdb 100644 --- a/homeassistant/components/compit/binary_sensor.py +++ b/homeassistant/components/compit/binary_sensor.py @@ -21,7 +21,7 @@ from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator PARALLEL_UPDATES = 0 NO_SENSOR = "no_sensor" -ON_STATES = ["on", "yes", "charging", "alert", "exceeded"] +ON_STATES = ["on", "yes", "charging", "alert", "exceeded", "open"] DESCRIPTIONS: dict[CompitParameter, BinarySensorEntityDescription] = { CompitParameter.AIRING: BinarySensorEntityDescription( @@ -53,6 +53,18 @@ DESCRIPTIONS: dict[CompitParameter, BinarySensorEntityDescription] = { device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, ), + CompitParameter.GWC: BinarySensorEntityDescription( + key=CompitParameter.GWC.value, + translation_key="ground_heat_exchanger_attached", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + ), + CompitParameter.MIXER_PUMP_STATUS: BinarySensorEntityDescription( + key=CompitParameter.MIXER_PUMP_STATUS.value, + translation_key="mixer_pump_status", + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + ), CompitParameter.PUMP_STATUS: BinarySensorEntityDescription( key=CompitParameter.PUMP_STATUS.value, translation_key="pump_status", @@ -77,10 +89,20 @@ class CompitDeviceDescription: DEVICE_DEFINITIONS: dict[int, CompitDeviceDescription] = { + 3: CompitDeviceDescription( + name="R810", + parameters={ + CompitParameter.MIXER_PUMP_STATUS: DESCRIPTIONS[ + CompitParameter.MIXER_PUMP_STATUS + ], + }, + ), 12: CompitDeviceDescription( name="Nano Color", parameters={ + CompitParameter.AIRING: DESCRIPTIONS[CompitParameter.AIRING], CompitParameter.CO2_LEVEL: DESCRIPTIONS[CompitParameter.CO2_LEVEL], + CompitParameter.GWC: DESCRIPTIONS[CompitParameter.GWC], }, ), 78: CompitDeviceDescription( @@ -98,6 +120,7 @@ DEVICE_DEFINITIONS: dict[int, CompitDeviceDescription] = { parameters={ CompitParameter.AIRING: DESCRIPTIONS[CompitParameter.AIRING], CompitParameter.CO2_LEVEL: DESCRIPTIONS[CompitParameter.CO2_LEVEL], + CompitParameter.GWC: DESCRIPTIONS[CompitParameter.GWC], }, ), 225: CompitDeviceDescription( diff --git a/homeassistant/components/compit/icons.json b/homeassistant/components/compit/icons.json index 90075efef44d..13f95356dbc4 100644 --- a/homeassistant/components/compit/icons.json +++ b/homeassistant/components/compit/icons.json @@ -13,6 +13,12 @@ "dust_alert": { "default": "mdi:alert" }, + "ground_heat_exchanger_attached": { + "default": "mdi:heat-pump" + }, + "mixer_pump_status": { + "default": "mdi:pump" + }, "pump_status": { "default": "mdi:pump" }, diff --git a/homeassistant/components/compit/strings.json b/homeassistant/components/compit/strings.json index c555485de5a7..596156e694b6 100644 --- a/homeassistant/components/compit/strings.json +++ b/homeassistant/components/compit/strings.json @@ -46,6 +46,12 @@ "dust_alert": { "name": "Dust alert" }, + "ground_heat_exchanger_attached": { + "name": "Ground heat exchanger attached" + }, + "mixer_pump_status": { + "name": "Mixer pump" + }, "pump_status": { "name": "Pump status" }, diff --git a/homeassistant/components/coolmaster/config_flow.py b/homeassistant/components/coolmaster/config_flow.py index 238d0ea58181..f96035df0233 100644 --- a/homeassistant/components/coolmaster/config_flow.py +++ b/homeassistant/components/coolmaster/config_flow.py @@ -86,6 +86,8 @@ class CoolmasterConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is None: return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + errors = {} host = user_input[CONF_HOST] diff --git a/homeassistant/components/coolmaster/strings.json b/homeassistant/components/coolmaster/strings.json index abebf548f46d..db89dfd8dbf2 100644 --- a/homeassistant/components/coolmaster/strings.json +++ b/homeassistant/components/coolmaster/strings.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "no_units": "Could not find any HVAC units in CoolMasterNet host." diff --git a/homeassistant/components/directv/__init__.py b/homeassistant/components/directv/__init__.py index a6aa9ea9745e..8e895602caed 100644 --- a/homeassistant/components/directv/__init__.py +++ b/homeassistant/components/directv/__init__.py @@ -8,8 +8,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .const import DOMAIN + PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE] SCAN_INTERVAL = timedelta(seconds=30) @@ -28,6 +31,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: DirecTVConfigEntry) -> b entry.runtime_data = dtv + # Register the receiver device so client entities can link to it via_device_id. + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, dtv.device.info.receiver_id)}, + manufacturer=dtv.device.info.brand, + name=next( + ( + str.title(location.name) + for location in dtv.device.locations + if not location.client + ), + None, + ), + sw_version=dtv.device.info.version, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/directv/entity.py b/homeassistant/components/directv/entity.py index 4f0126dcc249..0031fc3e149c 100644 --- a/homeassistant/components/directv/entity.py +++ b/homeassistant/components/directv/entity.py @@ -2,9 +2,12 @@ from directv import DIRECTV +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity +from . import DirecTVConfigEntry from .const import DOMAIN @@ -14,16 +17,32 @@ class DIRECTVEntity(Entity): _attr_has_entity_name = True _attr_name = None - def __init__(self, *, dtv: DIRECTV, name: str, address: str = "0") -> None: + def __init__( + self, + *, + hass: HomeAssistant, + dtv: DIRECTV, + entry: DirecTVConfigEntry, + name: str, + address: str = "0", + ) -> None: """Initialize the DirecTV entity.""" self._address = address self._device_id = address if address != "0" else dtv.device.info.receiver_id self._is_client = address != "0" self.dtv = dtv + via_device_id: str | None = None + if self._is_client: + via_device_id = dr.async_get_device_id_by_identifier( + hass, + (DOMAIN, dtv.device.info.receiver_id), + config_entry_id=entry.entry_id, + ) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._device_id)}, - manufacturer=self.dtv.device.info.brand, + manufacturer=dtv.device.info.brand, name=name, - sw_version=self.dtv.device.info.version, - via_device=(DOMAIN, self.dtv.device.info.receiver_id), + sw_version=dtv.device.info.version, ) + if via_device_id is not None: + self._attr_device_info["via_device_id"] = via_device_id diff --git a/homeassistant/components/directv/media_player.py b/homeassistant/components/directv/media_player.py index bf21c0955fc1..bebaedfd41b6 100644 --- a/homeassistant/components/directv/media_player.py +++ b/homeassistant/components/directv/media_player.py @@ -61,7 +61,9 @@ async def async_setup_entry( async_add_entities( ( DIRECTVMediaPlayer( + hass=hass, dtv=dtv, + entry=entry, name=str.title(location.name), address=location.address, ) @@ -74,10 +76,20 @@ async def async_setup_entry( class DIRECTVMediaPlayer(DIRECTVEntity, MediaPlayerEntity): """Representation of a DirecTV receiver on the network.""" - def __init__(self, *, dtv: DIRECTV, name: str, address: str = "0") -> None: + def __init__( + self, + *, + hass: HomeAssistant, + dtv: DIRECTV, + entry: DirecTVConfigEntry, + name: str, + address: str = "0", + ) -> None: """Initialize DirecTV media player.""" super().__init__( + hass=hass, dtv=dtv, + entry=entry, name=name, address=address, ) diff --git a/homeassistant/components/directv/remote.py b/homeassistant/components/directv/remote.py index 3484949eb139..ecd293dc84db 100644 --- a/homeassistant/components/directv/remote.py +++ b/homeassistant/components/directv/remote.py @@ -29,7 +29,9 @@ async def async_setup_entry( async_add_entities( ( DIRECTVRemote( + hass=hass, dtv=dtv, + entry=entry, name=str.title(location.name), address=location.address, ) @@ -42,10 +44,20 @@ async def async_setup_entry( class DIRECTVRemote(DIRECTVEntity, RemoteEntity): """Device that sends commands to a DirecTV receiver.""" - def __init__(self, *, dtv: DIRECTV, name: str, address: str = "0") -> None: + def __init__( + self, + *, + hass: HomeAssistant, + dtv: DIRECTV, + entry: DirecTVConfigEntry, + name: str, + address: str = "0", + ) -> None: """Initialize DirecTV remote.""" super().__init__( + hass=hass, dtv=dtv, + entry=entry, name=name, address=address, ) diff --git a/homeassistant/components/eheimdigital/__init__.py b/homeassistant/components/eheimdigital/__init__.py index 8e1f7b5a4bab..4a1efdd2f65d 100644 --- a/homeassistant/components/eheimdigital/__init__.py +++ b/homeassistant/components/eheimdigital/__init__.py @@ -1,11 +1,17 @@ """The EHEIM Digital integration.""" +from typing import TYPE_CHECKING + +from eheimdigital.device import EheimDigitalDevice + from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator +from .entity import async_device_info PLATFORMS = [ Platform.BINARY_SENSOR, @@ -28,6 +34,17 @@ async def async_setup_entry( await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator + main = coordinator.hub.main + if TYPE_CHECKING: + # After the first refresh at least one device is found and so there is + # always a main device set. + assert isinstance(main, EheimDigitalDevice) + # Register the main device up front so child devices can resolve their + # via_device_id link during concurrent platform setup. + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, **async_device_info(coordinator, main) + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/eheimdigital/entity.py b/homeassistant/components/eheimdigital/entity.py index ca6e1525c845..9753ef197591 100644 --- a/homeassistant/components/eheimdigital/entity.py +++ b/homeassistant/components/eheimdigital/entity.py @@ -10,6 +10,7 @@ from eheimdigital.types import EheimDigitalClientError from homeassistant.const import CONF_HOST from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -17,6 +18,22 @@ from .const import DOMAIN from .coordinator import EheimDigitalUpdateCoordinator +def async_device_info( + coordinator: EheimDigitalUpdateCoordinator, device: EheimDigitalDevice +) -> DeviceInfo: + """Return the base device info for an EHEIM Digital device.""" + return DeviceInfo( + configuration_url=f"http://{coordinator.config_entry.data[CONF_HOST]}", + name=device.name, + connections={(CONNECTION_NETWORK_MAC, device.mac_address)}, + manufacturer="EHEIM", + model=device.model_name, + identifiers={(DOMAIN, device.mac_address)}, + suggested_area=device.aquarium_name, + sw_version=device.sw_version, + ) + + class EheimDigitalEntity[_DeviceT: EheimDigitalDevice]( CoordinatorEntity[EheimDigitalUpdateCoordinator], ABC ): @@ -29,21 +46,22 @@ class EheimDigitalEntity[_DeviceT: EheimDigitalDevice]( ) -> None: """Initialize a EHEIM Digital entity.""" super().__init__(coordinator) + main = coordinator.hub.main if TYPE_CHECKING: # At this point at least one device is found # and so there is always a main device set - assert isinstance(coordinator.hub.main, EheimDigitalDevice) - self._attr_device_info = DeviceInfo( - configuration_url=f"http://{coordinator.config_entry.data[CONF_HOST]}", - name=device.name, - connections={(CONNECTION_NETWORK_MAC, device.mac_address)}, - manufacturer="EHEIM", - model=device.model_name, - identifiers={(DOMAIN, device.mac_address)}, - suggested_area=device.aquarium_name, - sw_version=device.sw_version, - via_device=(DOMAIN, coordinator.hub.main.mac_address), - ) + assert isinstance(main, EheimDigitalDevice) + self._attr_device_info = async_device_info(coordinator, device) + if device.mac_address != main.mac_address: + # The main device is registered during setup, before the platforms + # are forwarded, so this link always resolves deterministically. + self._attr_device_info["via_device_id"] = ( + dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, main.mac_address), + config_entry_id=coordinator.config_entry.entry_id, + ) + ) self._device = device self._device_address = device.mac_address diff --git a/homeassistant/components/eheimdigital/manifest.json b/homeassistant/components/eheimdigital/manifest.json index 6b0cb372fcbc..32f8d0b15704 100644 --- a/homeassistant/components/eheimdigital/manifest.json +++ b/homeassistant/components/eheimdigital/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["eheimdigital"], "quality_scale": "platinum", - "requirements": ["eheimdigital==1.7.0"], + "requirements": ["eheimdigital==1.7.1"], "zeroconf": [ { "name": "eheimdigital._http._tcp.local.", "type": "_http._tcp.local." } ] diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 712e5206f410..74774a09de20 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -26,7 +26,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType from homeassistant.util.network import is_ip_address @@ -58,6 +58,7 @@ from .discovery import ( async_trigger_discovery, async_update_entry_from_discovery, ) +from .entity import create_elk_system_device_info from .models import ELKM1Data from .services import async_setup_services @@ -308,6 +309,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ElkM1ConfigEntry) -> boo keypads={}, ) + # Register the ElkM1 system device before forwarding platforms so entities + # on any platform can deterministically resolve it as their via_device. + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + **create_elk_system_device_info(elk, prefix, entry.unique_id), + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/elkm1/entity.py b/homeassistant/components/elkm1/entity.py index a9c4b6494cc1..b2fc9fcfd981 100644 --- a/homeassistant/components/elkm1/entity.py +++ b/homeassistant/components/elkm1/entity.py @@ -10,6 +10,7 @@ from elkm1_lib.elk import Elk from homeassistant.const import ATTR_CONNECTIONS from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import Entity @@ -47,6 +48,23 @@ def create_elk_entities( return entities +def create_elk_system_device_info(elk: Elk, prefix: str, mac: str | None) -> DeviceInfo: + """Return the device info for the ElkM1 system device.""" + device_name = "ElkM1" + if prefix: + device_name += f" {prefix}" + device_info = DeviceInfo( + identifiers={(DOMAIN, f"{prefix}_system")}, + manufacturer="ELK Products, Inc.", + model="M1", + name=device_name, + sw_version=elk.panel.elkm1_version, + ) + if mac: + device_info[ATTR_CONNECTIONS] = {(CONNECTION_NETWORK_MAC, mac)} + return device_info + + def generate_unique_id(prefix: str, element: Element) -> str: """Generate a unique id.""" # unique_id starts with elkm1_ iff there is no prefix @@ -128,10 +146,16 @@ class ElkEntity(Entity): @override def device_info(self) -> DeviceInfo: """Device info connecting via the ElkM1 system.""" + config_entry = self.platform.config_entry + assert config_entry return DeviceInfo( name=self._element.name, identifiers={(DOMAIN, self._unique_id)}, - via_device=(DOMAIN, f"{self._prefix}_system"), + via_device_id=dr.async_get_device_id_by_identifier( + self.hass, + (DOMAIN, f"{self._prefix}_system"), + config_entry_id=config_entry.entry_id, + ), ) @@ -142,16 +166,4 @@ class ElkAttachedEntity(ElkEntity): @override def device_info(self) -> DeviceInfo: """Device info for the underlying ElkM1 system.""" - device_name = "ElkM1" - if self._prefix: - device_name += f" {self._prefix}" - device_info = DeviceInfo( - identifiers={(DOMAIN, f"{self._prefix}_system")}, - manufacturer="ELK Products, Inc.", - model="M1", - name=device_name, - sw_version=self._elk.panel.elkm1_version, - ) - if self._mac: - device_info[ATTR_CONNECTIONS] = {(CONNECTION_NETWORK_MAC, self._mac)} - return device_info + return create_elk_system_device_info(self._elk, self._prefix, self._mac) diff --git a/homeassistant/components/energieleser/manifest.json b/homeassistant/components/energieleser/manifest.json index 72b5f99f0cd9..933ee9ada770 100644 --- a/homeassistant/components/energieleser/manifest.json +++ b/homeassistant/components/energieleser/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["energieleser==0.1.5"], + "requirements": ["energieleser==0.1.6"], "zeroconf": [ { "type": "_stromleser._tcp.local." diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index d09e7d7f6481..e86f6ce3f1be 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "quality_scale": "platinum", "requirements": [ "aioesphomeapi==45.6.1", - "esphome-dashboard-api==1.3.0", + "esphome-dashboard-api==1.4.0", "bleak-esphome==3.9.7" ], "zeroconf": ["_esphomelib._tcp.local."] diff --git a/homeassistant/components/feedreader/coordinator.py b/homeassistant/components/feedreader/coordinator.py index 42ca2108d1ac..22c09ed4162a 100644 --- a/homeassistant/components/feedreader/coordinator.py +++ b/homeassistant/components/feedreader/coordinator.py @@ -171,13 +171,15 @@ class FeedReaderCoordinator( """Update last_entry_timestamp and fire entry.""" # Check if the entry has a updated or published date. # Start from a updated date because generally `updated` > `published`. - if time_stamp := entry.get("updated_parsed") or entry.get("published_parsed"): - self._last_entry_timestamp = time_stamp - else: + time_stamp = entry.get("updated_parsed") or entry.get("published_parsed") + if time_stamp is None: _LOGGER.debug( "No updated_parsed or published_parsed info available for entry %s", entry, ) + elif time_stamp and time_stamp > self._last_entry_timestamp: + self._last_entry_timestamp = time_stamp + entry["feed_url"] = self.url self.hass.bus.async_fire(self._event_type, entry) _LOGGER.debug("New event fired for entry %s", entry.get("link")) diff --git a/homeassistant/components/firefly_iii/coordinator.py b/homeassistant/components/firefly_iii/coordinator.py index a2e2d2a1bf6f..333fcf8c4481 100644 --- a/homeassistant/components/firefly_iii/coordinator.py +++ b/homeassistant/components/firefly_iii/coordinator.py @@ -2,7 +2,7 @@ import asyncio from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import timedelta import logging from typing import override @@ -21,6 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util from .const import DOMAIN @@ -76,25 +77,22 @@ class FireflyDataUpdateCoordinator(DataUpdateCoordinator[FireflyCoordinatorData] raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth", - translation_placeholders={"error": repr(err)}, ) from err except FireflyConnectionError as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="cannot_connect", - translation_placeholders={"error": repr(err)}, ) from err except FireflyTimeoutError as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="timeout_connect", - translation_placeholders={"error": repr(err)}, ) from err @override async def _async_update_data(self) -> FireflyCoordinatorData: """Fetch data from Firefly III API.""" - now = datetime.now() # pylint: disable=home-assistant-enforce-naive-now + now = dt_util.now() start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) end_date = now @@ -127,19 +125,16 @@ class FireflyDataUpdateCoordinator(DataUpdateCoordinator[FireflyCoordinatorData] raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth", - translation_placeholders={"error": repr(err)}, ) from err except FireflyConnectionError as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="cannot_connect", - translation_placeholders={"error": repr(err)}, ) from err except FireflyTimeoutError as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="timeout_connect", - translation_placeholders={"error": repr(err)}, ) from err return FireflyCoordinatorData( diff --git a/homeassistant/components/firefly_iii/strings.json b/homeassistant/components/firefly_iii/strings.json index d367a6869936..9c595e36a89f 100644 --- a/homeassistant/components/firefly_iii/strings.json +++ b/homeassistant/components/firefly_iii/strings.json @@ -89,13 +89,13 @@ }, "exceptions": { "cannot_connect": { - "message": "An error occurred while trying to connect to the Firefly III instance: {error}" + "message": "An error occurred while trying to connect to the Firefly III instance" }, "invalid_auth": { - "message": "An error occurred while trying to authenticate: {error}" + "message": "An error occurred while trying to authenticate" }, "timeout_connect": { - "message": "A timeout occurred while trying to connect to the Firefly III instance: {error}" + "message": "A timeout occurred while trying to connect to the Firefly III instance" } } } diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 420a0e63c390..8d03882c004b 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260729.1"] + "requirements": ["home-assistant-frontend==20260729.3"] } diff --git a/homeassistant/components/frontier_silicon/manifest.json b/homeassistant/components/frontier_silicon/manifest.json index 461265433d29..cf80c981b123 100644 --- a/homeassistant/components/frontier_silicon/manifest.json +++ b/homeassistant/components/frontier_silicon/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["afsapi"], - "requirements": ["afsapi==1.0.1"], + "requirements": ["afsapi==1.0.2"], "ssdp": [ { "st": "urn:schemas-frontier-silicon-com:undok:fsapi:1" diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 3186d9c50bad..3df476d36281 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -230,17 +230,9 @@ async def async_migrate_entry( ), ) - # Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1 - device_registry = dr.async_get(hass) - for device in dr.async_entries_for_config_entry( - device_registry, entry.entry_id - ): - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - remove_config_subentry_id=None, - ) - + # Devices left in both the config entry and its subentry by Home Assistant Core + # 2025.7.0b0-2025.7.0b1 are collapsed onto the subentry by the device registry + # migration, so there's nothing to correct here. hass.config_entries.async_update_entry(entry, minor_version=2) if entry.version == 2 and entry.minor_version == 2: diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index d6496fae7d4e..d8ef2f223208 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -25,6 +25,7 @@ from homeassistant.helpers import llm from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, + NumberSelectorMode, SelectOptionDict, SelectSelector, SelectSelectorConfig, @@ -41,6 +42,8 @@ from .const import ( CONF_RECOMMENDED, CONF_SEXUAL_BLOCK_THRESHOLD, CONF_TEMPERATURE, + CONF_THINKING_BUDGET, + CONF_THINKING_LEVEL, CONF_TOP_K, CONF_TOP_P, CONF_USE_GOOGLE_SEARCH_TOOL, @@ -59,6 +62,8 @@ from .const import ( RECOMMENDED_STT_MODEL, RECOMMENDED_STT_OPTIONS, RECOMMENDED_TEMPERATURE, + RECOMMENDED_THINKING_BUDGET, + RECOMMENDED_THINKING_LEVEL, RECOMMENDED_TOP_K, RECOMMENDED_TOP_P, RECOMMENDED_TTS_MODEL, @@ -438,6 +443,7 @@ async def google_generative_ai_config_option_schema( ): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)), } ) + if subentry_type != "tts": schema.update( { @@ -456,6 +462,35 @@ async def google_generative_ai_config_option_schema( description={"suggested_value": options.get(CONF_MAX_TOKENS)}, default=RECOMMENDED_MAX_TOKENS, ): int, + vol.Optional( + CONF_THINKING_BUDGET, + description={"suggested_value": options.get(CONF_THINKING_BUDGET)}, + default=RECOMMENDED_THINKING_BUDGET, + ): vol.All( + NumberSelector( + NumberSelectorConfig( + min=-1, max=24576, step=1, mode=NumberSelectorMode.BOX + ) + ), + vol.Coerce(int), + ), + vol.Optional( + CONF_THINKING_LEVEL, + description={"suggested_value": options.get(CONF_THINKING_LEVEL)}, + default=RECOMMENDED_THINKING_LEVEL, + ): SelectSelector( + SelectSelectorConfig( + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_THINKING_LEVEL, + options=[ + "auto", + "minimal", + "low", + "medium", + "high", + ], + ) + ), vol.Optional( CONF_HARASSMENT_BLOCK_THRESHOLD, description={ diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index c71f52150485..9953a9cfe72d 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -40,6 +40,10 @@ CONF_DANGEROUS_BLOCK_THRESHOLD = "dangerous_block_threshold" RECOMMENDED_HARM_BLOCK_THRESHOLD = "BLOCK_MEDIUM_AND_ABOVE" CONF_USE_GOOGLE_SEARCH_TOOL = "enable_google_search_tool" RECOMMENDED_USE_GOOGLE_SEARCH_TOOL = False +CONF_THINKING_BUDGET = "thinking_budget" +RECOMMENDED_THINKING_BUDGET = -1 +CONF_THINKING_LEVEL = "thinking_level" +RECOMMENDED_THINKING_LEVEL = "auto" TIMEOUT_MILLIS = 10000 FILE_POLLING_INTERVAL_SECONDS = 0.05 diff --git a/homeassistant/components/google_generative_ai_conversation/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py index 6964c781ee21..fdf38982ad2c 100644 --- a/homeassistant/components/google_generative_ai_conversation/entity.py +++ b/homeassistant/components/google_generative_ai_conversation/entity.py @@ -28,6 +28,7 @@ from google.genai.types import ( SafetySetting, Schema, ThinkingConfig, + ThinkingLevel, Tool, ToolListUnion, ) @@ -49,6 +50,8 @@ from .const import ( CONF_MAX_TOKENS, CONF_SEXUAL_BLOCK_THRESHOLD, CONF_TEMPERATURE, + CONF_THINKING_BUDGET, + CONF_THINKING_LEVEL, CONF_TOP_K, CONF_TOP_P, CONF_USE_GOOGLE_SEARCH_TOOL, @@ -59,6 +62,8 @@ from .const import ( RECOMMENDED_HARM_BLOCK_THRESHOLD, RECOMMENDED_MAX_TOKENS, RECOMMENDED_TEMPERATURE, + RECOMMENDED_THINKING_BUDGET, + RECOMMENDED_THINKING_LEVEL, RECOMMENDED_TOP_K, RECOMMENDED_TOP_P, TIMEOUT_MILLIS, @@ -91,6 +96,75 @@ SUPPORTED_SCHEMA_KEYS = { } +def _is_thinking_model(model: str) -> bool: + """Check if the model supports thinking configuration.""" + name = model.removeprefix("models/") + # Exclude non-text models (TTS, image generation) + if name.endswith(("tts", "image", "image-preview")): + return False + return name.startswith(("gemini-2.5", "gemini-3")) + + +def _is_gemini_3_model(model: str) -> bool: + """Check if the model is a Gemini 3 series model.""" + name = model.removeprefix("models/") + return name.startswith("gemini-3") + + +def _create_thinking_config( + model: str, + thinking_budget: int, + thinking_level: str | None = None, +) -> ThinkingConfig | None: + """Create a ThinkingConfig based on the model and user configuration. + + Args: + model: The model name (e.g., "models/gemini-2.5-flash"). + thinking_budget: The user-configured thinking budget: + -1 = automatic (default behavior), + 0 = disable thinking, + >0 = custom token budget (Gemini 2.5 only). + thinking_level: The user-configured thinking level for Gemini 3 models: + "auto" = automatic (default), "minimal", "low", "medium", "high". + + """ + if not _is_thinking_model(model): + return None + + if _is_gemini_3_model(model): + name = model.removeprefix("models/") + level_map: dict[str, ThinkingLevel] = { + "minimal": ThinkingLevel.MINIMAL, + "low": ThinkingLevel.LOW, + "medium": ThinkingLevel.MEDIUM, + "high": ThinkingLevel.HIGH, + } + if name.startswith("gemini-3") and "pro" in name: + level_map.pop("minimal") + if thinking_level and thinking_level in level_map: + return ThinkingConfig( + include_thoughts=True, + thinking_level=level_map[thinking_level], + ) + return ThinkingConfig(include_thoughts=True) + + # Gemini 2.5 models use integer thinking_budget + if thinking_budget == -1: + return ThinkingConfig(include_thoughts=True) + + name = model.removeprefix("models/") + if name.startswith("gemini-2.5-pro"): + # gemini-2.5-pro minimum thinking budget is 128 + if thinking_budget < 128: + return ThinkingConfig(include_thoughts=True, thinking_budget=128) + return ThinkingConfig(include_thoughts=True, thinking_budget=thinking_budget) + + if thinking_budget == 0: + return ThinkingConfig(include_thoughts=False, thinking_budget=0) + + return ThinkingConfig(include_thoughts=True, thinking_budget=thinking_budget) + + def _camel_to_snake(name: str) -> str: """Convert camel case to snake case.""" return "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_") @@ -659,11 +733,11 @@ class GoogleGenerativeAILLMBaseEntity(Entity): """Create the GenerateContentConfig for the LLM.""" options = self.subentry.data model = options.get(CONF_CHAT_MODEL, self.default_model) - thinking_config: ThinkingConfig | None = None - if model.startswith("models/gemini-2.5") and not model.endswith( - ("tts", "image", "image-preview") - ): - thinking_config = ThinkingConfig(include_thoughts=True) + thinking_config = _create_thinking_config( + model, + int(options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET)), + options.get(CONF_THINKING_LEVEL, RECOMMENDED_THINKING_LEVEL), + ) return GenerateContentConfig( temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index bd5ef1e968f8..ea2f3dcd946a 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -45,6 +45,8 @@ "recommended": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::recommended%]", "sexual_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::sexual_block_threshold%]", "temperature": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::temperature%]", + "thinking_budget": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::thinking_budget%]", + "thinking_level": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::thinking_level%]", "top_k": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_k%]", "top_p": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_p%]" } @@ -79,12 +81,16 @@ "recommended": "Recommended model settings", "sexual_block_threshold": "Contains references to sexual acts or other lewd content", "temperature": "Temperature", + "thinking_budget": "Thinking budget", + "thinking_level": "Thinking level", "top_k": "Top K", "top_p": "Top P" }, "data_description": { "enable_google_search_tool": "Only works if there is nothing selected in the \"Control Home Assistant\" setting. See docs for a workaround using it with \"Assist\".", - "prompt": "Instruct how the LLM should respond. This can be a template." + "prompt": "Instruct how the LLM should respond. This can be a template.", + "thinking_budget": "Token budget for model reasoning (Gemini 2.5 only). Use -1 for automatic or 0 to disable (not available for Gemini 2.5 Pro).", + "thinking_level": "Thinking level for Gemini 3 models. Ignored for Gemini 2.5 models, which use the thinking budget instead. \"Minimal\" does not guarantee that thinking is off." } } } @@ -112,6 +118,8 @@ "recommended": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::recommended%]", "sexual_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::sexual_block_threshold%]", "temperature": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::temperature%]", + "thinking_budget": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::thinking_budget%]", + "thinking_level": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::thinking_level%]", "top_k": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_k%]", "top_p": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_p%]" }, @@ -149,5 +157,16 @@ } } } + }, + "selector": { + "thinking_level": { + "options": { + "auto": "Auto", + "high": "High", + "low": "Low", + "medium": "Medium", + "minimal": "Minimal" + } + } } } diff --git a/homeassistant/components/google_health/__init__.py b/homeassistant/components/google_health/__init__.py index 3e6eed06135b..8001ac637e0d 100644 --- a/homeassistant/components/google_health/__init__.py +++ b/homeassistant/components/google_health/__init__.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client +from homeassistant.helpers import aiohttp_client, device_registry as dr from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, OAuth2Session, @@ -115,6 +115,17 @@ async def async_setup_entry( sleep_coordinator=sleep_coordinator, ) + # Register the account device up front so the per-device sensors can resolve + # it as their via_device parent even when only the device scope is granted + # (the account-level sensors that would otherwise create it are gated on + # different scopes). + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer="Google", + entry_type=dr.DeviceEntryType.SERVICE, + ) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) return True diff --git a/homeassistant/components/google_health/quality_scale.yaml b/homeassistant/components/google_health/quality_scale.yaml index eab8c89d611d..a00be31878a7 100644 --- a/homeassistant/components/google_health/quality_scale.yaml +++ b/homeassistant/components/google_health/quality_scale.yaml @@ -64,9 +64,7 @@ rules: docs-troubleshooting: done docs-use-cases: done dynamic-devices: done - entity-category: - status: exempt - comment: All entities are user-facing primary sensors. + entity-category: done entity-device-class: done entity-disabled-by-default: status: exempt diff --git a/homeassistant/components/google_health/sensor.py b/homeassistant/components/google_health/sensor.py index 109916395467..1ff0978ab57b 100644 --- a/homeassistant/components/google_health/sensor.py +++ b/homeassistant/components/google_health/sensor.py @@ -15,6 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( PERCENTAGE, + EntityCategory, UnitOfEnergy, UnitOfLength, UnitOfMass, @@ -22,11 +23,17 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceEntryType, + DeviceInfo, + async_get_device_id_by_identifier, +) from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM, UnitSystem from . import GoogleHealthConfigEntry from .const import DOMAIN @@ -50,6 +57,7 @@ class GoogleHealthSensorEntityDescription[ """Class describing Google Health sensor entities.""" value_fn: Callable[[Any], _ValueT] + suggested_unit_fn: Callable[[UnitSystem], str | None] | None = None ACTIVITY_SENSORS: list[ @@ -69,11 +77,17 @@ ACTIVITY_SENSORS: list[ value_fn=lambda data: ( data.distance.millimeters_sum / 1000.0 if data and data.distance else 0.0 ), + suggested_unit_fn=lambda units: ( + UnitOfLength.MILES + if units is US_CUSTOMARY_SYSTEM + else UnitOfLength.KILOMETERS + ), ), GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, float]( key="active_calories", translation_key="active_calories", native_unit_of_measurement=UnitOfEnergy.KILO_CALORIE, + device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, value_fn=lambda data: ( data.active_energy_burned.kcal_sum @@ -85,6 +99,7 @@ ACTIVITY_SENSORS: list[ key="total_calories", translation_key="total_calories", native_unit_of_measurement=UnitOfEnergy.KILO_CALORIE, + device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, value_fn=lambda data: ( data.total_calories.kcal_sum if data and data.total_calories else 0.0 @@ -109,6 +124,9 @@ BODY_SENSORS: list[ value_fn=lambda data: ( data.weight.weight_grams / 1000.0 if data and data.weight else None ), + suggested_unit_fn=lambda units: ( + UnitOfMass.POUNDS if units is US_CUSTOMARY_SYSTEM else None + ), ), GoogleHealthSensorEntityDescription[GoogleHealthBodyCoordinator, int | None]( key="resting_heart_rate", @@ -212,11 +230,15 @@ NUTRITION_SENSORS: list[ if data and data.hydration and data.hydration.amount_consumed else 0.0 ), + suggested_unit_fn=lambda units: ( + UnitOfVolume.FLUID_OUNCES if units is US_CUSTOMARY_SYSTEM else None + ), ), GoogleHealthSensorEntityDescription[GoogleHealthNutritionCoordinator, float]( key="calories_consumed", translation_key="calories_consumed", native_unit_of_measurement=UnitOfEnergy.KILO_CALORIE, + device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, value_fn=lambda data: ( data.nutrition.energy.kcal_sum @@ -335,6 +357,7 @@ class GoogleHealthSensor[_CoordinatorT: GoogleHealthDataUpdateCoordinator[Any]]( self.entity_description = description self._attr_unique_id = f"{entry_id}_{description.key}" self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, entry_id)}, manufacturer="Google", ) @@ -345,6 +368,15 @@ class GoogleHealthSensor[_CoordinatorT: GoogleHealthDataUpdateCoordinator[Any]]( """Return the state of the sensor.""" return cast(StateType, self.entity_description.value_fn(self.coordinator.data)) + @property + @override + def suggested_unit_of_measurement(self) -> str | None: + """Return the suggested unit of measurement.""" + if (suggested_unit_fn := self.entity_description.suggested_unit_fn) is not None: + return suggested_unit_fn(self.hass.config.units) + + return super().suggested_unit_of_measurement + class GoogleHealthDeviceSensor( CoordinatorEntity[GoogleHealthDeviceCoordinator], SensorEntity @@ -352,6 +384,7 @@ class GoogleHealthDeviceSensor( """Device-specific Google Health sensor entity.""" _attr_has_entity_name = True + _attr_entity_category = EntityCategory.DIAGNOSTIC entity_description: GoogleHealthDeviceSensorEntityDescription def __init__( @@ -374,7 +407,11 @@ class GoogleHealthDeviceSensor( or (device.device_type.title() if device.device_type else "Device"), model=device.device_type.title() if device.device_type else None, sw_version=device.device_version, - via_device=(DOMAIN, entry_id), + via_device_id=async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, entry_id), + config_entry_id=entry_id, + ), ) if device.mac_address: diff --git a/homeassistant/components/green_planet_energy/__init__.py b/homeassistant/components/green_planet_energy/__init__.py index 182407c23cdd..d3763862148d 100644 --- a/homeassistant/components/green_planet_energy/__init__.py +++ b/homeassistant/components/green_planet_energy/__init__.py @@ -1,24 +1,14 @@ """Green Planet Energy integration for Home Assistant.""" -from datetime import timedelta - -import voluptuous as vol - -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import ( - HomeAssistant, - ServiceCall, - ServiceResponse, - SupportsResponse, -) -from homeassistant.exceptions import ServiceValidationError +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from homeassistant.util import dt as dt_util from .const import DOMAIN from .coordinator import GreenPlanetEnergyUpdateCoordinator +from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -26,105 +16,10 @@ type GreenPlanetEnergyConfigEntry = ConfigEntry[GreenPlanetEnergyUpdateCoordinat PLATFORMS: list[Platform] = [Platform.SENSOR] -# Service constants -SERVICE_GET_CHEAPEST_DURATION = "get_cheapest_duration" -ATTR_DURATION = "duration" -ATTR_TIME_RANGE = "time_range" - -# Time range options -TIME_RANGE_DAY = "day" -TIME_RANGE_NIGHT = "night" -TIME_RANGE_FULL_DAY = "full_day" - -SERVICE_GET_CHEAPEST_DURATION_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DURATION): vol.All( - vol.Coerce(float), vol.Range(min=0.5, max=24) - ), - vol.Optional(ATTR_TIME_RANGE, default=TIME_RANGE_FULL_DAY): vol.In( - [TIME_RANGE_DAY, TIME_RANGE_NIGHT, TIME_RANGE_FULL_DAY] - ), - } -) - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Green Planet Energy component.""" - - async def get_cheapest_duration(call: ServiceCall) -> ServiceResponse: - """Handle the get_cheapest_duration service call.""" - # This integration has single_config_entry, so get the first entry - entries = hass.config_entries.async_entries(DOMAIN) - - if not entries: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_config_entry", - ) - - entry = entries[0] - - if entry.state is not ConfigEntryState.LOADED: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="config_entry_not_loaded", - ) - - coordinator: GreenPlanetEnergyUpdateCoordinator = entry.runtime_data - - duration = call.data[ATTR_DURATION] - time_range = call.data[ATTR_TIME_RANGE] - data = coordinator.data - api = coordinator.api - now = dt_util.now() - current_hour = now.hour - - result: tuple[float | None, int | None] - - if time_range == TIME_RANGE_DAY: - result = api.get_cheapest_duration_day(data, duration, current_hour) - elif time_range == TIME_RANGE_NIGHT: - result = api.get_cheapest_duration_night(data, duration, current_hour) - else: # TIME_RANGE_FULL_DAY - result = api.get_cheapest_duration(data, duration, current_hour) - - avg_price, start_hour_result = result - - if avg_price is None or start_hour_result is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_data_available", - ) - - start_time = dt_util.start_of_local_day(now).replace( - hour=start_hour_result, minute=0, second=0, microsecond=0 - ) - - # If the calculated start time is in the past, shift to tomorrow - if start_time < now: - start_time = start_time + timedelta(days=1) - - end_time = start_time + timedelta(hours=duration) - - hours_until_start = (start_time - now).total_seconds() / 3600 - - return { - "duration": duration, - "average_price": round(avg_price / 100, 4), - "start_time": start_time.isoformat(), - "end_time": end_time.isoformat(), - "hours_until_start": round(hours_until_start, 1), - "time_range": time_range, - } - - hass.services.async_register( - DOMAIN, - SERVICE_GET_CHEAPEST_DURATION, - get_cheapest_duration, - schema=SERVICE_GET_CHEAPEST_DURATION_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) - + async_setup_services(hass) return True diff --git a/homeassistant/components/green_planet_energy/icons.json b/homeassistant/components/green_planet_energy/icons.json index a3b326305416..4778d583320a 100644 --- a/homeassistant/components/green_planet_energy/icons.json +++ b/homeassistant/components/green_planet_energy/icons.json @@ -2,6 +2,9 @@ "services": { "get_cheapest_duration": { "service": "mdi:clock-check" + }, + "get_prices": { + "service": "mdi:lightning-bolt-circle" } } } diff --git a/homeassistant/components/green_planet_energy/quality_scale.yaml b/homeassistant/components/green_planet_energy/quality_scale.yaml index bb38b1831bda..09f9f6a76112 100644 --- a/homeassistant/components/green_planet_energy/quality_scale.yaml +++ b/homeassistant/components/green_planet_energy/quality_scale.yaml @@ -1,17 +1,13 @@ rules: # Bronze - action-setup: - status: exempt - comment: The integration registers no actions. + action-setup: done appropriate-polling: done brands: done common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: - status: exempt - comment: The integration registers no actions. + docs-actions: done docs-conditions: status: exempt comment: This integration does not have any conditions. @@ -32,9 +28,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: exempt - comment: The integration registers no actions. + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: status: exempt diff --git a/homeassistant/components/green_planet_energy/services.py b/homeassistant/components/green_planet_energy/services.py new file mode 100644 index 000000000000..25f43f1cbb73 --- /dev/null +++ b/homeassistant/components/green_planet_energy/services.py @@ -0,0 +1,197 @@ +"""Services for Green Planet Energy integration.""" + +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.selector import ConfigEntrySelector +from homeassistant.helpers.service import async_get_config_entry +from homeassistant.util import dt as dt_util +from homeassistant.util.json import JsonValueType + +from .const import DOMAIN + +SERVICE_GET_PRICES = "get_prices" +ATTR_HOURS = "hours" +SERVICE_GET_CHEAPEST_DURATION = "get_cheapest_duration" + +ATTR_DURATION = "duration" +ATTR_TIME_RANGE = "time_range" + +TIME_RANGE_DAY = "day" +TIME_RANGE_NIGHT = "night" +TIME_RANGE_FULL_DAY = "full_day" + + +def _validate_hours(v: float) -> float: + """Validate that hours is a multiple of 0.25 (15 minutes).""" + if abs(v * 4 - round(v * 4)) >= 1e-9: + raise vol.Invalid("hours must be a multiple of 0.25 (15 minutes)") + return v + + +SERVICE_GET_PRICES_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): ConfigEntrySelector( + {"integration": DOMAIN} + ), + vol.Required(ATTR_HOURS): vol.All( + vol.Coerce(float), + vol.Range(min=0.25, max=24), + _validate_hours, + ), + } +) + +SERVICE_GET_CHEAPEST_DURATION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DURATION): vol.All( + vol.Coerce(float), vol.Range(min=0.5, max=24) + ), + vol.Optional(ATTR_TIME_RANGE, default=TIME_RANGE_FULL_DAY): vol.In( + [TIME_RANGE_DAY, TIME_RANGE_NIGHT, TIME_RANGE_FULL_DAY] + ), + } +) + + +async def get_cheapest_duration(call: ServiceCall) -> ServiceResponse: + """Find the cheapest consecutive time window for a given duration.""" + entries = call.hass.config_entries.async_entries(DOMAIN) + + if not entries: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_config_entry", + ) + + entry = entries[0] + + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="config_entry_not_loaded", + ) + + coordinator = entry.runtime_data + duration = call.data[ATTR_DURATION] + time_range = call.data[ATTR_TIME_RANGE] + data = coordinator.data + api = coordinator.api + now = dt_util.now() + current_hour = now.hour + + result: tuple[float | None, int | None] + + if time_range == TIME_RANGE_DAY: + result = api.get_cheapest_duration_day(data, duration, current_hour) + elif time_range == TIME_RANGE_NIGHT: + result = api.get_cheapest_duration_night(data, duration, current_hour) + else: + result = api.get_cheapest_duration(data, duration, current_hour) + + avg_price, start_hour_result = result + + if avg_price is None or start_hour_result is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_data_available", + ) + + start_time = dt_util.start_of_local_day(now).replace( + hour=start_hour_result, minute=0, second=0, microsecond=0 + ) + if start_time < now: + start_time = start_time + timedelta(days=1) + + end_time = start_time + timedelta(hours=duration) + hours_until_start = (start_time - now).total_seconds() / 3600 + + return { + "duration": duration, + "average_price": round(avg_price / 100, 4), + "start_time": start_time.isoformat(), + "end_time": end_time.isoformat(), + "hours_until_start": round(hours_until_start, 1), + "time_range": time_range, + } + + +async def get_prices(call: ServiceCall) -> ServiceResponse: + """Return raw 15-minute-slot electricity prices for the next N hours. + + Prices are in EUR/kWh. Slots for which the API has no data yet (e.g. + tomorrow's prices have not been published yet) are silently omitted + from the result. + """ + entry = async_get_config_entry(call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]) + data = entry.runtime_data.data + hours: float = call.data[ATTR_HOURS] + + now = dt_util.now() + slot_timestamp = int(dt_util.as_timestamp(now) // 900 * 900) + slot_start = dt_util.as_local(dt_util.utc_from_timestamp(slot_timestamp)) + end_time = slot_start + timedelta(hours=hours) + today = slot_start.date() + tomorrow = today + timedelta(days=1) + + slots: list[JsonValueType] = [] + current = slot_start + while current < end_time: + slot_end = current + timedelta(minutes=15) + h = current.hour + m = current.minute + current_date = current.date() + + if current_date == today: + key = f"gpe_price_{h:02d}_{m:02d}" + elif current_date == tomorrow: + key = f"gpe_price_{h:02d}_{m:02d}_tomorrow" + else: + current = slot_end + continue + + if key in data: + slots.append( + { + "start": current.isoformat(), + "end": slot_end.isoformat(), + "price": round(data[key] / 100, 6), + } + ) + + current = slot_end + + return { + "prices": slots, + "hours_requested": hours, + } + + +def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for Green Planet Energy.""" + + hass.services.async_register( + DOMAIN, + SERVICE_GET_CHEAPEST_DURATION, + get_cheapest_duration, + schema=SERVICE_GET_CHEAPEST_DURATION_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_PRICES, + get_prices, + schema=SERVICE_GET_PRICES_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/green_planet_energy/services.yaml b/homeassistant/components/green_planet_energy/services.yaml index 652a8414a699..7f32b785a810 100644 --- a/homeassistant/components/green_planet_energy/services.yaml +++ b/homeassistant/components/green_planet_energy/services.yaml @@ -23,3 +23,19 @@ get_cheapest_duration: value: "day" - label: Night (18:00-06:00) value: "night" + +get_prices: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: green_planet_energy + hours: + required: true + selector: + number: + min: 0.25 + max: 24 + step: 0.25 + unit_of_measurement: hours diff --git a/homeassistant/components/green_planet_energy/strings.json b/homeassistant/components/green_planet_energy/strings.json index ab7fb2cedca4..853ab3280e31 100644 --- a/homeassistant/components/green_planet_energy/strings.json +++ b/homeassistant/components/green_planet_energy/strings.json @@ -70,6 +70,20 @@ } }, "name": "Get cheapest time window" + }, + "get_prices": { + "description": "Returns raw 15-minute electricity price slots for the next N hours. Slots beyond the API horizon are omitted.", + "fields": { + "config_entry_id": { + "description": "The Green Planet Energy integration instance to use.", + "name": "Config entry" + }, + "hours": { + "description": "How many hours of price data to return, starting from the current 15-minute slot. Minimum 0.25, maximum 24.", + "name": "Hours" + } + }, + "name": "Get energy prices" } } } diff --git a/homeassistant/components/homekit_controller/binary_sensor.py b/homeassistant/components/homekit_controller/binary_sensor.py index 4a19a271a5a1..91d48e875a70 100644 --- a/homeassistant/components/homekit_controller/binary_sensor.py +++ b/homeassistant/components/homekit_controller/binary_sensor.py @@ -1,22 +1,33 @@ -"""Support for Homekit motion sensors.""" +"""Support for HomeKit binary sensors.""" +from dataclasses import dataclass from typing import override -from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes from aiohomekit.model.services import Service, ServicesTypes from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import ConfigType from . import KNOWN_DEVICES from .connection import HKDevice -from .entity import HomeKitEntity +from .entity import CharacteristicEntity, HomeKitEntity +from .utils import folded_name + + +@dataclass(frozen=True) +class HomeKitBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes a HomeKit binary sensor.""" + + on_value: int | bool = 1 class HomeKitMotionSensor(HomeKitEntity, BinarySensorEntity): @@ -167,13 +178,64 @@ REJECT_CHAR_BY_TYPE = { ServicesTypes.BATTERY_SERVICE: CharacteristicsTypes.BATTERY_LEVEL, } +CHARACTERISTIC_BINARY_SENSORS: dict[str, HomeKitBinarySensorEntityDescription] = { + CharacteristicsTypes.STATUS_LO_BATT: HomeKitBinarySensorEntityDescription( + key=CharacteristicsTypes.STATUS_LO_BATT, + name="Low Battery", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + ), + CharacteristicsTypes.STATUS_FAULT: HomeKitBinarySensorEntityDescription( + key=CharacteristicsTypes.STATUS_FAULT, + name="Problem", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), +} + + +class CharacteristicBinarySensor(CharacteristicEntity, BinarySensorEntity): + """Representation of a HomeKit binary sensor backed by a single characteristic.""" + + entity_description: HomeKitBinarySensorEntityDescription + + def __init__( + self, + conn: HKDevice, + info: ConfigType, + char: Characteristic, + description: HomeKitBinarySensorEntityDescription, + ) -> None: + """Initialise a HomeKit characteristic binary sensor.""" + self.entity_description = description + super().__init__(conn, info, char) + + @property + @override + def name(self) -> str: + """Return the name of the sensor.""" + if name := self.accessory.name: + return f"{name} {self.entity_description.name}" + return f"{self.entity_description.name}" + + @override + def get_characteristic_types(self) -> list[str]: + """Define the homekit characteristics the entity is tracking.""" + return [self._char.type] + + @property + @override + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self._char.value == self.entity_description.on_value + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up Homekit lighting.""" + """Set up HomeKit binary sensors.""" hkid: str = config_entry.data["AccessoryPairingID"] conn: HKDevice = hass.data[KNOWN_DEVICES][hkid] @@ -198,3 +260,60 @@ async def async_setup_entry( return True conn.add_listener(async_add_service) + + @callback + def async_add_characteristic(char: Characteristic) -> bool: + if char.service.type == ServicesTypes.BATTERY_SERVICE: + return False + if not (description := CHARACTERISTIC_BINARY_SENSORS.get(char.type)): + return False + if char.type == CharacteristicsTypes.STATUS_LO_BATT and ( + _should_skip_low_battery_characteristic(char) + ): + return False + + info = {"aid": char.service.accessory.aid, "iid": char.service.iid} + entity = CharacteristicBinarySensor(conn, info, char, description) + conn.async_migrate_unique_id( + entity.old_unique_id, entity.unique_id, Platform.BINARY_SENSOR + ) + async_add_entities([entity]) + return True + + conn.add_char_factory(async_add_characteristic) + + +def _should_skip_low_battery_characteristic(char: Characteristic) -> bool: + """Check if the low battery characteristic should not create an entity.""" + return char.service.accessory.services.first( + service_type=ServicesTypes.BATTERY_SERVICE + ) is not None or _has_earlier_low_battery_characteristic(char) + + +def _has_earlier_low_battery_characteristic(char: Characteristic) -> bool: + """Check if the accessory already exposed the same low battery source. + + Unscoped low battery characteristics are treated as accessory-level duplicates. + """ + source_key = _low_battery_source_key(char.service) + return any( + service.iid < char.service.iid + and service.has(char.type) + and _low_battery_source_key(service) == source_key + for service in char.service.accessory.services + ) + + +def _low_battery_source_key(service: Service) -> str | None: + """Return the low battery source key for the service.""" + if ( + service_label_index := service.value(CharacteristicsTypes.SERVICE_LABEL_INDEX) + ) is not None: + return f"label:{service.type}:{service_label_index}" + + service_name = service.value(CharacteristicsTypes.NAME) + if service_name is not None and folded_name(str(service_name)) != folded_name( + service.accessory.name + ): + return f"name:{folded_name(str(service_name))}" + return None diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index fdd34455486d..83569a98f022 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -81,6 +81,9 @@ CHARACTERISTIC_PLATFORMS = { CharacteristicsTypes.VENDOR_EVE_MOTION_DURATION: "number", CharacteristicsTypes.VENDOR_EVE_MOTION_SENSITIVITY: "number", CharacteristicsTypes.VENDOR_EVE_THERMO_VALVE_POSITION: "sensor", + CharacteristicsTypes.SET_DURATION: "number", + CharacteristicsTypes.STATUS_FAULT: "binary_sensor", + CharacteristicsTypes.STATUS_LO_BATT: "binary_sensor", CharacteristicsTypes.VENDOR_HAA_SETUP: "button", CharacteristicsTypes.VENDOR_HAA_UPDATE: "button", CharacteristicsTypes.VENDOR_KOOGEEK_REALTIME_ENERGY: "sensor", diff --git a/homeassistant/components/homekit_controller/number.py b/homeassistant/components/homekit_controller/number.py index e42da9b7393c..0b6c1666c276 100644 --- a/homeassistant/components/homekit_controller/number.py +++ b/homeassistant/components/homekit_controller/number.py @@ -12,11 +12,12 @@ from homeassistant.components.number import ( DEFAULT_MAX_VALUE, DEFAULT_MIN_VALUE, DEFAULT_STEP, + NumberDeviceClass, NumberEntity, NumberEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, Platform +from homeassistant.const import EntityCategory, Platform, UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType @@ -64,6 +65,14 @@ NUMBER_ENTITIES: dict[str, NumberEntityDescription] = { translation_key="sensitivity", entity_category=EntityCategory.CONFIG, ), + CharacteristicsTypes.SET_DURATION: NumberEntityDescription( + key=CharacteristicsTypes.SET_DURATION, + name="Duration", + device_class=NumberDeviceClass.DURATION, + translation_key="duration", + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTime.SECONDS, + ), } diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index f9a56ea3db9d..962bc818bf2d 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -13,6 +13,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==10.1.0"], + "requirements": ["python-homewizard-energy==10.2.0"], "zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."] } diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index cb3945ec6126..9e632da96350 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -18,7 +18,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( ATTR_VIA_DEVICE, PERCENTAGE, - SIGNAL_STRENGTH_DECIBELS, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, UnitOfApparentPower, UnitOfElectricCurrent, @@ -133,7 +133,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="wifi_rssi", translation_key="wifi_rssi", - native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, diff --git a/homeassistant/components/hue/__init__.py b/homeassistant/components/hue/__init__.py index 98cca2e0b7cd..3e2cfeaaef7e 100644 --- a/homeassistant/components/hue/__init__.py +++ b/homeassistant/components/hue/__init__.py @@ -2,13 +2,12 @@ from aiohue.util import normalize_bridge_id -from homeassistant.components import persistent_notification from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType -from .bridge import HueBridge, HueConfigEntry +from .bridge import HueBridge, HueConfigEntry, _async_register_bridge_device from .const import DOMAIN from .migration import check_migration from .services import async_setup_services @@ -70,47 +69,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: HueConfigEntry) -> bool: hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) return False - # add bridge device to device registry - device_registry = dr.async_get(hass) - if bridge.api_version == 1: - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, api.config.mac_address)}, - identifiers={(DOMAIN, api.config.bridge_id)}, - manufacturer="Signify", - name=api.config.name, - model_id=api.config.model_id, - sw_version=api.config.software_version, - ) - # create persistent notification if we found a bridge version - # with security vulnerability - if ( - api.config.model_id == "BSB002" - and api.config.software_version < "1935144040" - ): - persistent_notification.async_create( - hass, - ( - "Your Hue hub has a known security vulnerability ([CVE-2020-6007] " - "(https://cve.circl.lu/cve/CVE-2020-6007)). " - "Go to the Hue app and check for software updates." - ), - "Signify Hue", - "hue_hub_firmware", - ) - else: - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, api.config.mac_address)}, - identifiers={ - (DOMAIN, api.config.bridge_id), - (DOMAIN, api.config.bridge_device.id), - }, - manufacturer=api.config.bridge_device.product_data.manufacturer_name, - name=api.config.name, - model_id=api.config.model_id, - sw_version=api.config.software_version, + # v1 bridges already register their device before platform forwarding, so + # light/sensor entities can resolve it as their via_device parent; only + # register it here if that has not already happened. v2 bridges are always + # (re)registered here to merge the network MAC connection into the bridge + # device created by async_setup_devices with only its Zigbee MAC. + if bridge.api_version != 1 or ( + dr.async_get(hass).async_get_device_by_identifier( + (DOMAIN, api.config.bridge_id), entry.entry_id ) + is None + ): + _async_register_bridge_device(hass, entry, api, bridge.api_version) return True diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index 2a67d92161c2..c1bf684236a7 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -11,10 +11,11 @@ from aiohue import HueBridgeV1, HueBridgeV2, LinkButtonNotPressed, Unauthorized from aiohue.errors import AiohueException, BridgeBusy from homeassistant import core +from homeassistant.components import persistent_notification from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_API_VERSION, CONF_HOST, Platform from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -from homeassistant.helpers import aiohttp_client +from homeassistant.helpers import aiohttp_client, device_registry as dr from .const import DOMAIN from .v1.sensor_base import SensorManager @@ -105,6 +106,13 @@ class HueBridge: if self.api_version == 1: if self.api.sensors is not None: self.sensor_manager = SensorManager(self) + # Register the bridge device before forwarding the platforms so the + # light and sensor entities can resolve it as their via_device parent + # while they are being added. + if self.hass.config_entries.async_get_entry(self.config_entry.entry_id): + _async_register_bridge_device( + self.hass, self.config_entry, self.api, self.api_version + ) await self.hass.config_entries.async_forward_entry_setups( self.config_entry, PLATFORMS_v1 ) @@ -179,6 +187,56 @@ class HueBridge: create_config_flow(self.hass, self.host) +@core.callback +def _async_register_bridge_device( + hass: core.HomeAssistant, + config_entry: HueConfigEntry, + api: HueBridgeV1 | HueBridgeV2, + api_version: int, +) -> None: + """Add the bridge device to the device registry.""" + device_registry = dr.async_get(hass) + if api_version == 1: + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, api.config.mac_address)}, + identifiers={(DOMAIN, api.config.bridge_id)}, + manufacturer="Signify", + name=api.config.name, + model_id=api.config.model_id, + sw_version=api.config.software_version, + ) + # create persistent notification if we found a bridge version + # with security vulnerability + if ( + api.config.model_id == "BSB002" + and api.config.software_version < "1935144040" + ): + persistent_notification.async_create( + hass, + ( + "Your Hue hub has a known security vulnerability ([CVE-2020-6007] " + "(https://cve.circl.lu/cve/CVE-2020-6007)). " + "Go to the Hue app and check for software updates." + ), + "Signify Hue", + "hue_hub_firmware", + ) + else: + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, api.config.mac_address)}, + identifiers={ + (DOMAIN, api.config.bridge_id), + (DOMAIN, api.config.bridge_device.id), + }, + manufacturer=api.config.bridge_device.product_data.manufacturer_name, + name=api.config.name, + model_id=api.config.model_id, + sw_version=api.config.software_version, + ) + + async def _update_listener(hass: core.HomeAssistant, entry: HueConfigEntry) -> None: """Handle ConfigEntry options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index b935d54cbaa1..b47c81046013 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -10,6 +10,6 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["aiohue"], - "requirements": ["aiohue==4.8.2"], + "requirements": ["aiohue==4.9.0"], "zeroconf": ["_hue._tcp.local."] } diff --git a/homeassistant/components/hue/switch.py b/homeassistant/components/hue/switch.py index c6cd386f59ef..cd27d66cf0dc 100644 --- a/homeassistant/components/hue/switch.py +++ b/homeassistant/components/hue/switch.py @@ -1,5 +1,6 @@ """Support for switch platform for Hue resources (V2 only).""" +from collections.abc import Callable from typing import Any, override from aiohue.v2 import HueBridgeV2 @@ -11,17 +12,20 @@ from aiohue.v2.controllers.sensors import ( Motion, MotionController, ) +from aiohue.v2.models.behavior_script import BehaviorScriptCategory from homeassistant.components.switch import ( SwitchDeviceClass, SwitchEntity, SwitchEntityDescription, ) -from homeassistant.const import EntityCategory +from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .bridge import HueConfigEntry +from .const import DOMAIN from .v2.entity import HueBaseEntity @@ -48,12 +52,15 @@ async def async_setup_entry( | HueLightSensorEnabledEntity | HueMotionSensorEnabledEntity ], + resource_filter: Callable[[Any], bool] | None = None, ): @callback def async_add_entity( event_type: EventType, resource: BehaviorInstance | LightLevel | Motion ) -> None: """Add entity from Hue resource.""" + if resource_filter is not None and not resource_filter(resource): + return async_add_entities([switch_class(bridge, controller, resource)]) # add all current items in controller @@ -67,10 +74,38 @@ async def async_setup_entry( ) ) + @callback + def is_user_automation(resource: BehaviorInstance) -> bool: + """Return if the behavior instance is an automation from the Hue app. + + Anything else is device configuration, which the bridge keeps running + even after it accepts switching it off. Categories we do not recognise + are skipped too, better no switch than one that does nothing. + """ + script = api.config.behavior_script.get(resource.script_id) + return ( + script is not None + and script.metadata.category is BehaviorScriptCategory.AUTOMATION + ) + + # clean up entities previously created for internal behavior instances + entity_registry = er.async_get(hass) + for resource in api.config.behavior_instance: + if is_user_automation(resource): + continue + if entity_id := entity_registry.async_get_entity_id( + Platform.SWITCH, DOMAIN, resource.id + ): + entity_registry.async_remove(entity_id) + # setup for each switch-type hue resource register_items(api.sensors.motion, HueMotionSensorEnabledEntity) register_items(api.sensors.light_level, HueLightSensorEnabledEntity) - register_items(api.config.behavior_instance, HueBehaviorInstanceEnabledEntity) + register_items( + api.config.behavior_instance, + HueBehaviorInstanceEnabledEntity, + is_user_automation, + ) class HueResourceEnabledEntity(HueBaseEntity, SwitchEntity): diff --git a/homeassistant/components/hue/v1/light.py b/homeassistant/components/hue/v1/light.py index d7a4b98579ec..82de586c6e8c 100644 --- a/homeassistant/components/hue/v1/light.py +++ b/homeassistant/components/hue/v1/light.py @@ -29,6 +29,7 @@ from homeassistant.components.light import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -545,7 +546,11 @@ class HueLight(CoordinatorEntity, LightEntity): name=self.name, sw_version=self.light.swversion, suggested_area=suggested_area, - via_device=(DOMAIN, self.bridge.api.config.bridgeid), + via_device_id=dr.async_get_device_id_by_identifier( + self.bridge.hass, + (DOMAIN, self.bridge.api.config.bridgeid), + config_entry_id=self.bridge.config_entry.entry_id, + ), ) @override diff --git a/homeassistant/components/hue/v1/sensor_device.py b/homeassistant/components/hue/v1/sensor_device.py index e741c3bbfa32..90d8c6c8664d 100644 --- a/homeassistant/components/hue/v1/sensor_device.py +++ b/homeassistant/components/hue/v1/sensor_device.py @@ -2,7 +2,7 @@ from typing import override -from homeassistant.helpers import entity +from homeassistant.helpers import device_registry as dr, entity from homeassistant.helpers.device_registry import DeviceInfo from ..const import CONF_ALLOW_UNREACHABLE, DEFAULT_ALLOW_UNREACHABLE, DOMAIN @@ -61,5 +61,9 @@ class GenericHueDevice(entity.Entity): # pylint: disable=home-assistant-enforce model=(self.primary_sensor.productname or self.primary_sensor.modelid), name=self.primary_sensor.name, sw_version=self.primary_sensor.swversion, - via_device=(DOMAIN, self.bridge.api.config.bridgeid), + via_device_id=dr.async_get_device_id_by_identifier( + self.bridge.hass, + (DOMAIN, self.bridge.api.config.bridgeid), + config_entry_id=self.bridge.config_entry.entry_id, + ), ) diff --git a/homeassistant/components/hue/v2/binary_sensor.py b/homeassistant/components/hue/v2/binary_sensor.py index 739ae81b09bf..d5a39fd6d4c2 100644 --- a/homeassistant/components/hue/v2/binary_sensor.py +++ b/homeassistant/components/hue/v2/binary_sensor.py @@ -19,9 +19,11 @@ from aiohue.v2.controllers.sensors import ( ) from aiohue.v2.models.camera_motion import CameraMotion from aiohue.v2.models.contact import Contact, ContactState +from aiohue.v2.models.convenience_area_motion import ConvenienceAreaMotion from aiohue.v2.models.entertainment_configuration import EntertainmentStatus from aiohue.v2.models.grouped_motion import GroupedMotion from aiohue.v2.models.motion import Motion +from aiohue.v2.models.motion_area_configuration import MotionAreaHealth from aiohue.v2.models.resource import ResourceTypes from aiohue.v2.models.security_area_motion import SecurityAreaMotion from aiohue.v2.models.tamper import Tamper, TamperState @@ -181,11 +183,10 @@ class HueGroupedMotionSensor(HueMotionSensor): class HueMotionAwareSensor(HueMotionSensor): """Representation of a Motion sensor based on Hue Motion Aware. - Note that we only create sensors for the SecurityAreaMotion resource - and not for the ConvenienceAreaMotion resource, because the latter - does not have a state when it's not directly controlling lights. - The SecurityAreaMotion resource is always available with a state, allowing - Home Assistant users to actually use it as a motion sensor in their HA automations. + A MotionAware zone owns both a ConvenienceAreaMotion and a SecurityAreaMotion + service, and which of the two carries the motion state depends on how the zone + is set up in the Hue app. The source is therefore resolved from the zone, and + the state is unknown while neither service reports a valid one. """ controller: SecurityAreaMotionController @@ -201,6 +202,26 @@ class HueMotionAwareSensor(HueMotionSensor): """Return sensor name.""" return self.controller.get_motion_area_configuration(self.resource.id).name + @property + @override + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + zone = self._motion_area_configuration + # switching a zone off leaves the `enabled` flag of its services untouched, + # so the zone is the only reliable signal that it stopped reporting motion + if not zone.enabled or zone.health == MotionAreaHealth.NOT_RUNNING: + return None + # either service can be the one reporting a zone, with the other left holding + # a reading that no longer changes, so take the first that has a valid one. + # The convenience service comes first because a zone that is bound to lights + # reports on that service. + for source in (self._convenience_service, self.resource): + if source is None or not source.enabled or source.motion is None: + continue + if (value := source.motion.value) is not None: + return value + return None + def __init__( self, bridge: HueBridge, @@ -229,6 +250,32 @@ class HueMotionAwareSensor(HueMotionSensor): self._handle_event, self._motion_area_configuration.id ) ) + # zones that are bound to lights report their motion on the convenience service + if (service_id := self._convenience_service_id) is not None: + self.async_on_remove( + self.bridge.api.sensors.convenience_area_motion.subscribe( + self._handle_event, service_id + ) + ) + + @property + def _convenience_service_id(self) -> str | None: + """Return the id of this zone's ConvenienceAreaMotion service, if it has one.""" + return next( + ( + service.rid + for service in self._motion_area_configuration.services + if service.rtype == ResourceTypes.CONVENIENCE_AREA_MOTION + ), + None, + ) + + @property + def _convenience_service(self) -> ConvenienceAreaMotion | None: + """Return this zone's ConvenienceAreaMotion resource, if the bridge has it.""" + if (service_id := self._convenience_service_id) is None: + return None + return self.bridge.api.sensors.convenience_area_motion.get(service_id) # pylint: disable-next=home-assistant-enforce-class-module diff --git a/homeassistant/components/hue/v2/device.py b/homeassistant/components/hue/v2/device.py index 298fd8841860..fb476759a5b8 100644 --- a/homeassistant/components/hue/v2/device.py +++ b/homeassistant/components/hue/v2/device.py @@ -18,7 +18,6 @@ from homeassistant.const import ( ATTR_NAME, ATTR_SUGGESTED_AREA, ATTR_SW_VERSION, - ATTR_VIA_DEVICE, ) from homeassistant.core import callback from homeassistant.helpers import device_registry as dr @@ -49,7 +48,11 @@ async def async_setup_devices(bridge: HueBridge): name=hue_resource.metadata.name, model=hue_resource.type.value.replace("_", " ").title(), manufacturer=api.config.bridge_device.product_data.manufacturer_name, - via_device=(DOMAIN, api.config.bridge_device.id), + via_device_id=dr.async_get_device_id_by_identifier( + hass, + (DOMAIN, api.config.bridge_device.id), + config_entry_id=entry.entry_id, + ), suggested_area=hue_resource.metadata.name if hue_resource.type == ResourceTypes.ROOM else None, @@ -68,7 +71,13 @@ async def async_setup_devices(bridge: HueBridge): if hue_resource.id == api.config.bridge_device.id: params[ATTR_IDENTIFIERS].add((DOMAIN, api.config.bridge_id)) else: - params[ATTR_VIA_DEVICE] = (DOMAIN, api.config.bridge_device.id) + # The bridge device is always registered first (see sort below), so + # its id can be resolved here for the via_device link. + params["via_device_id"] = dr.async_get_device_id_by_identifier( + hass, + (DOMAIN, api.config.bridge_device.id), + config_entry_id=entry.entry_id, + ) zigbee = dev_controller.get_zigbee_connectivity(hue_resource.id) if zigbee and zigbee.mac_address: params[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, zigbee.mac_address)} diff --git a/homeassistant/components/insteon/entity.py b/homeassistant/components/insteon/entity.py index 7e0b880100c9..1e9c0267484c 100644 --- a/homeassistant/components/insteon/entity.py +++ b/homeassistant/components/insteon/entity.py @@ -7,6 +7,7 @@ from typing import Any, override from pyinsteon import devices from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -88,6 +89,8 @@ class InsteonEntity(Entity): @override def device_info(self) -> DeviceInfo: """Return device information.""" + config_entry = self.platform.config_entry + assert config_entry return DeviceInfo( identifiers={(DOMAIN, str(self._insteon_device.address))}, manufacturer="SmartLabs, Inc", @@ -100,7 +103,11 @@ class InsteonEntity(Entity): f"{self._insteon_device.firmware:02x} Engine Version:" f" {self._insteon_device.engine_version}" ), - via_device=(DOMAIN, str(devices.modem.address)), + via_device_id=dr.async_get_device_id_by_identifier( + self.hass, + (DOMAIN, str(devices.modem.address)), + config_entry_id=config_entry.entry_id, + ), configuration_url=f"homeassistant://insteon/device/config/{self._insteon_device.id}", ) diff --git a/homeassistant/components/integration/config_flow.py b/homeassistant/components/integration/config_flow.py index 718fee7893c5..e4361b6d9d39 100644 --- a/homeassistant/components/integration/config_flow.py +++ b/homeassistant/components/integration/config_flow.py @@ -61,6 +61,10 @@ def entity_selector_compatible( if current else None ) + if unit_of_measurement is None: + return selector.EntitySelector( + selector.EntitySelectorConfig(domain=ALLOWED_DOMAINS) + ) entities = [ ent.entity_id diff --git a/homeassistant/components/iskra/entity.py b/homeassistant/components/iskra/entity.py index f1c01d3eaa40..062930ca230b 100644 --- a/homeassistant/components/iskra/entity.py +++ b/homeassistant/components/iskra/entity.py @@ -1,5 +1,6 @@ """Base entity for Iskra devices.""" +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -26,7 +27,11 @@ class IskraEntity(CoordinatorEntity[IskraDataUpdateCoordinator]): name=self.device.model, sw_version=self.device.fw_version, serial_number=self.device.serial, - via_device=(DOMAIN, gateway.serial), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, gateway.serial), + config_entry_id=coordinator.config_entry.entry_id, + ), ) else: self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/izone/__init__.py b/homeassistant/components/izone/__init__.py index 243bbcee449f..831d25bf7829 100644 --- a/homeassistant/components/izone/__init__.py +++ b/homeassistant/components/izone/__init__.py @@ -7,7 +7,7 @@ from homeassistant import config_entries from homeassistant.const import CONF_EXCLUDE, CONF_HOST, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType from .const import DATA_CONFIG, DOMAIN @@ -184,6 +184,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: IZoneConfigEntry) -> boo await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator + + # Register the controller device before forwarding platforms so zone + # entities can resolve their via_device_id parent at construction time. + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, controller.device_uid)}, + manufacturer="IZone", + model=controller.sys_type, + name=f"iZone Controller {controller.device_uid}", + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index 8065f807bf25..cac53a519c00 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -26,7 +26,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform +from homeassistant.helpers import device_registry as dr, entity_platform from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.temperature import display_temp as show_temp @@ -403,7 +403,11 @@ class ZoneDevice(IZoneCoordinatorEntity, ClimateEntity): manufacturer="IZone", model=zone.type.name.title(), name=zone.name.title(), - via_device=(DOMAIN, controller_entity.unique_id), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, controller_entity.unique_id), + config_entry_id=coordinator.config_entry.entry_id, + ), ) @property diff --git a/homeassistant/components/jellyfin/entity.py b/homeassistant/components/jellyfin/entity.py index b84bb5be5778..5ef0ca2c61ca 100644 --- a/homeassistant/components/jellyfin/entity.py +++ b/homeassistant/components/jellyfin/entity.py @@ -2,6 +2,7 @@ from typing import Any, override +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -50,7 +51,11 @@ class JellyfinClientEntity(JellyfinEntity): model=self.client_name, name=self.device_name, sw_version=self.app_version, - via_device=(DOMAIN, coordinator.server_id), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, coordinator.server_id), + config_entry_id=coordinator.config_entry.entry_id, + ), ) self._attr_name = None else: diff --git a/homeassistant/components/kitchen_sink/sensor.py b/homeassistant/components/kitchen_sink/sensor.py index 1ff83f6b8411..c3d285312dec 100644 --- a/homeassistant/components/kitchen_sink/sensor.py +++ b/homeassistant/components/kitchen_sink/sensor.py @@ -8,6 +8,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEGREE, UnitOfPower from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import UNDEFINED, StateType, UndefinedType @@ -31,6 +32,10 @@ async def async_setup_entry( "2_ch_power_strip", ) + via_device_id = dr.async_get_device_id_by_identifier( + hass, (DOMAIN, "2_ch_power_strip"), config_entry_id=config_entry.entry_id + ) + async_add_entities( [ DemoSensor( @@ -42,7 +47,7 @@ async def async_setup_entry( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, unit_of_measurement=UnitOfPower.WATT, - via_device="2_ch_power_strip", + via_device_id=via_device_id, ), DemoSensor( device_unique_id="outlet_2", @@ -53,7 +58,7 @@ async def async_setup_entry( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, unit_of_measurement=UnitOfPower.WATT, - via_device="2_ch_power_strip", + via_device_id=via_device_id, ), DemoSensor( device_unique_id="statistics_issues", @@ -135,7 +140,7 @@ class DemoSensor(SensorEntity): device_class: SensorDeviceClass | None, state_class: SensorStateClass | None, unit_of_measurement: str | None, - via_device: str | None = None, + via_device_id: str | None = None, ) -> None: """Initialize the sensor.""" self._attr_device_class = device_class @@ -150,5 +155,5 @@ class DemoSensor(SensorEntity): identifiers={(DOMAIN, device_unique_id)}, name=device_name, ) - if via_device: - self._attr_device_info["via_device"] = (DOMAIN, via_device) + if via_device_id: + self._attr_device_info["via_device_id"] = via_device_id diff --git a/homeassistant/components/kitchen_sink/switch.py b/homeassistant/components/kitchen_sink/switch.py index b156c4f3f3bf..e555adf07520 100644 --- a/homeassistant/components/kitchen_sink/switch.py +++ b/homeassistant/components/kitchen_sink/switch.py @@ -5,6 +5,7 @@ from typing import Any, override from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -27,6 +28,10 @@ async def async_setup_entry( "2_ch_power_strip", ) + via_device_id = dr.async_get_device_id_by_identifier( + hass, (DOMAIN, "2_ch_power_strip"), config_entry_id=config_entry.entry_id + ) + async_add_entities( [ DemoSwitch( @@ -35,7 +40,7 @@ async def async_setup_entry( entity_name=None, state=False, assumed=False, - via_device="2_ch_power_strip", + via_device_id=via_device_id, ), DemoSwitch( unique_id="outlet_2", @@ -43,7 +48,7 @@ async def async_setup_entry( entity_name=None, state=True, assumed=False, - via_device="2_ch_power_strip", + via_device_id=via_device_id, ), ] ) @@ -65,7 +70,7 @@ class DemoSwitch(SwitchEntity): assumed: bool, translation_key: str | None = None, device_class: SwitchDeviceClass | None = None, - via_device: str | None = None, + via_device_id: str | None = None, ) -> None: """Initialize the Demo switch.""" self._attr_assumed_state = assumed @@ -77,8 +82,8 @@ class DemoSwitch(SwitchEntity): identifiers={(DOMAIN, unique_id)}, name=device_name, ) - if via_device: - self._attr_device_info["via_device"] = (DOMAIN, via_device) + if via_device_id: + self._attr_device_info["via_device_id"] = via_device_id self._attr_name = entity_name @override diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 701933000487..4f4def15dff0 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -11,10 +11,10 @@ "loggers": ["xknx", "xknxproject"], "quality_scale": "platinum", "requirements": [ - "xknx==3.17.0", - "xknxproject==3.9.0", + "xknx==3.18.0", + "xknxproject==3.10.0", "knx-frontend==2026.7.23.145751", - "knx-telegram-store[sqlite,postgres]==0.11.1" + "knx-telegram-store[sqlite,postgres]==0.11.2" ], "single_config_entry": true } diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index 101984eae410..173271ff4c52 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -211,7 +211,9 @@ def register_lcn_address_devices( """ device_registry = dr.async_get(hass) - host_identifiers = (DOMAIN, config_entry.entry_id) + host_device_id = dr.async_get_device_id_by_identifier( + hass, (DOMAIN, config_entry.entry_id), config_entry_id=config_entry.entry_id + ) for device_config in config_entry.data[CONF_DEVICES]: address = device_config[CONF_ADDRESS] @@ -233,7 +235,7 @@ def register_lcn_address_devices( device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers=identifiers, - via_device=host_identifiers, + via_device_id=host_device_id, manufacturer="Issendorff", sw_version=sw_version, name=device_name, diff --git a/homeassistant/components/lifx/config_flow.py b/homeassistant/components/lifx/config_flow.py index aaae23f0d2cc..8c04593b5f72 100644 --- a/homeassistant/components/lifx/config_flow.py +++ b/homeassistant/components/lifx/config_flow.py @@ -117,12 +117,12 @@ class LifXConfigFlow(ConfigFlow, domain=DOMAIN): if not (legacy_entry := async_get_legacy_entry(self.hass)): return False device_registry = dr.async_get(self.hass) - existing_device = device_registry.async_get_device( + existing_devices = device_registry.async_get_devices( identifiers={(DOMAIN, self.unique_id)} ) - return bool( - existing_device is not None - and legacy_entry.entry_id in existing_device.config_entries + return any( + device.config_entry_id == legacy_entry.entry_id + for device in existing_devices ) async def async_step_discovery_confirm( diff --git a/homeassistant/components/livisi/entity.py b/homeassistant/components/livisi/entity.py index 2e6f13ac8978..3ae368418fe0 100644 --- a/homeassistant/components/livisi/entity.py +++ b/homeassistant/components/livisi/entity.py @@ -6,6 +6,7 @@ from typing import Any, override from livisi.const import CAPABILITY_MAP from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -59,7 +60,11 @@ class LivisiEntity(CoordinatorEntity[LivisiDataUpdateCoordinator]): model=device["type"], name=device_name, suggested_area=room_name, - via_device=(DOMAIN, config_entry.entry_id), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, config_entry.entry_id), + config_entry_id=config_entry.entry_id, + ), ) super().__init__(coordinator) diff --git a/homeassistant/components/loqed/config_flow.py b/homeassistant/components/loqed/config_flow.py index c55acea89a25..9f0a2add5c01 100644 --- a/homeassistant/components/loqed/config_flow.py +++ b/homeassistant/components/loqed/config_flow.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components import webhook from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_API_TOKEN, CONF_NAME, CONF_WEBHOOK_ID +from homeassistant.const import CONF_API_TOKEN, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -27,30 +27,43 @@ class LoqedConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 DOMAIN = DOMAIN _host: str | None = None + _locks: list[dict[str, Any]] + _api_token: str | None = None + + def __init__(self) -> None: + """Initialize the config flow.""" + super().__init__() + self._locks = [] async def validate_input( self, hass: HomeAssistant, data: dict[str, Any] ) -> dict[str, Any]: """Validate the user input allows us to connect.""" - # 1. Checking loqed-connection - try: - session = async_get_clientsession(hass) + session = async_get_clientsession(hass) + if self._locks and not self._host: + # Reuse the lock list already fetched during manual setup to + # avoid a duplicate cloud request. + lock_data = {"data": self._locks} + else: cloud_api_client = cloud_loqed.CloudAPIClient( session, data[CONF_API_TOKEN], ) cloud_client = cloud_loqed.LoqedCloudAPI(cloud_api_client) - lock_data = await cloud_client.async_get_locks() - except aiohttp.ClientError as err: - _LOGGER.error("HTTP Connection error to loqed API") - raise CannotConnect from err + + try: + lock_data = await cloud_client.async_get_locks() + except aiohttp.ClientError as err: + _LOGGER.error("HTTP Connection error to loqed API") + raise CannotConnect from err try: + match_key, match_value = ( + ("bridge_ip", self._host) if self._host else ("id", data.get("lock_id")) + ) selected_lock = next( - lock - for lock in lock_data["data"] - if lock["bridge_ip"] == self._host or lock["name"] == data.get("name") + lock for lock in lock_data["data"] if lock[match_key] == match_value ) apiclient = loqed.APIClient(session, f"http://{selected_lock['bridge_ip']}") @@ -73,11 +86,11 @@ class LoqedConfigFlow(ConfigFlow, domain=DOMAIN): "name": selected_lock["name"], "id": selected_lock["id"], } - except StopIteration: - raise InvalidAuth from StopIteration - except aiohttp.ClientError: + except StopIteration as err: + raise InvalidAuth from err + except aiohttp.ClientError as err: _LOGGER.error("HTTP Connection error to loqed lock") - raise CannotConnect from aiohttp.ClientError + raise CannotConnect from err @override async def async_step_zeroconf( @@ -103,21 +116,10 @@ class LoqedConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Show userform to user.""" - user_data_schema = ( - vol.Schema( - { - vol.Required(CONF_API_TOKEN): str, - } - ) - if self._host - else vol.Schema( - { - # Name field is no longer allowed in config flow schemas - # pylint: disable-next=home-assistant-config-flow-name-field - vol.Required(CONF_NAME): str, - vol.Required(CONF_API_TOKEN): str, - } - ) + user_data_schema = vol.Schema( + { + vol.Required(CONF_API_TOKEN): str, + } ) if user_input is None: @@ -131,6 +133,39 @@ class LoqedConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} + # If no Zeroconf discovery and no selected lock, we need to fetch locks and show picker + if not self._host and not user_input.get("lock_id"): + session = async_get_clientsession(self.hass) + cloud_api_client = cloud_loqed.CloudAPIClient( + session, + user_input[CONF_API_TOKEN], + ) + cloud_client = cloud_loqed.LoqedCloudAPI(cloud_api_client) + + try: + lock_data = await cloud_client.async_get_locks() + except aiohttp.ClientError: + errors["base"] = "cannot_connect" + else: + self._locks = lock_data["data"] + self._api_token = user_input[CONF_API_TOKEN] + if not self._locks: + errors["base"] = "no_locks" + elif len(self._locks) == 1: + user_input["lock_id"] = self._locks[0]["id"] + else: + return await self.async_step_pick_lock() + + if errors: + return self.async_show_form( + step_id="user", + data_schema=user_data_schema, + errors=errors, + description_placeholders={ + "config_url": "https://integrations.loqed.com/personal-access-tokens", + }, + ) + try: info = await self.validate_input(self.hass, user_input) except CannotConnect: @@ -147,10 +182,12 @@ class LoqedConfigFlow(ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() return self.async_create_entry( - title="LOQED Touch Smart Lock", - data=( - user_input | {CONF_WEBHOOK_ID: webhook.async_generate_id()} | info - ), + title=info["name"], + data={ + CONF_API_TOKEN: user_input[CONF_API_TOKEN], + CONF_WEBHOOK_ID: webhook.async_generate_id(), + **info, + }, ) return self.async_show_form( @@ -162,6 +199,28 @@ class LoqedConfigFlow(ConfigFlow, domain=DOMAIN): }, ) + async def async_step_pick_lock( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle lock selection when multiple locks are available.""" + if user_input is not None: + if self._api_token is None: + return await self.async_step_user() + return await self.async_step_user( + {**user_input, CONF_API_TOKEN: self._api_token} + ) + + lock_options = {lock["id"]: lock["name"] for lock in self._locks} + + return self.async_show_form( + step_id="pick_lock", + data_schema=vol.Schema( + { + vol.Required("lock_id"): vol.In(lock_options), + } + ), + ) + class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/loqed/strings.json b/homeassistant/components/loqed/strings.json index 7e025716366f..91b9d75715c8 100644 --- a/homeassistant/components/loqed/strings.json +++ b/homeassistant/components/loqed/strings.json @@ -5,14 +5,20 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "no_locks": "No locks found in your LOQED account." }, "flow_title": "LOQED Touch Smartlock setup", "step": { + "pick_lock": { + "data": { + "lock_id": "Select your lock" + }, + "description": "Multiple locks found. Please select the lock you want to configure." + }, "user": { "data": { - "api_token": "[%key:common::config_flow::data::api_token%]", - "name": "Name of your lock in the LOQED app." + "api_token": "[%key:common::config_flow::data::api_token%]" }, "description": "Log in at LOQED's [personal access tokens portal]({config_url}) and: \n* Create an API key by clicking 'Create' \n* Copy the created access token." } diff --git a/homeassistant/components/lunatone/light.py b/homeassistant/components/lunatone/light.py index c915913c7a12..80f7fedd311e 100644 --- a/homeassistant/components/lunatone/light.py +++ b/homeassistant/components/lunatone/light.py @@ -15,6 +15,7 @@ from homeassistant.components.light import ( brightness_supported, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -97,9 +98,13 @@ class LunatoneLight( return DeviceInfo( identifiers={(DOMAIN, self.unique_id)}, name=self._device.name, - via_device=( - DOMAIN, - f"{self._config_entry_unique_id}-line{self._device.data.line}", + via_device_id=dr.async_get_device_id_by_identifier( + self.hass, + ( + DOMAIN, + f"{self._config_entry_unique_id}-line{self._device.data.line}", + ), + config_entry_id=self.coordinator.config_entry.entry_id, ), ) @@ -261,7 +266,11 @@ class LunatoneLineBroadcastLight( self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.unique_id)}, name=f"DALI Line {line}", - via_device=(DOMAIN, config_entry_unique_id), + via_device_id=dr.async_get_device_id_by_identifier( + self.coordinator.hass, + (DOMAIN, config_entry_unique_id), + config_entry_id=self.coordinator.config_entry.entry_id, + ), **extra_info, ) diff --git a/homeassistant/components/lunatone/sensor.py b/homeassistant/components/lunatone/sensor.py index 973486f4dedc..0335a848c1e3 100644 --- a/homeassistant/components/lunatone/sensor.py +++ b/homeassistant/components/lunatone/sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -135,7 +136,11 @@ class LunatoneSensor( f"DALI Line {self.sensor.data.dali_sensor_address.line}" f" - A{self.sensor.data.dali_sensor_address.address}\u00b2" ), - via_device=(DOMAIN, str(self._config_entry_unique_id)), + via_device_id=dr.async_get_device_id_by_identifier( + self.coordinator.hass, + (DOMAIN, str(self._config_entry_unique_id)), + config_entry_id=self.coordinator.config_entry.entry_id, + ), ) self._attr_device_info = device_info diff --git a/homeassistant/components/lyngdorf/config_flow.py b/homeassistant/components/lyngdorf/config_flow.py index 2ebd56213690..f06633f16cdd 100644 --- a/homeassistant/components/lyngdorf/config_flow.py +++ b/homeassistant/components/lyngdorf/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Lyngdorf integration.""" +import logging from typing import Any, override from urllib.parse import urlparse @@ -23,6 +24,8 @@ from homeassistant.helpers.service_info.ssdp import ( from .const import CONF_SERIAL_NUMBER, DEFAULT_DEVICE_NAME, DOMAIN +_LOGGER = logging.getLogger(__name__) + class LyngdorfFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a Lyngdorf config flow.""" @@ -158,6 +161,11 @@ class LyngdorfFlowHandler(ConfigFlow, domain=DOMAIN): device_model_name = discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME) or "" if not (model := lookup_receiver_model(device_model_name)): + _LOGGER.warning( + "SSDP discovered device with unrecognized model name %r at %s", + device_model_name, + self._host, + ) raise AbortFlow("unsupported_model") self._device_model = model.model_name self._device_serial_number = ( diff --git a/homeassistant/components/mailgun/__init__.py b/homeassistant/components/mailgun/__init__.py index 7e1d5e148674..3489c3303be4 100644 --- a/homeassistant/components/mailgun/__init__.py +++ b/homeassistant/components/mailgun/__init__.py @@ -1,5 +1,4 @@ """Support for Mailgun.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import hashlib import hmac @@ -16,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN +from .const import DATA_CONFIG, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -45,7 +44,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if DOMAIN not in config: return True - hass.data[DOMAIN] = config[DOMAIN] + hass.data[DATA_CONFIG] = config[DOMAIN] return True @@ -76,7 +75,7 @@ async def handle_webhook( async def verify_webhook(hass, token=None, timestamp=None, signature=None): """Verify webhook was signed by Mailgun.""" - if DOMAIN not in hass.data: + if DATA_CONFIG not in hass.data: _LOGGER.warning("Cannot validate Mailgun webhook, missing API Key") return True @@ -84,7 +83,7 @@ async def verify_webhook(hass, token=None, timestamp=None, signature=None): return False hmac_digest = hmac.new( - key=bytes(hass.data[DOMAIN][CONF_API_KEY], "utf-8"), + key=bytes(hass.data[DATA_CONFIG][CONF_API_KEY], "utf-8"), msg=bytes(f"{timestamp}{token}", "utf-8"), digestmod=hashlib.sha256, ).hexdigest() diff --git a/homeassistant/components/mailgun/const.py b/homeassistant/components/mailgun/const.py index 4532c1cbc469..123159d7b966 100644 --- a/homeassistant/components/mailgun/const.py +++ b/homeassistant/components/mailgun/const.py @@ -1,3 +1,11 @@ """Const for Mailgun.""" +from typing import Any + +from homeassistant.util.hass_dict import HassKey + DOMAIN = "mailgun" + +# YAML component config (api_key / domain / sandbox) used by notify + webhook verify. +# Domain-level, not per config entry — entries only register webhooks. +DATA_CONFIG: HassKey[dict[str, Any]] = HassKey(DOMAIN) diff --git a/homeassistant/components/mailgun/notify.py b/homeassistant/components/mailgun/notify.py index 753802828f0f..91efbfbbc3ff 100644 --- a/homeassistant/components/mailgun/notify.py +++ b/homeassistant/components/mailgun/notify.py @@ -22,7 +22,8 @@ from homeassistant.const import CONF_API_KEY, CONF_DOMAIN, CONF_RECIPIENT, CONF_ from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import CONF_SANDBOX, DOMAIN +from . import CONF_SANDBOX +from .const import DATA_CONFIG _LOGGER = logging.getLogger(__name__) @@ -42,9 +43,7 @@ def get_service( discovery_info: DiscoveryInfoType | None = None, ) -> MailgunNotificationService | None: """Get the Mailgun notification service.""" - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=home-assistant-use-runtime-data - data = hass.data[DOMAIN] + data = hass.data[DATA_CONFIG] mailgun_service = MailgunNotificationService( data.get(CONF_DOMAIN), data.get(CONF_SANDBOX), diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index f7057fcc7600..f5db114e6ed4 100644 --- a/homeassistant/components/matrix/manifest.json +++ b/homeassistant/components/matrix/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["matrix_client"], "quality_scale": "legacy", - "requirements": ["matrix-nio==0.26.0", "Pillow==12.3.0", "aiofiles==24.1.0"] + "requirements": ["matrix-nio==0.26.0", "Pillow==12.3.0", "aiofiles==25.1.0"] } diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index 95940a57216c..f69e53b8bd99 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -70,7 +70,12 @@ class MatterAdapter: def endpoint_added_callback(event: EventType, data: dict[str, int]) -> None: """Handle endpoint added event.""" node = self.matter_client.get_node(data["node_id"]) - self._setup_endpoint(node.endpoints[data["endpoint_id"]]) + endpoint = node.endpoints[data["endpoint_id"]] + # Ensure the bridge device (endpoint 0) is registered before a + # bridged child endpoint resolves it as its via_device. + if endpoint.is_bridged_device and node.endpoints[0] != endpoint: + self._setup_endpoint(node.endpoints[0]) + self._setup_endpoint(endpoint) def endpoint_removed_callback(event: EventType, data: dict[str, int]) -> None: """Handle endpoint removed event.""" @@ -136,9 +141,12 @@ class MatterAdapter: """Set up an node.""" LOGGER.debug("Setting up entities for node %s", node.node_id) try: - for endpoint in node.endpoints.values(): + # Process endpoints in order so the bridge device (endpoint 0) is + # registered before any bridged child endpoint resolves it as its + # via_device. + for endpoint_id in sorted(node.endpoints): # Node endpoints are translated into HA devices - self._setup_endpoint(endpoint) + self._setup_endpoint(node.endpoints[endpoint_id]) except Exception as err: # noqa: BLE001 # We don't want to crash the whole setup when a single node fails to setup # for whatever reason, so we catch all exceptions here. @@ -172,14 +180,20 @@ class MatterAdapter: or (device_type.__name__ if device_type else None) ) + device_registry = dr.async_get(self.hass) + # handle bridged devices - bridge_device_id = None + via_device_id: str | None = None if endpoint.is_bridged_device and endpoint.node.endpoints[0] != endpoint: bridge_device_id = get_device_id( server_info, endpoint.node.endpoints[0], ) - bridge_device_id = f"{ID_TYPE_DEVICE_ID}_{bridge_device_id}" + via_device_id = dr.async_get_device_id_by_identifier( + self.hass, + (DOMAIN, f"{ID_TYPE_DEVICE_ID}_{bridge_device_id}"), + config_entry_id=self.config_entry.entry_id, + ) node_device_id = get_device_id( server_info, @@ -214,7 +228,7 @@ class MatterAdapter: else: model_id = str(product_id) if (product_id := basic_info.productID) else None - dr.async_get(self.hass).async_get_or_create( + device_registry.async_get_or_create( name=name, config_entry_id=self.config_entry.entry_id, identifiers=identifiers, @@ -224,7 +238,7 @@ class MatterAdapter: model=model_name, model_id=model_id, serial_number=serial_number, - via_device=(DOMAIN, bridge_device_id) if bridge_device_id else None, + via_device_id=via_device_id, ) def _setup_endpoint(self, endpoint: MatterEndpoint) -> None: diff --git a/homeassistant/components/media_player/intent.py b/homeassistant/components/media_player/intent.py index 8e9308a33572..f5b2573183ae 100644 --- a/homeassistant/components/media_player/intent.py +++ b/homeassistant/components/media_player/intent.py @@ -376,8 +376,7 @@ class MediaSearchAndPlayHandler(intent.IntentHandler): ) or not (results := entity_response.result) ): - # No results found - return intent_obj.create_response() + raise intent.IntentHandleError(f"No results found for {search_query}") # 2. Play Media (first result) first_result = results[0] diff --git a/homeassistant/components/melcloud/coordinator.py b/homeassistant/components/melcloud/coordinator.py index e8f39bcdd90e..2f30b7eeb3ed 100644 --- a/homeassistant/components/melcloud/coordinator.py +++ b/homeassistant/components/melcloud/coordinator.py @@ -11,6 +11,7 @@ from pymelcloud.atw_device import Zone from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -38,6 +39,8 @@ type MelCloudConfigEntry = ConfigEntry[dict[str, list[MelCloudDeviceUpdateCoordi class MelCloudDeviceUpdateCoordinator(DataUpdateCoordinator[None]): """Per-device coordinator for MELCloud data updates.""" + config_entry: MelCloudConfigEntry + def __init__( self, hass: HomeAssistant, @@ -110,7 +113,11 @@ class MelCloudDeviceUpdateCoordinator(DataUpdateCoordinator[None]): manufacturer="Mitsubishi Electric", model="ATW zone device", name=f"{self.device.name} {zone.name}", - via_device=(DOMAIN, f"{dev.mac}-{dev.serial}"), + via_device_id=dr.async_get_device_id_by_identifier( + self.hass, + (DOMAIN, f"{dev.mac}-{dev.serial}"), + config_entry_id=self.config_entry.entry_id, + ), ) @override diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index f8305094b810..05a7fb988245 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -4,14 +4,8 @@ import logging from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr -from .const import ( - CONF_TRACK_HOME, - DEFAULT_HOME_LATITUDE, - DEFAULT_HOME_LONGITUDE, - DOMAIN, -) +from .const import CONF_TRACK_HOME, DEFAULT_HOME_LATITUDE, DEFAULT_HOME_LONGITUDE from .coordinator import MetDataUpdateCoordinator, MetWeatherConfigEntry PLATFORMS = [Platform.WEATHER] @@ -49,8 +43,6 @@ async def async_setup_entry( await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - await cleanup_old_device(hass) - return True @@ -59,12 +51,3 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) - - -async def cleanup_old_device(hass: HomeAssistant) -> None: - """Cleanup device without proper device identifier.""" - device_reg = dr.async_get(hass) - device = device_reg.async_get_device(identifiers={(DOMAIN,)}) # type: ignore[arg-type] - if device: - _LOGGER.debug("Removing improper device %s", device.name) - device_reg.async_remove_device(device.id) diff --git a/homeassistant/components/midea/climate.py b/homeassistant/components/midea/climate.py index 0da5a824fe33..bc0c00cfb975 100644 --- a/homeassistant/components/midea/climate.py +++ b/homeassistant/components/midea/climate.py @@ -166,15 +166,6 @@ class MideaClimate(MideaEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _zone: int | None = None - def __init__( - self, - device: MideaClimateDevice, - description: MideaClimateEntityDescription, - ) -> None: - """Midea Climate entity init.""" - super().__init__(device, description.key) - self.entity_description = description - def _float_attribute(self, attr: str) -> float | None: """Return a device attribute as float, if convertible.""" value = self._device.get_attribute(attr) diff --git a/homeassistant/components/midea/entity.py b/homeassistant/components/midea/entity.py index e5182872a249..db59109622ca 100644 --- a/homeassistant/components/midea/entity.py +++ b/homeassistant/components/midea/entity.py @@ -6,7 +6,7 @@ from midealocal.device import MideaDevice from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from .const import DOMAIN, LOGGER from .device_catalog import MIDEA_DEVICE_NAMES @@ -20,11 +20,14 @@ class MideaEntity(Entity): _attr_has_entity_name = True _attr_should_poll = False - def __init__(self, device: MideaDevice, entity_key: str) -> None: + def __init__( + self, device: MideaDevice, entity_description: EntityDescription + ) -> None: """Initialize Midea base entity.""" self._device = device - self._unique_id = f"{self._device.device_id}_{entity_key}" + self._unique_id = f"{self._device.device_id}_{entity_description.key}" self._device_name = self._device.name + self.entity_description = entity_description @override async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/mikrotik/entity.py b/homeassistant/components/mikrotik/entity.py index 13573bbfb195..1f9041a7accd 100644 --- a/homeassistant/components/mikrotik/entity.py +++ b/homeassistant/components/mikrotik/entity.py @@ -5,39 +5,82 @@ from yarl import URL from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import slugify from .const import DOMAIN from .coordinator import MikrotikDataUpdateCoordinator -class MikrotikEntity[DescriptionT: EntityDescription]( - CoordinatorEntity[MikrotikDataUpdateCoordinator] -): - """Base class for Mikrotik entities.""" +class MikrotikBaseEntity(CoordinatorEntity[MikrotikDataUpdateCoordinator]): + """Base class for all Mikrotik entities.""" _attr_has_entity_name = True - entity_description: DescriptionT def __init__( self, coordinator: MikrotikDataUpdateCoordinator, - description: DescriptionT, + description: EntityDescription, ) -> None: """Initialize the entity.""" super().__init__(coordinator) self.entity_description = description self._serial = coordinator.api.serial_number - self._attr_device_info = DeviceInfo( + + def _base_device_info(self) -> DeviceInfo: + """Return the device info fields shared by all Mikrotik devices.""" + coordinator = self.coordinator + return DeviceInfo( configuration_url=URL.build( scheme="http", host=coordinator.host, ), - identifiers={(DOMAIN, self._serial)}, - name=coordinator.hostname, manufacturer="Mikrotik", model=coordinator.model, sw_version=coordinator.firmware, serial_number=self._serial, ) + + +class MikrotikEntity(MikrotikBaseEntity): + """Base class for Mikrotik entities.""" + + def __init__( + self, + coordinator: MikrotikDataUpdateCoordinator, + description: EntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, description) + self._attr_device_info = DeviceInfo( + **self._base_device_info(), + identifiers={(DOMAIN, self._serial)}, + name=coordinator.hostname, + ) self._attr_unique_id = f"{self._serial}_{description.key}" + + +class MikrotikDeviceEntity(MikrotikBaseEntity): + """Base class for Mikrotik device entities.""" + + def __init__( + self, + coordinator: MikrotikDataUpdateCoordinator, + description: EntityDescription, + interface: dict, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, description) + + name = interface.get("name") + ident = f"{slugify(interface.get('mac-address'))}_{name}" + + self._attr_device_info = DeviceInfo( + **self._base_device_info(), + identifiers={(DOMAIN, ident)}, + name=name, + via_device=(DOMAIN, coordinator.api.serial_number), + ) + self._attr_unique_id = ident + self._attr_name = name + self._interface = interface diff --git a/homeassistant/components/mikrotik/sensor.py b/homeassistant/components/mikrotik/sensor.py index 4712befbde30..acadf7c793b0 100644 --- a/homeassistant/components/mikrotik/sensor.py +++ b/homeassistant/components/mikrotik/sensor.py @@ -162,9 +162,7 @@ async def async_setup_entry( async_add_entities(sensors_list) -class MikrotikSensorEntity( - MikrotikEntity[MikrotikSensorEntityDescription], SensorEntity -): +class MikrotikSensorEntity(MikrotikEntity, SensorEntity): """Sensor device.""" entity_description: MikrotikSensorEntityDescription diff --git a/homeassistant/components/mikrotik/update.py b/homeassistant/components/mikrotik/update.py index fc9c3d4a9602..9aa717ac3a7e 100644 --- a/homeassistant/components/mikrotik/update.py +++ b/homeassistant/components/mikrotik/update.py @@ -81,13 +81,13 @@ async def async_setup_entry( class MikrotikUpdateEntity(MikrotikEntity, UpdateEntity): """Mixin for update entity specific attributes.""" - update_description: MikrotikUpdateEntityDescription + entity_description: MikrotikUpdateEntityDescription @property @override def supported_features(self) -> UpdateEntityFeature: """Flag supported features.""" - return cast(UpdateEntityFeature, self.entity_description.supported_features) + return self.entity_description.supported_features @property def _device_path_info(self) -> dict[str, Any]: diff --git a/homeassistant/components/mitsubishi_comfort/config_flow.py b/homeassistant/components/mitsubishi_comfort/config_flow.py index 7edae20eab09..84581f611b5f 100644 --- a/homeassistant/components/mitsubishi_comfort/config_flow.py +++ b/homeassistant/components/mitsubishi_comfort/config_flow.py @@ -88,14 +88,15 @@ class MitsubishiComfortConfigFlow(ConfigFlow, domain=DOMAIN): changed IP. """ mac = dr.format_mac(discovery_info.macaddress) - device = dr.async_get(self.hass).async_get_device( + devices = dr.async_get(self.hass).async_get_devices( connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) + device_entry_ids = {device.config_entry_id for device in devices} entry = next( ( entry for entry in self._async_current_entries(include_ignore=False) - if device is not None and entry.entry_id in device.config_entries + if entry.entry_id in device_entry_ids ), None, ) diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index 79380be47d31..aeb96c5a2659 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -4,11 +4,12 @@ import asyncio import logging -from motionblinds import AsyncMotionMulticast +from motionblinds import DEVICE_TYPES_GATEWAY, DEVICE_TYPES_WIFI, AsyncMotionMulticast from homeassistant.const import CONF_API_KEY, CONF_HOST, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from .const import ( CONF_BLIND_TYPE_LIST, @@ -21,6 +22,7 @@ from .const import ( PLATFORMS, ) from .coordinator import DataUpdateCoordinatorMotionBlinds, MotionBlindsConfigEntry +from .entity import gateway_device_info from .gateway import ConnectMotionGateway _LOGGER = logging.getLogger(__name__) @@ -101,6 +103,20 @@ async def async_setup_entry( entry.runtime_data = coordinator + # Register the gateway device up front so child blinds can resolve it as their + # via_device parent regardless of the order platforms are set up in. The any() + # is the exact complement of the children's linking condition, so the gateway is + # still registered if it self-reports an unexpected device_type while RF (non + # Wi-Fi) blinds depend on it. + if motion_gateway.device_type in DEVICE_TYPES_GATEWAY or any( + blind.device_type not in DEVICE_TYPES_WIFI + for blind in motion_gateway.device_list.values() + ): + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + **gateway_device_info(motion_gateway), + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/motion_blinds/entity.py b/homeassistant/components/motion_blinds/entity.py index 3d843cc373c8..2fabb4a06a2a 100644 --- a/homeassistant/components/motion_blinds/entity.py +++ b/homeassistant/components/motion_blinds/entity.py @@ -24,6 +24,23 @@ from .coordinator import DataUpdateCoordinatorMotionBlinds from .gateway import device_name +def gateway_device_info(gateway: MotionGateway) -> DeviceInfo: + """Return the device info of a Motionblinds gateway.""" + if gateway.firmware is not None: + sw_version = f"{gateway.firmware}, protocol: {gateway.protocol}" + else: + sw_version = f"Protocol: {gateway.protocol}" + + return DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, gateway.mac)}, + identifiers={(DOMAIN, gateway.mac)}, + manufacturer=MANUFACTURER, + name=DEFAULT_GATEWAY_NAME, + model="Wi-Fi bridge", + sw_version=sw_version, + ) + + class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlinds]): """Representation of a Motionblind entity.""" @@ -50,42 +67,37 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind self._update_interval_moving = UPDATE_INTERVAL_MOVING if blind.device_type in DEVICE_TYPES_GATEWAY: - gateway = blind + self._attr_device_info = gateway_device_info(blind) else: gateway = blind._gateway # noqa: SLF001 - if gateway.firmware is not None: - sw_version = f"{gateway.firmware}, protocol: {gateway.protocol}" - else: - sw_version = f"Protocol: {gateway.protocol}" + if gateway.firmware is not None: + sw_version = f"{gateway.firmware}, protocol: {gateway.protocol}" + else: + sw_version = f"Protocol: {gateway.protocol}" - if blind.device_type in DEVICE_TYPES_GATEWAY: - self._attr_device_info = DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, blind.mac)}, - identifiers={(DOMAIN, blind.mac)}, - manufacturer=MANUFACTURER, - name=DEFAULT_GATEWAY_NAME, - model="Wi-Fi bridge", - sw_version=sw_version, - ) - elif blind.device_type in DEVICE_TYPES_WIFI: - self._attr_device_info = DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, blind.mac)}, - identifiers={(DOMAIN, blind.mac)}, - manufacturer=MANUFACTURER, - model=blind.blind_type, - name=device_name(blind), - sw_version=sw_version, - hw_version=blind.wireless_name, - ) - else: - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, blind.mac)}, - manufacturer=MANUFACTURER, - model=blind.blind_type, - name=device_name(blind), - via_device=(DOMAIN, blind._gateway.mac), # noqa: SLF001 - hw_version=blind.wireless_name, - ) + if blind.device_type in DEVICE_TYPES_WIFI: + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, blind.mac)}, + identifiers={(DOMAIN, blind.mac)}, + manufacturer=MANUFACTURER, + model=blind.blind_type, + name=device_name(blind), + sw_version=sw_version, + hw_version=blind.wireless_name, + ) + else: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, blind.mac)}, + manufacturer=MANUFACTURER, + model=blind.blind_type, + name=device_name(blind), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, gateway.mac), + config_entry_id=coordinator.config_entry.entry_id, + ), + hw_version=blind.wireless_name, + ) @property @override diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index 0e3b12111bb3..40a788c06d8d 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -1319,8 +1319,8 @@ def ensure_via_device_exists( if ( device_info is None or CONF_VIA_DEVICE not in device_info - or (device_registry := dr.async_get(hass)).async_get_device( - identifiers={device_info["via_device"]} + or (device_registry := dr.async_get(hass)).async_get_device_by_identifier( + device_info["via_device"], config_entry.entry_id ) ): return diff --git a/homeassistant/components/mqtt/infrared.py b/homeassistant/components/mqtt/infrared.py index dd620bf2d3a4..f686f63b10c9 100644 --- a/homeassistant/components/mqtt/infrared.py +++ b/homeassistant/components/mqtt/infrared.py @@ -220,7 +220,7 @@ class MqttInfraredReceiverEntity(MqttEntity, InfraredReceiverEntity): _LOGGER.debug("Ignoring retained infrared signal on topic %s", msg.topic) return payload = self._value_template(msg.payload) - if not payload or payload in (PAYLOAD_NONE, "null"): + if not payload or payload in (PAYLOAD_NONE, "null", '""'): _LOGGER.debug( "Ignoring payload for %s on topic %s, with template %s", self.entity_id, diff --git a/homeassistant/components/mqtt/repairs.py b/homeassistant/components/mqtt/repairs.py index 75dfac16cf66..07439681f2c1 100644 --- a/homeassistant/components/mqtt/repairs.py +++ b/homeassistant/components/mqtt/repairs.py @@ -36,8 +36,8 @@ class MQTTDeviceEntryMigration(RepairsFlow): """Handle the confirm step of a fix flow.""" if user_input is not None: device_registry = dr.async_get(self.hass) - subentry_device = device_registry.async_get_device( - identifiers={(DOMAIN, self.subentry_id)} + subentry_device = device_registry.async_get_device_by_identifier( + (DOMAIN, self.subentry_id), self.entry_id ) entry = self.hass.config_entries.async_get_entry(self.entry_id) if TYPE_CHECKING: diff --git a/homeassistant/components/music_assistant/helpers.py b/homeassistant/components/music_assistant/helpers.py index 12d2fe496885..ab8269d4f4bd 100644 --- a/homeassistant/components/music_assistant/helpers.py +++ b/homeassistant/components/music_assistant/helpers.py @@ -4,12 +4,15 @@ from collections.abc import Callable, Coroutine import functools from typing import TYPE_CHECKING, Any +from music_assistant_models.auth import UserRole from music_assistant_models.errors import MusicAssistantError from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from .const import DOMAIN + if TYPE_CHECKING: from music_assistant_client import MusicAssistantClient @@ -46,10 +49,19 @@ def get_music_assistant_client( return entry.runtime_data.mass +async def _async_get_available_mass_usernames(mass: MusicAssistantClient) -> list[str]: + """Get available Music Assistant usernames which can be used in Home Assistant.""" + users = await mass.auth.list_users() + return [ + user.username for user in users if user.enabled and user.role != UserRole.GUEST + ] + + async def async_resolve_mass_username( - hass: HomeAssistant, user_id: str, available_usernames: list[str] + hass: HomeAssistant, mass: MusicAssistantClient, user_id: str ) -> str | None: """Resolve the Music Assistant username for the Home Assistant user.""" + available_usernames = await _async_get_available_mass_usernames(mass) if (user := await hass.auth.async_get_user(user_id)) is None: return None for cred in user.credentials: @@ -62,3 +74,19 @@ async def async_resolve_mass_username( if username in available_usernames: return username return None + + +async def async_verify_mass_username_availability( + mass: MusicAssistantClient, username: str +) -> None: + """Verify Music Assistant username availability for service calls.""" + available_usernames = await _async_get_available_mass_usernames(mass) + if username not in available_usernames: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_username", + translation_placeholders={ + "username": username, + "available_usernames": ", ".join(available_usernames), + }, + ) diff --git a/homeassistant/components/music_assistant/media_browser.py b/homeassistant/components/music_assistant/media_browser.py index 94b43b2a716e..65df5a443e2e 100644 --- a/homeassistant/components/music_assistant/media_browser.py +++ b/homeassistant/components/music_assistant/media_browser.py @@ -76,6 +76,39 @@ LIBRARY_MASS_MEDIA_TYPE_MAP = { LIBRARY_AUDIOBOOKS: MASSMediaType.AUDIOBOOK, } +MUSIC_MASS_MEDIA_TYPES = [ + MASSMediaType.ARTIST, + MASSMediaType.ALBUM, + MASSMediaType.TRACK, + MASSMediaType.PLAYLIST, +] + +MEDIA_CLASS_MASS_MEDIA_TYPE_MAP = { + MediaClass.ARTIST: [MASSMediaType.ARTIST], + MediaClass.ALBUM: [MASSMediaType.ALBUM], + MediaClass.TRACK: [MASSMediaType.TRACK], + MediaClass.PLAYLIST: [MASSMediaType.PLAYLIST], + # music is the class a voice assistant picks for a plain "play something" + # request, so it has to mean music rather than the radio stations we + # happen to hand back to HA under the same class + MediaClass.MUSIC: MUSIC_MASS_MEDIA_TYPES, + MediaClass.DIRECTORY: [MASSMediaType.AUDIOBOOK], + MediaClass.PODCAST: [MASSMediaType.PODCAST], +} + +SEARCHABLE_MASS_MEDIA_TYPES = [ + MASSMediaType.ARTIST, + MASSMediaType.ALBUM, + MASSMediaType.TRACK, + MASSMediaType.PLAYLIST, + MASSMediaType.RADIO, + MASSMediaType.AUDIOBOOK, + MASSMediaType.PODCAST, +] + +# an artist holds nothing else we can search or browse +ARTIST_MASS_MEDIA_TYPES = [MASSMediaType.ALBUM, MASSMediaType.TRACK] + MEDIA_CONTENT_TYPE_FLAC = "audio/flac" THUMB_SIZE = 200 SORT_NAME = "sort_name" @@ -490,22 +523,48 @@ async def _search_within_playlist( async def _search_within_artist( - mass: MusicAssistantClient, artist_uri: str, search_query: str, limit: int + mass: MusicAssistantClient, + artist_uri: str, + search_query: str, + limit: int, + media_types: list[MASSMediaType], ) -> SearchResults: """Search for content within an artist's catalog.""" artist = await mass.music.get_item_by_uri(artist_uri) search_query = f"{artist.name} - {search_query}" return await mass.music.search( search_query, - media_types=[MASSMediaType.ALBUM, MASSMediaType.TRACK], + media_types=media_types, limit=limit, ) def _get_media_types_from_query(query: SearchMediaQuery) -> list[MASSMediaType]: - """Map query to Music Assistant media types.""" + """Map query to Music Assistant media types. + + Returns nothing when the query rules out everything we could look for. + """ media_types: list[MASSMediaType] = [] + # searching inside an artist can never turn up more than their own + # albums and tracks, whatever the rest of the query asks for + allowed = ( + ARTIST_MASS_MEDIA_TYPES + if "artist/" in (query.media_content_id or "") + else SEARCHABLE_MASS_MEDIA_TYPES + ) + + # an explicit filter is the only thing the user picked themselves, so it + # wins from the media type that merely surrounds the search, and asking + # for something unsearchable leaves nothing rather than everything + if query.media_filter_classes: + requested = { + media_type + for cls in query.media_filter_classes + for media_type in MEDIA_CLASS_MASS_MEDIA_TYPE_MAP.get(cls, ()) + } + return [media_type for media_type in allowed if media_type in requested] + match query.media_content_type: case MediaType.ARTIST: media_types = [MASSMediaType.ARTIST] @@ -523,21 +582,7 @@ def _get_media_types_from_query(query: SearchMediaQuery) -> list[MASSMediaType]: media_types = [MASSMediaType.PODCAST] case _: # No specific type selected - if query.media_filter_classes: - # Map MediaClass to search types - mapping = { - MediaClass.ARTIST: MASSMediaType.ARTIST, - MediaClass.ALBUM: MASSMediaType.ALBUM, - MediaClass.TRACK: MASSMediaType.TRACK, - MediaClass.PLAYLIST: MASSMediaType.PLAYLIST, - MediaClass.MUSIC: MASSMediaType.RADIO, - MediaClass.DIRECTORY: MASSMediaType.AUDIOBOOK, - MediaClass.PODCAST: MASSMediaType.PODCAST, - } - media_types = [ - mapping[cls] for cls in query.media_filter_classes if cls in mapping - ] - elif library_media_type := LIBRARY_MASS_MEDIA_TYPE_MAP.get( + if library_media_type := LIBRARY_MASS_MEDIA_TYPE_MAP.get( query.media_content_id or "" ): # Searching from a library listing scopes to that library, @@ -545,19 +590,10 @@ def _get_media_types_from_query(query: SearchMediaQuery) -> list[MASSMediaType]: # rather than as a concrete media type. media_types = [library_media_type] - # Default to all types if none specified - if not media_types: - media_types = [ - MASSMediaType.ARTIST, - MASSMediaType.ALBUM, - MASSMediaType.TRACK, - MASSMediaType.PLAYLIST, - MASSMediaType.RADIO, - MASSMediaType.AUDIOBOOK, - MASSMediaType.PODCAST, - ] - - return media_types + # Default to everything we are allowed to look for if none specified + return [ + media_type for media_type in media_types if media_type in allowed + ] or allowed def _process_search_results( @@ -645,6 +681,12 @@ async def async_search_media( limit = 5 # Default limit per media type search_results: SearchResults | None = None + # Determine which media types to search + media_types = _get_media_types_from_query(query) + if not media_types: + # the query ruled out everything we could have looked for + return SearchMedia(result=[]) + # Handle media_content_id if provided (for contextual searches) if query.media_content_id: if "album/" in query.media_content_id: @@ -658,12 +700,9 @@ async def async_search_media( if "artist/" in query.media_content_id: # For artists, we already run a search, so save the results search_results = await _search_within_artist( - mass, query.media_content_id, search_query, limit + mass, query.media_content_id, search_query, limit, media_types ) - # Determine which media types to search - media_types = _get_media_types_from_query(query) - # Execute search using the Music Assistant API if we haven't already done so if search_results is None: search_results = await mass.music.search( diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 74d1db426191..83aa62bc1b21 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -6,7 +6,6 @@ from contextlib import suppress import os from typing import TYPE_CHECKING, Any, override -from music_assistant_models.auth import UserRole from music_assistant_models.constants import PLAYER_CONTROL_NONE from music_assistant_models.enums import ( EventType, @@ -61,7 +60,11 @@ from .const import ( DOMAIN, ) from .entity import MusicAssistantEntity -from .helpers import async_resolve_mass_username, catch_musicassistant_error +from .helpers import ( + async_resolve_mass_username, + async_verify_mass_username_availability, + catch_musicassistant_error, +) from .media_browser import async_browse_media, async_search_media from .schemas import QUEUE_DETAILS_SCHEMA, queue_item_dict_from_mass_item @@ -463,26 +466,12 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): # An explicit username is validated strictly; when omitted we default to # the Home Assistant user that made the call (best-effort, never raises). user_id = self._context.user_id if self._context is not None else None - if username is not None or user_id is not None: - available_usernames = [ - user.username - for user in await self.mass.auth.list_users() - if user.enabled and user.role != UserRole.GUEST - ] - if username is not None: - if username not in available_usernames: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_username", - translation_placeholders={ - "username": username, - "available_usernames": ", ".join(available_usernames), - }, - ) - elif user_id is not None: - username = await async_resolve_mass_username( - self.hass, user_id, available_usernames - ) + if username is not None: + await async_verify_mass_username_availability( + mass=self.mass, username=username + ) + elif user_id is not None: + username = await async_resolve_mass_username(self.hass, self.mass, user_id) media_uris: list[str] = [] item: MediaItemType | ItemMapping | None = None diff --git a/homeassistant/components/music_assistant/services.py b/homeassistant/components/music_assistant/services.py index 8154a4eeae47..9a8d1083c061 100644 --- a/homeassistant/components/music_assistant/services.py +++ b/homeassistant/components/music_assistant/services.py @@ -54,7 +54,7 @@ from .const import ( ATTR_USERNAME, DOMAIN, ) -from .helpers import get_music_assistant_client +from .helpers import async_verify_mass_username_availability, get_music_assistant_client from .schemas import ( LIBRARY_RESULTS_SCHEMA, SEARCH_RESULT_SCHEMA, @@ -102,6 +102,7 @@ def register_actions(hass: HomeAssistant) -> None: vol.Optional(ATTR_SEARCH_ALBUM): cv.string, vol.Optional(ATTR_LIMIT, default=5): vol.Coerce(int), vol.Optional(ATTR_LIBRARY_ONLY, default=False): cv.boolean, + vol.Optional(ATTR_USERNAME): cv.string, } ), supports_response=SupportsResponse.ONLY, @@ -121,6 +122,7 @@ def register_actions(hass: HomeAssistant) -> None: vol.Optional(ATTR_ORDER_BY): cv.string, vol.Optional(ATTR_ALBUM_TYPE): list[MediaType], vol.Optional(ATTR_ALBUM_ARTISTS_ONLY): cv.boolean, + vol.Optional(ATTR_USERNAME): cv.string, } ), supports_response=SupportsResponse.ONLY, @@ -184,6 +186,11 @@ async def handle_search(call: ServiceCall) -> ServiceResponse: search_name = call.data[ATTR_SEARCH_NAME] search_artist = call.data.get(ATTR_SEARCH_ARTIST) search_album = call.data.get(ATTR_SEARCH_ALBUM) + search_username = call.data.get(ATTR_USERNAME) + if search_username is not None: + await async_verify_mass_username_availability( + mass=mass, username=search_username + ) if search_album and search_artist: search_name = f"{search_artist} - {search_album} - {search_name}" elif search_album: @@ -195,6 +202,7 @@ async def handle_search(call: ServiceCall) -> ServiceResponse: media_types=call.data.get(ATTR_MEDIA_TYPE, MediaType.ALL), limit=call.data[ATTR_LIMIT], library_only=call.data[ATTR_LIBRARY_ONLY], + user=search_username, ) response: ServiceResponse = SEARCH_RESULT_SCHEMA( { @@ -238,12 +246,16 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse: limit = call.data.get(ATTR_LIMIT, DEFAULT_LIMIT) offset = call.data.get(ATTR_OFFSET, DEFAULT_OFFSET) order_by = call.data.get(ATTR_ORDER_BY, DEFAULT_SORT_ORDER) + username = call.data.get(ATTR_USERNAME) + if username is not None: + await async_verify_mass_username_availability(mass=mass, username=username) base_params = { "favorite": call.data.get(ATTR_FAVORITE), "search": call.data.get(ATTR_SEARCH), "limit": limit, "offset": offset, "order_by": order_by, + "user": username, } library_result: ( list[Album] diff --git a/homeassistant/components/music_assistant/services.yaml b/homeassistant/components/music_assistant/services.yaml index d5852d76100b..45033c722440 100644 --- a/homeassistant/components/music_assistant/services.yaml +++ b/homeassistant/components/music_assistant/services.yaml @@ -156,6 +156,10 @@ search: default: false selector: boolean: + username: + example: "john" + selector: + text: get_library: fields: @@ -246,3 +250,7 @@ get_library: default: false selector: boolean: + username: + example: "john" + selector: + text: diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index 722e272741d3..d7374f1ce4b7 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -363,6 +363,10 @@ "search": { "description": "Optional search string to search through this library.", "name": "Search" + }, + "username": { + "description": "Music Assistant username used for this request. Providing the username will respect the user's configured provider filters.", + "name": "Username" } }, "name": "Get library items", @@ -462,6 +466,10 @@ "name": { "description": "The name/title to search for.", "name": "Search name" + }, + "username": { + "description": "Music Assistant username used for searching. Searches respect the user's configured provider filters.", + "name": "Username" } }, "name": "Search Music Assistant", diff --git a/homeassistant/components/myuplink/coordinator.py b/homeassistant/components/myuplink/coordinator.py index f1bab8995a11..fae248e35014 100644 --- a/homeassistant/components/myuplink/coordinator.py +++ b/homeassistant/components/myuplink/coordinator.py @@ -2,7 +2,7 @@ import asyncio.timeouts from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import timedelta import logging from typing import override @@ -22,7 +22,6 @@ class CoordinatorData: systems: list[System] devices: dict[str, Device] points: dict[str, dict[str, DevicePoint]] - time: datetime type MyUplinkConfigEntry = ConfigEntry[MyUplinkDataCoordinator] @@ -75,5 +74,4 @@ class MyUplinkDataCoordinator(DataUpdateCoordinator[CoordinatorData]): systems=systems, devices=devices, points=points, - time=datetime.now(), # pylint: disable=home-assistant-enforce-naive-now ) diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index 2212644bce60..4f59f85b2372 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -74,6 +74,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: NetgearConfigEntry) -> b coordinator_link=coordinator_link, ) + # Register the router device before platforms so tracked devices can always + # resolve it as their via_device parent, regardless of platform setup order. + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, **router.device_info + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/netgear/entity.py b/homeassistant/components/netgear/entity.py index 40bbb9801681..c1d172521ce5 100644 --- a/homeassistant/components/netgear/entity.py +++ b/homeassistant/components/netgear/entity.py @@ -3,7 +3,6 @@ from abc import abstractmethod from typing import Any, override -from homeassistant.const import CONF_HOST from homeassistant.core import callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -37,7 +36,11 @@ class NetgearDeviceEntity(CoordinatorEntity[NetgearTrackerCoordinator]): connections={(dr.CONNECTION_NETWORK_MAC, self._mac)}, default_name=self._device_name, default_model=device["device_model"], - via_device=(DOMAIN, coordinator.router.unique_id), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, coordinator.router.unique_id), + config_entry_id=coordinator.config_entry.entry_id, + ), ) def get_device_name(self): @@ -69,22 +72,8 @@ class NetgearRouterEntity(Entity): def __init__(self, router: NetgearRouter) -> None: """Initialize a Netgear device.""" self._router = router - - configuration_url = None - if host := router.entry.data[CONF_HOST]: - configuration_url = f"http://{host}/" - self._attr_unique_id = router.serial_number - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, router.unique_id)}, - manufacturer="Netgear", - name=router.device_name, - model=router.model, - serial_number=router.serial_number, - sw_version=router.firmware_version, - hw_version=router.hardware_version, - configuration_url=configuration_url, - ) + self._attr_device_info = router.device_info class NetgearRouterCoordinatorEntity[T: NetgearDataCoordinator[Any]]( diff --git a/homeassistant/components/netgear/router.py b/homeassistant/components/netgear/router.py index 1dc86b150e47..c46e0eb86ede 100644 --- a/homeassistant/components/netgear/router.py +++ b/homeassistant/components/netgear/router.py @@ -17,6 +17,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.util import dt as dt_util from .const import ( @@ -270,6 +271,24 @@ class NetgearRouter: async with self.api_lock: await self.hass.async_add_executor_job(self.api.update_new_firmware) + @property + def device_info(self) -> DeviceInfo: + """Return the device information for the router.""" + configuration_url = None + if host := self.entry.data[CONF_HOST]: + configuration_url = f"http://{host}/" + + return DeviceInfo( + identifiers={(DOMAIN, self.unique_id)}, + manufacturer="Netgear", + name=self.device_name, + model=self.model, + serial_number=self.serial_number, + sw_version=self.firmware_version, + hw_version=self.hardware_version, + configuration_url=configuration_url, + ) + @property def port(self) -> int: """Port used by the API.""" diff --git a/homeassistant/components/nextdns/__init__.py b/homeassistant/components/nextdns/__init__.py index 0e77c236bd9e..dfa729b852eb 100644 --- a/homeassistant/components/nextdns/__init__.py +++ b/homeassistant/components/nextdns/__init__.py @@ -135,7 +135,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: hass.config_entries.async_add_subentry(parent_entry, subentry) entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id) - device = device_registry.async_get_device(identifiers={(DOMAIN, profile_id)}) + device = device_registry.async_get_device_by_identifier( + (DOMAIN, profile_id), entry.entry_id + ) for entity_entry in entities: entity_disabled_by = entity_entry.disabled_by @@ -172,20 +174,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device.id, disabled_by=device_disabled_by, new_identifiers={(DOMAIN, profile_id)}, - add_config_subentry_id=subentry.subentry_id, - add_config_entry_id=parent_entry.entry_id, + new_config_entry_id=parent_entry.entry_id, + new_config_subentry_id=subentry.subentry_id, ) - if parent_entry.entry_id != entry.entry_id: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - ) - else: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - remove_config_subentry_id=None, - ) if parent_entry.entry_id != entry.entry_id: await hass.config_entries.async_remove(entry.entry_id) diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index f1d4dc52e61e..e2b3f9ba7da5 100644 --- a/homeassistant/components/nibe_heatpump/manifest.json +++ b/homeassistant/components/nibe_heatpump/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/nibe_heatpump", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["nibe==2.22.0"] + "requirements": ["nibe==2.24.0"] } diff --git a/homeassistant/components/nuki/entity.py b/homeassistant/components/nuki/entity.py index be45ee5b578a..13bff64dbf2e 100644 --- a/homeassistant/components/nuki/entity.py +++ b/homeassistant/components/nuki/entity.py @@ -4,6 +4,7 @@ from typing import override from pynuki.device import NukiDevice +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -38,6 +39,10 @@ class NukiEntity[_NukiDeviceT: NukiDevice](CoordinatorEntity[NukiCoordinator]): manufacturer="Nuki Home Solutions GmbH", model=self._nuki_device.device_model_str.capitalize(), sw_version=self._nuki_device.firmware_version, - via_device=(DOMAIN, self.coordinator.bridge_id), + via_device_id=dr.async_get_device_id_by_identifier( + self.coordinator.hass, + (DOMAIN, self.coordinator.bridge_id), + config_entry_id=self.coordinator.config_entry.entry_id, + ), serial_number=parse_id(self._nuki_device.nuki_id), ) diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index f97d67a9503c..77b8f18e2385 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -226,17 +226,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> _LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) if entry.version == 2 and entry.minor_version == 1: - # Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1 - device_registry = dr.async_get(hass) - for device in dr.async_entries_for_config_entry( - device_registry, entry.entry_id - ): - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - remove_config_subentry_id=None, - ) - + # Devices left in both the config entry and its subentry by Home Assistant Core + # 2025.7.0b0-2025.7.0b1 are collapsed onto the subentry by the device registry + # migration, so there's nothing to correct here. hass.config_entries.async_update_entry(entry, minor_version=2) if entry.version == 2 and entry.minor_version == 2: diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 5327599c9abe..2ff1088ce1c9 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -418,17 +418,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) if entry.version == 2 and entry.minor_version == 1: - # Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1 - device_registry = dr.async_get(hass) - for device in dr.async_entries_for_config_entry( - device_registry, entry.entry_id - ): - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - remove_config_subentry_id=None, - ) - + # Devices left in both the config entry and its subentry by Home Assistant Core + # 2025.7.0b0-2025.7.0b1 are collapsed onto the subentry by the device registry + # migration, so there's nothing to correct here. hass.config_entries.async_update_entry(entry, minor_version=2) if entry.version == 2 and entry.minor_version == 2: diff --git a/homeassistant/components/openrgb/light.py b/homeassistant/components/openrgb/light.py index f508f9cb3991..e88190944edb 100644 --- a/homeassistant/components/openrgb/light.py +++ b/homeassistant/components/openrgb/light.py @@ -17,6 +17,7 @@ from homeassistant.components.light import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -99,7 +100,11 @@ class OpenRGBLight(CoordinatorEntity[OpenRGBCoordinator], LightEntity): model=f"{self.device.metadata.description} ({self.device.type.name})", sw_version=self.device.metadata.version, serial_number=self.device.metadata.serial, - via_device=(DOMAIN, coordinator.entry_id), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, coordinator.entry_id), + config_entry_id=coordinator.entry_id, + ), ) modes = [mode.name for mode in self.device.modes] diff --git a/homeassistant/components/ouman_eh_800/__init__.py b/homeassistant/components/ouman_eh_800/__init__.py index 5e2bc8e4dd3f..6880d3f671d3 100644 --- a/homeassistant/components/ouman_eh_800/__init__.py +++ b/homeassistant/components/ouman_eh_800/__init__.py @@ -2,7 +2,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from .const import OumanDevice from .coordinator import OumanEh800ConfigEntry, OumanEh800Coordinator _PLATFORMS: list[Platform] = [ @@ -23,6 +25,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: OumanEh800ConfigEntry) - entry.runtime_data = coordinator + # Register the main device up front so the L1/L2 sub-devices can + # deterministically resolve their via_device_id, regardless of which + # platform's entities are added first. + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + **coordinator.device_info(OumanDevice.MAIN), + ) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) return True diff --git a/homeassistant/components/ouman_eh_800/coordinator.py b/homeassistant/components/ouman_eh_800/coordinator.py index e27206fec6b8..1db24d70e75e 100644 --- a/homeassistant/components/ouman_eh_800/coordinator.py +++ b/homeassistant/components/ouman_eh_800/coordinator.py @@ -24,6 +24,7 @@ from homeassistant.exceptions import ( ConfigEntryNotReady, HomeAssistantError, ) +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -63,10 +64,10 @@ class OumanEh800Coordinator(DataUpdateCoordinator[dict[OumanEndpoint, OumanValue ) entry_id = config_entry.entry_id - main_device_identifier = (DOMAIN, entry_id) - self.device_info: dict[OumanDevice, DeviceInfo] = { + self._main_device_identifier = (DOMAIN, entry_id) + self._device_info: dict[OumanDevice, DeviceInfo] = { OumanDevice.MAIN: DeviceInfo( - identifiers={main_device_identifier}, + identifiers={self._main_device_identifier}, manufacturer="Ouman", model="EH-800", configuration_url=config_entry.data[CONF_URL], @@ -75,16 +76,25 @@ class OumanEh800Coordinator(DataUpdateCoordinator[dict[OumanEndpoint, OumanValue identifiers={(DOMAIN, f"{entry_id}_{OumanDevice.L1}")}, translation_key="heating_circuit", translation_placeholders={"circuit_number": "1"}, - via_device=main_device_identifier, ), OumanDevice.L2: DeviceInfo( identifiers={(DOMAIN, f"{entry_id}_{OumanDevice.L2}")}, translation_key="heating_circuit", translation_placeholders={"circuit_number": "2"}, - via_device=main_device_identifier, ), } + def device_info(self, device: OumanDevice) -> DeviceInfo: + """Return the device info for a logical device.""" + device_info = self._device_info[device] + if device is not OumanDevice.MAIN and "via_device_id" not in device_info: + device_info["via_device_id"] = dr.async_get_device_id_by_identifier( + self.hass, + self._main_device_identifier, + config_entry_id=self.config_entry.entry_id, + ) + return device_info + @override async def _async_setup(self) -> None: try: @@ -132,7 +142,7 @@ class OumanEh800Coordinator(DataUpdateCoordinator[dict[OumanEndpoint, OumanValue ): if circuit_name := self.data.get(endpoint): assert isinstance(circuit_name, str) - device_info = self.device_info[device] + device_info = self._device_info[device] device_info["translation_key"] = "heating_circuit_with_name" device_info["translation_placeholders"] = { "circuit_number": circuit_number, diff --git a/homeassistant/components/ouman_eh_800/entity.py b/homeassistant/components/ouman_eh_800/entity.py index d99666a40499..55f03b71f707 100644 --- a/homeassistant/components/ouman_eh_800/entity.py +++ b/homeassistant/components/ouman_eh_800/entity.py @@ -1,9 +1,11 @@ """Base entity for Ouman EH-800.""" from dataclasses import dataclass +from typing import override from ouman_eh_800_api import OumanEndpoint +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -39,4 +41,9 @@ class OumanEh800Entity(CoordinatorEntity[OumanEh800Coordinator]): f"{coordinator.config_entry.entry_id}" f"_{description.device}_{description.key}" ) - self._attr_device_info = coordinator.device_info[description.device] + + @property + @override + def device_info(self) -> DeviceInfo: + """Return the device info.""" + return self.coordinator.device_info(self.entity_description.device) diff --git a/homeassistant/components/philips_js/const.py b/homeassistant/components/philips_js/const.py index 7788634ebc03..8db0778c5852 100644 --- a/homeassistant/components/philips_js/const.py +++ b/homeassistant/components/philips_js/const.py @@ -7,4 +7,7 @@ CONF_ALLOW_NOTIFY = "allow_notify" CONST_APP_ID = "homeassistant.io" CONST_APP_NAME = "Home Assistant" +TV_STATE_OFF = "Off" +TV_STATE_ON = "On" + TRIGGER_TYPE_TURN_ON = "turn_on" diff --git a/homeassistant/components/philips_js/media_player.py b/homeassistant/components/philips_js/media_player.py index 4086b15da680..a8336a287b2c 100644 --- a/homeassistant/components/philips_js/media_player.py +++ b/homeassistant/components/philips_js/media_player.py @@ -20,6 +20,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.trigger import PluggableAction from . import LOGGER as _LOGGER +from .const import TV_STATE_OFF from .coordinator import PhilipsTVConfigEntry, PhilipsTVDataUpdateCoordinator from .entity import PhilipsJsEntity from .helpers import async_get_turn_on_trigger @@ -458,7 +459,9 @@ class PhilipsTVMediaPlayer(PhilipsJsEntity, MediaPlayerEntity): @callback def _update_from_coordinator(self): if self._tv.on: - if self._tv.powerstate in ("Standby", "StandbyKeep"): + if self._tv.powerstate in ("Standby", "StandbyKeep") or ( + self._tv.powerstate is None and self._tv.screenstate == TV_STATE_OFF + ): self._attr_state = MediaPlayerState.OFF else: self._attr_state = MediaPlayerState.ON diff --git a/homeassistant/components/philips_js/switch.py b/homeassistant/components/philips_js/switch.py index 2be26ce2460c..f08e8c6075fd 100644 --- a/homeassistant/components/philips_js/switch.py +++ b/homeassistant/components/philips_js/switch.py @@ -6,12 +6,10 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import TV_STATE_OFF, TV_STATE_ON from .coordinator import PhilipsTVConfigEntry, PhilipsTVDataUpdateCoordinator from .entity import PhilipsJsEntity -HUE_POWER_OFF = "Off" -HUE_POWER_ON = "On" - async def async_setup_entry( hass: HomeAssistant, @@ -50,23 +48,23 @@ class PhilipsTVScreenSwitch(PhilipsJsEntity, SwitchEntity): return False if not self.coordinator.api.on: return False - return self.coordinator.api.powerstate == "On" + return self.coordinator.api.powerstate in (TV_STATE_ON, None) @property @override def is_on(self) -> bool: """Return True if entity is on.""" - return self.coordinator.api.screenstate == "On" + return self.coordinator.api.screenstate == TV_STATE_ON @override async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - await self.coordinator.api.setScreenState("On") + await self.coordinator.api.setScreenState(TV_STATE_ON) @override async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - await self.coordinator.api.setScreenState("Off") + await self.coordinator.api.setScreenState(TV_STATE_OFF) class PhilipsTVAmbilightHueSwitch(PhilipsJsEntity, SwitchEntity): @@ -92,22 +90,22 @@ class PhilipsTVAmbilightHueSwitch(PhilipsJsEntity, SwitchEntity): return False if not self.coordinator.api.on: return False - return self.coordinator.api.powerstate == "On" + return self.coordinator.api.powerstate in (TV_STATE_ON, None) @property @override def is_on(self) -> bool: """Return True if entity is on.""" - return self.coordinator.api.huelamp_power == HUE_POWER_ON + return self.coordinator.api.huelamp_power == TV_STATE_ON @override async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - await self.coordinator.api.setHueLampPower(HUE_POWER_ON) + await self.coordinator.api.setHueLampPower(TV_STATE_ON) self.async_write_ha_state() @override async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - await self.coordinator.api.setHueLampPower(HUE_POWER_OFF) + await self.coordinator.api.setHueLampPower(TV_STATE_OFF) self.async_write_ha_state() diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index a571604c4c16..cd69ed347808 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -3,7 +3,7 @@ from collections.abc import Callable from functools import wraps import logging -from typing import Any, Concatenate, cast, override +from typing import TYPE_CHECKING, Any, Concatenate, cast, override from plexapi.client import PlexClient import plexapi.exceptions @@ -20,7 +20,7 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -557,6 +557,9 @@ class PlexMediaPlayer(MediaPlayerEntity): entry_type=DeviceEntryType.SERVICE, ) + config_entry = self.platform.config_entry + if TYPE_CHECKING: + assert config_entry return DeviceInfo( identifiers={(DOMAIN, self.machine_identifier)}, manufacturer=self.device_platform or "Plex", @@ -566,7 +569,11 @@ class PlexMediaPlayer(MediaPlayerEntity): # name to None name=cast(str | None, self.name), sw_version=self.device_version, - via_device=(DOMAIN, self.plex_server.machine_identifier), + via_device_id=dr.async_get_device_id_by_identifier( + self.hass, + (DOMAIN, self.plex_server.machine_identifier), + config_entry_id=config_entry.entry_id, + ), ) @override diff --git a/homeassistant/components/portainer/manifest.json b/homeassistant/components/portainer/manifest.json index 395fe0b96413..ea80800d7c45 100644 --- a/homeassistant/components/portainer/manifest.json +++ b/homeassistant/components/portainer/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["pyportainer"], "quality_scale": "platinum", - "requirements": ["pyportainer==1.0.42"] + "requirements": ["pyportainer==1.0.43"] } diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 7a4d32bea96f..510c239ed982 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -58,6 +58,7 @@ PLATFORMS = [ Platform.SENSOR, Platform.SIREN, Platform.SWITCH, + Platform.TIME, Platform.UPDATE, ] FIRMWARE_UPDATE_INTERVAL = timedelta(hours=24) @@ -229,23 +230,39 @@ async def async_setup_entry( # ensure host device is setup before connected camera devices that use via_device device_registry = dr.async_get(hass) - device_registry.async_get_or_create( + host_device = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, host.unique_id)}, connections={(dr.CONNECTION_NETWORK_MAC, host.api.mac_address)}, ) if host.api.is_nvr and host.api.model in DUAL_LENS_DUAL_MOTION_MODELS: - # ensure the camera device is setup before + # ensure the camera devices are setup before # the lens sub-devices that use via_device - if host.api.supported(0, "UID"): - camera_dev_id = f"{host.unique_id}_{host.api.camera_uid(0)}" + for channel in host.api.stream_channels: + if host.api.supported(channel, "UID"): + camera_dev_id = f"{host.unique_id}_{host.api.camera_uid(channel)}" + else: + camera_dev_id = f"{host.unique_id}_ch{channel}" + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, camera_dev_id)}, + via_device_id=host_device.id, + ) + + # ensure the camera devices that chimes connect through are setup + # before the chime sub-devices that use via_device + for chime in host.api.chime_list: + if chime.channel is None or not host.api.is_nvr: + continue # chime connected directly to the host device + if host.api.supported(chime.channel, "UID"): + camera_dev_id = f"{host.unique_id}_{host.api.camera_uid(chime.channel)}" else: - camera_dev_id = f"{host.unique_id}_ch0" + camera_dev_id = f"{host.unique_id}_ch{chime.channel}" device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, camera_dev_id)}, - via_device=(DOMAIN, host.unique_id), + via_device_id=host_device.id, ) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index d18c01eb99e1..16e85a943c4b 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -7,6 +7,7 @@ from typing import override from reolink_aio.api import DUAL_LENS_DUAL_MOTION_MODELS, Chime, Host from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -210,7 +211,11 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._dev_id)}, connections=connections, - via_device=(DOMAIN, self._host.unique_id), + via_device_id=dr.async_get_device_id_by_identifier( + self.coordinator.hass, + (DOMAIN, self._host.unique_id), + config_entry_id=self.coordinator.config_entry.entry_id, + ), name=self._host.api.camera_name(channel), model=self._host.api.camera_model(channel), model_id=self._host.api.item_number(channel), @@ -231,7 +236,11 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): self._dev_id = f"{self._host.unique_id}_lens{channel}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._dev_id)}, - via_device=(DOMAIN, parent_dev_id), + via_device_id=dr.async_get_device_id_by_identifier( + self.coordinator.hass, + (DOMAIN, parent_dev_id), + config_entry_id=self.coordinator.config_entry.entry_id, + ), name=f"{self._host.api.camera_name(0)} lens {channel}", model=self._host.api.camera_model(0), manufacturer=self._host.api.manufacturer, @@ -298,7 +307,11 @@ class ReolinkHostChimeCoordinatorEntity(ReolinkHostCoordinatorEntity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._dev_id)}, - via_device=(DOMAIN, via_dev_id), + via_device_id=dr.async_get_device_id_by_identifier( + self.coordinator.hass, + (DOMAIN, via_dev_id), + config_entry_id=self.coordinator.config_entry.entry_id, + ), name=chime.name, model="Reolink Chime", manufacturer=self._host.api.manufacturer, @@ -336,7 +349,11 @@ class ReolinkChimeCoordinatorEntity(ReolinkChannelCoordinatorEntity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._dev_id)}, - via_device=(DOMAIN, via_dev_id), + via_device_id=dr.async_get_device_id_by_identifier( + self.coordinator.hass, + (DOMAIN, via_dev_id), + config_entry_id=self.coordinator.config_entry.entry_id, + ), name=chime.name, model="Reolink Chime", manufacturer=self._host.api.manufacturer, diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index 6e1943fafe5a..efcf8aa7267d 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -629,6 +629,14 @@ "siren_on_event": { "default": "mdi:alarm-light" } + }, + "time": { + "floodlight_schedule_end": { + "default": "mdi:clock-end" + }, + "floodlight_schedule_start": { + "default": "mdi:clock-start" + } } }, "services": { diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 10f9235cc250..0416a43ebd84 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -20,5 +20,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.21.7"] + "requirements": ["reolink-aio==0.21.8"] } diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index f695251a1528..c5fa83c37f39 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -880,6 +880,14 @@ "siren_on_event": { "name": "Siren on event" } + }, + "time": { + "floodlight_schedule_end": { + "name": "Floodlight schedule end" + }, + "floodlight_schedule_start": { + "name": "Floodlight schedule start" + } } }, "exceptions": { diff --git a/homeassistant/components/reolink/time.py b/homeassistant/components/reolink/time.py new file mode 100644 index 000000000000..7fa39e57f1fd --- /dev/null +++ b/homeassistant/components/reolink/time.py @@ -0,0 +1,138 @@ +"""Component providing support for Reolink time entities.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import time +from typing import Any, override + +from reolink_aio.api import Host +from reolink_aio.enums import SpotlightModeEnum + +from homeassistant.components.time import TimeEntity, TimeEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription +from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class ReolinkTimeEntityDescription( + TimeEntityDescription, + ReolinkChannelEntityDescription, +): + """A class that describes time entities.""" + + method: Callable[[Host, int, time], Any] + value: Callable[[Host, int], time | None] + + +def _schedule_time(api: Host, ch: int, prefix: str) -> time | None: + """Return the start or end time of the floodlight schedule.""" + schedule = api.whiteled_schedule(ch) + if not schedule: + return None + return time(hour=schedule[f"{prefix}Hour"], minute=schedule[f"{prefix}Min"]) + + +def _set_start(api: Host, ch: int, value: time) -> Any: + """Set the start time of the floodlight schedule.""" + schedule = api.whiteled_schedule(ch) or {} + return api.set_spotlight_lighting_schedule( + ch, + schedule.get("EndHour", 0), + schedule.get("EndMin", 0), + value.hour, + value.minute, + ) + + +def _set_end(api: Host, ch: int, value: time) -> Any: + """Set the end time of the floodlight schedule.""" + schedule = api.whiteled_schedule(ch) or {} + return api.set_spotlight_lighting_schedule( + ch, + value.hour, + value.minute, + schedule.get("StartHour", 0), + schedule.get("StartMin", 0), + ) + + +TIME_ENTITIES = ( + ReolinkTimeEntityDescription( + key="floodlight_schedule_start", + cmd_key="GetWhiteLed", + cmd_id=[289, 438], + translation_key="floodlight_schedule_start", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + supported=lambda api, ch: ( + SpotlightModeEnum.schedule.name in api.whiteled_mode_list(ch) + ), + value=lambda api, ch: _schedule_time(api, ch, "Start"), + method=_set_start, + ), + ReolinkTimeEntityDescription( + key="floodlight_schedule_end", + cmd_key="GetWhiteLed", + cmd_id=[289, 438], + translation_key="floodlight_schedule_end", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + supported=lambda api, ch: ( + SpotlightModeEnum.schedule.name in api.whiteled_mode_list(ch) + ), + value=lambda api, ch: _schedule_time(api, ch, "End"), + method=_set_end, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ReolinkConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Reolink time entities.""" + reolink_data = config_entry.runtime_data + api = reolink_data.host.api + + async_add_entities( + ReolinkTimeEntity(reolink_data, channel, entity_description) + for entity_description in TIME_ENTITIES + for channel in api.channels + if entity_description.supported(api, channel) + ) + + +class ReolinkTimeEntity(ReolinkChannelCoordinatorEntity, TimeEntity): + """Base time entity class for Reolink IP cameras.""" + + entity_description: ReolinkTimeEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + entity_description: ReolinkTimeEntityDescription, + ) -> None: + """Initialize Reolink time entity.""" + self.entity_description = entity_description + super().__init__(reolink_data, channel) + + @property + @override + def native_value(self) -> time | None: + """Return the current value.""" + return self.entity_description.value(self._host.api, self._channel) + + @raise_translated_error + @override + async def async_set_value(self, value: time) -> None: + """Update the current value.""" + await self.entity_description.method(self._host.api, self._channel, value) + self.async_write_ha_state() diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index ada6df9a8469..92ae0d7500c4 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -153,10 +153,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> def _is_device_disabled( device_registry: dr.DeviceRegistry, + entry: RoborockConfigEntry, device: RoborockDevice, ) -> bool: """Check if a device is disabled in the device registry.""" - device_entry = device_registry.async_get_device(identifiers={(DOMAIN, device.duid)}) + device_entry = device_registry.async_get_device_by_identifier( + (DOMAIN, device.duid), entry.entry_id + ) return device_entry is not None and device_entry.disabled @@ -222,7 +225,7 @@ async def async_setup_device( config_entry_id=entry.entry_id, **get_device_info(device), ) - if _is_device_disabled(device_registry, device): + if _is_device_disabled(device_registry, entry, device): _LOGGER.debug("Device %s is disabled, skipping setup", device.duid) try: await device.close() diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index 41aa3420b591..4932f644e600 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -218,11 +218,13 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a flow started by a dhcp discovery.""" await self._async_handle_discovery_without_unique_id() device_registry = dr.async_get(self.hass) - device = device_registry.async_get_device( + devices = device_registry.async_get_devices( connections={(dr.CONNECTION_NETWORK_MAC, discovery_info.macaddress)} ) - if device is not None and any( - identifier[0] == DOMAIN for identifier in device.identifiers + if any( + identifier[0] == DOMAIN + for device in devices + for identifier in device.identifiers ): return self.async_abort(reason="already_configured") return await self.async_step_user() diff --git a/homeassistant/components/roon/event.py b/homeassistant/components/roon/event.py index 557aad9a350a..86c09e8d8a42 100644 --- a/homeassistant/components/roon/event.py +++ b/homeassistant/components/roon/event.py @@ -5,6 +5,7 @@ from typing import cast, override from homeassistant.components.event import EventDeviceClass, EventEntity from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -70,7 +71,11 @@ class RoonEventEntity(EventEntity): name=cast(str | None, self.name), manufacturer="RoonLabs", model=dev_model, - via_device=(DOMAIN, self._entry_id), + via_device_id=dr.async_get_device_id_by_identifier( + self._server.hass, + (DOMAIN, self._entry_id), + config_entry_id=self._entry_id, + ), ) def _roonapi_volume_callback( diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index ac749c0f3df6..21d24583fa15 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -15,6 +15,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -154,7 +155,9 @@ class RoonDevice(MediaPlayerEntity): name=cast(str | None, self.name), manufacturer="RoonLabs", model=dev_model, - via_device=(DOMAIN, self._entry_id), + via_device_id=dr.async_get_device_id_by_identifier( + self.hass, (DOMAIN, self._entry_id), config_entry_id=self._entry_id + ), ) def update_data(self, player_data=None): diff --git a/homeassistant/components/satel_integra/entity.py b/homeassistant/components/satel_integra/entity.py index 2ae55ab0ae50..dd32fda0db05 100644 --- a/homeassistant/components/satel_integra/entity.py +++ b/homeassistant/components/satel_integra/entity.py @@ -6,6 +6,7 @@ from satel_integra import AsyncSatel from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_NAME +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -60,7 +61,11 @@ class SatelIntegraEntity[_CoordinatorT: SatelIntegraBaseCoordinator]( self._attr_device_info = DeviceInfo( name=subentry.data[CONF_NAME], identifiers={(DOMAIN, self._attr_unique_id)}, - via_device=(DOMAIN, config_entry_id), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, config_entry_id), + config_entry_id=config_entry_id, + ), ) @property diff --git a/homeassistant/components/serial_pm/sensor.py b/homeassistant/components/serial_pm/sensor.py index 5cc49287d716..e7658cb394ba 100644 --- a/homeassistant/components/serial_pm/sensor.py +++ b/homeassistant/components/serial_pm/sensor.py @@ -94,7 +94,7 @@ class ParticulateMatterSensor(SensorEntity): @override def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" - return UnitOfDensity + return UnitOfDensity.MICROGRAMS_PER_CUBIC_METER def update(self) -> None: """Read from sensor and update the state.""" diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 886f55972967..2aae645c3cf4 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -123,7 +123,12 @@ class RpcBluTrvBinarySensor(RpcBinarySensor): ble_addr: str = coordinator.device.config[key]["addr"] fw_ver = coordinator.device.status[key].get("fw_ver") self._attr_device_info = get_blu_trv_device_info( - coordinator.device.config[key], ble_addr, coordinator.mac, fw_ver + coordinator.hass, + coordinator.config_entry.entry_id, + coordinator.device.config[key], + ble_addr, + coordinator.mac, + fw_ver, ) diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index 94c3e4ce26ef..4320e3098bb2 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -283,7 +283,12 @@ class ShellyBluTrvButton(ShellyRpcAttributeEntity, ButtonEntity): self._attr_unique_id = f"{format_ble_addr(ble_addr)}-{key}-{attribute}" self._attr_device_info = get_blu_trv_device_info( - config, ble_addr, coordinator.mac, fw_ver + coordinator.hass, + coordinator.config_entry.entry_id, + config, + ble_addr, + coordinator.mac, + fw_ver, ) @rpc_call diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 0f488216eac7..422618df9179 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -815,7 +815,12 @@ class RpcBluTrvClimate(ShellyRpcEntity, ClimateEntity): self._attr_unique_id = f"{ble_addr}-{self.key}" fw_ver = coordinator.device.status[self.key].get("fw_ver") self._attr_device_info = get_blu_trv_device_info( - self._config, ble_addr, self.coordinator.mac, fw_ver + coordinator.hass, + coordinator.config_entry.entry_id, + self._config, + ble_addr, + self.coordinator.mac, + fw_ver, ) @property diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 58011294253a..e94d73f8cacc 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -729,6 +729,8 @@ def get_entity_block_device_info( ) -> DeviceInfo: """Get device info for block entities.""" return get_block_device_info( + coordinator.hass, + coordinator.config_entry.entry_id, coordinator.device, coordinator.mac, coordinator.configuration_url, @@ -746,6 +748,8 @@ def get_entity_rpc_device_info( ) -> DeviceInfo: """Get device info for RPC entities.""" return get_rpc_device_info( + coordinator.hass, + coordinator.config_entry.entry_id, coordinator.device, coordinator.mac, coordinator.configuration_url, diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 38f72dccfec6..9344c38e11e7 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -155,7 +155,12 @@ class RpcBluTrvNumber(RpcNumber): ble_addr: str = coordinator.device.config[key]["addr"] fw_ver = coordinator.device.status[key].get("fw_ver") self._attr_device_info = get_blu_trv_device_info( - coordinator.device.config[key], ble_addr, coordinator.mac, fw_ver + coordinator.hass, + coordinator.config_entry.entry_id, + coordinator.device.config[key], + ble_addr, + coordinator.mac, + fw_ver, ) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 53cf5b9f618f..e456061c0277 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -195,7 +195,12 @@ class RpcBluTrvSensor(RpcSensor): ble_addr: str = coordinator.device.config[key]["addr"] fw_ver = coordinator.device.status[key].get("fw_ver") self._attr_device_info = get_blu_trv_device_info( - coordinator.device.config[key], ble_addr, coordinator.mac, fw_ver + coordinator.hass, + coordinator.config_entry.entry_id, + coordinator.device.config[key], + ble_addr, + coordinator.mac, + fw_ver, ) diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 1f932ea7a959..906e59a1bf51 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -785,6 +785,8 @@ def get_irrigation_zone_id(device: RpcDevice, key: str) -> int | None: def get_rpc_device_info( + hass: HomeAssistant, + config_entry_id: str, device: RpcDevice, mac: str, configuration_url: str, @@ -809,7 +811,9 @@ def get_rpc_device_info( model=model_name, model_id=model, suggested_area=suggested_area, - via_device=(DOMAIN, mac), + via_device_id=dr.async_get_device_id_by_identifier( + hass, (DOMAIN, mac), config_entry_id=config_entry_id + ), configuration_url=configuration_url, ) @@ -830,20 +834,29 @@ def get_rpc_device_info( model=model_name, model_id=model, suggested_area=suggested_area, - via_device=(DOMAIN, mac), + via_device_id=dr.async_get_device_id_by_identifier( + hass, (DOMAIN, mac), config_entry_id=config_entry_id + ), configuration_url=configuration_url, ) def get_blu_trv_device_info( - config: dict[str, Any], ble_addr: str, parent_mac: str, fw_ver: str | None + hass: HomeAssistant, + config_entry_id: str, + config: dict[str, Any], + ble_addr: str, + parent_mac: str, + fw_ver: str | None, ) -> DeviceInfo: """Return device info for RPC device.""" model_id = config.get("local_name") return DeviceInfo( connections={(CONNECTION_BLUETOOTH, ble_addr)}, identifiers={(DOMAIN, ble_addr)}, - via_device=(DOMAIN, parent_mac), + via_device_id=dr.async_get_device_id_by_identifier( + hass, (DOMAIN, parent_mac), config_entry_id=config_entry_id + ), manufacturer="Shelly", model=BLU_TRV_MODEL_NAME.get(model_id) if model_id else None, model_id=config.get("local_name"), @@ -862,6 +875,8 @@ def is_block_single_device(device: BlockDevice, block: Block | None = None) -> b def get_block_device_info( + hass: HomeAssistant, + config_entry_id: str, device: BlockDevice, mac: str, configuration_url: str, @@ -886,7 +901,9 @@ def get_block_device_info( model=model_name, model_id=model, suggested_area=suggested_area, - via_device=(DOMAIN, mac), + via_device_id=dr.async_get_device_id_by_identifier( + hass, (DOMAIN, mac), config_entry_id=config_entry_id + ), configuration_url=configuration_url, ) diff --git a/homeassistant/components/simplisafe/entity.py b/homeassistant/components/simplisafe/entity.py index a41e1e2b50ad..5984d453bb81 100644 --- a/homeassistant/components/simplisafe/entity.py +++ b/homeassistant/components/simplisafe/entity.py @@ -17,6 +17,7 @@ from simplipy.websocket import ( ) from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -101,7 +102,11 @@ class SimpliSafeEntity(CoordinatorEntity[SimpliSafeDataUpdateCoordinator]): manufacturer="SimpliSafe", model=model, name=device_name, - via_device=(DOMAIN, str(system.system_id)), + via_device_id=dr.async_get_device_id_by_identifier( + self.coordinator.hass, + (DOMAIN, str(system.system_id)), + config_entry_id=self.coordinator.config_entry.entry_id, + ), ) self._attr_unique_id = serial diff --git a/homeassistant/components/slack/manifest.json b/homeassistant/components/slack/manifest.json index 86c18e95f438..5daf664b30d2 100644 --- a/homeassistant/components/slack/manifest.json +++ b/homeassistant/components/slack/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_push", "loggers": ["slack"], - "requirements": ["slack_sdk==3.33.4", "aiofiles==24.1.0"] + "requirements": ["slack_sdk==3.33.4", "aiofiles==25.1.0"] } diff --git a/homeassistant/components/solarlog/__init__.py b/homeassistant/components/solarlog/__init__.py index f3f971360f9f..f95025566501 100644 --- a/homeassistant/components/solarlog/__init__.py +++ b/homeassistant/components/solarlog/__init__.py @@ -8,10 +8,10 @@ from solarlog_cli.solarlog_connector import SolarLogConnector from homeassistant.const import CONF_HOST, CONF_TIMEOUT, Platform 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 homeassistant.helpers.aiohttp_client import async_create_clientsession -from .const import CONF_HAS_PWD, DEFAULT_TIMEOUT +from .const import CONF_HAS_PWD, DEFAULT_TIMEOUT, DOMAIN from .coordinator import ( SolarLogBasicDataCoordinator, SolarlogConfigEntry, @@ -77,6 +77,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: SolarlogConfigEntry) -> entry.runtime_data.device_data_coordinator = device_coordinator await device_coordinator.async_config_entry_first_refresh() + # Register the controller device so inverter entities can resolve it as + # their via_device parent when they are added. + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer="Solar-Log", + model="Controller", + name="SolarLog", + configuration_url=solarlog.host, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/solarlog/entity.py b/homeassistant/components/solarlog/entity.py index e0ed33a70579..3ae9d8ccf258 100644 --- a/homeassistant/components/solarlog/entity.py +++ b/homeassistant/components/solarlog/entity.py @@ -1,6 +1,7 @@ """Entities for SolarLog integration.""" from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify @@ -57,8 +58,12 @@ class SolarLogInverterEntity(CoordinatorEntity[SolarLogDeviceDataCoordinator]): manufacturer="Solar-Log", model="Inverter", identifiers={(DOMAIN, name)}, - name=coordinator.solarlog.device_name(device_id), - via_device=(DOMAIN, coordinator.config_entry.entry_id), + name=device_name, + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, coordinator.config_entry.entry_id), + config_entry_id=coordinator.config_entry.entry_id, + ), ) self.device_id = device_id self.entity_description = description diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 7594061a997f..de9f0b6b14e0 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -579,8 +579,8 @@ class SonosDiscoveryManager: def is_device_disabled(self, uid: str) -> bool: """Check if the Sonos device is disabled in the device registry.""" if not ( - device := dr.async_get(self.hass).async_get_device( - identifiers={(DOMAIN, uid)} + device := dr.async_get(self.hass).async_get_device_by_identifier( + (DOMAIN, uid), self.entry.entry_id ) ): return False diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index f0e91f6ff8de..bf28dc055520 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -162,7 +162,7 @@ async def async_setup_entry( model_id=model_id, hw_version=str(player.firmware) if player.firmware is not None else None, sw_version=sw_version, - via_device=(DOMAIN, coordinator.server_uuid), + via_device_id=server_device.id if server_device else None, ) _LOGGER.debug("Creating / Updating player device %s", device) async_add_entities([SqueezeBoxMediaPlayerEntity(coordinator)]) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 2ab055702135..7cbfa0bd0d0c 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -93,10 +93,12 @@ async def coordinator_for_device( manageable_by_webhook: bool = False, ) -> SwitchBotCoordinator: """Instantiate coordinator and adds to list for gathering.""" - coordinator = coordinators_by_id.setdefault( - device.device_id, - SwitchBotCoordinator(hass, entry, api, device, manageable_by_webhook), - ) + coordinator = coordinators_by_id.get(device.device_id) + if coordinator is None: + coordinator = SwitchBotCoordinator( + hass, entry, api, device, manageable_by_webhook + ) + coordinators_by_id[device.device_id] = coordinator if coordinator.data is None: await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index e0d0668f5586..2658325a8811 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -134,6 +134,39 @@ async def async_setup_entry(hass: HomeAssistant, entry: SynologyDSMConfigEntry) coordinator_cameras=coordinator_cameras, coordinator_switches=coordinator_switches, ) + + # Register parent devices before forwarding platform setups so that child + # devices (storage/USB devices, surveillance station, cameras) can resolve + # their via_device_id regardless of platform setup order. + if TYPE_CHECKING: + assert api.information is not None + assert api.network is not None + central_device = dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, api.information.serial)}, + connections={(dr.CONNECTION_NETWORK_MAC, mac) for mac in api.network.macs}, + name=api.network.hostname, + manufacturer="Synology", + model=api.information.model, + sw_version=api.information.version_string, + configuration_url=api.config_url, + ) + if api.surveillance_station is not None: + dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={ + ( + DOMAIN, + f"{api.information.serial}_{SynoSurveillanceStation.INFO_API_KEY}", + ) + }, + name=f"{api.network.hostname} Surveillance Station", + manufacturer="Synology", + model=api.information.model, + sw_version=coordinator_switches.version if coordinator_switches else None, + via_device_id=central_device.id, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) if entry.options[CONF_BACKUP_SHARE]: diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index 084b72e8e90a..fffb2032b6a2 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -16,6 +16,7 @@ from homeassistant.components.camera import ( CameraEntityFeature, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -100,9 +101,13 @@ class SynoDSMCamera(SynologyDSMBaseEntity[SynologyDSMCameraUpdateCoordinator], C identifiers={(DOMAIN, f"{information.serial}_{self.camera_data.id}")}, name=self.camera_data.name, model=self.camera_data.model, - via_device=( - DOMAIN, - f"{information.serial}_{SynoSurveillanceStation.INFO_API_KEY}", + via_device_id=dr.async_get_device_id_by_identifier( + self.hass, + ( + DOMAIN, + f"{information.serial}_{SynoSurveillanceStation.INFO_API_KEY}", + ), + config_entry_id=self.coordinator.config_entry.entry_id, ), ) diff --git a/homeassistant/components/synology_dsm/entity.py b/homeassistant/components/synology_dsm/entity.py index 6fc74f630483..c92196574c87 100644 --- a/homeassistant/components/synology_dsm/entity.py +++ b/homeassistant/components/synology_dsm/entity.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, override +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -158,6 +159,10 @@ class SynologyDSMDeviceEntity( manufacturer=self._device_manufacturer, model=self._device_model, sw_version=self._device_firmware, - via_device=(DOMAIN, information.serial), + via_device_id=dr.async_get_device_id_by_identifier( + self.coordinator.hass, + (DOMAIN, information.serial), + config_entry_id=self.coordinator.config_entry.entry_id, + ), configuration_url=self._api.config_url, ) diff --git a/homeassistant/components/synology_dsm/switch.py b/homeassistant/components/synology_dsm/switch.py index b0359e9b90f5..a14a392be408 100644 --- a/homeassistant/components/synology_dsm/switch.py +++ b/homeassistant/components/synology_dsm/switch.py @@ -8,6 +8,7 @@ from synology_dsm.api.surveillance_station import SynoSurveillanceStation from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -128,5 +129,9 @@ class SynoDSMSurveillanceHomeModeToggle( manufacturer="Synology", model=self._api.information.model, sw_version=self._version, - via_device=(DOMAIN, self._api.information.serial), + via_device_id=dr.async_get_device_id_by_identifier( + self.hass, + (DOMAIN, self._api.information.serial), + config_entry_id=self.coordinator.config_entry.entry_id, + ), ) diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py index cc55876194e2..9be21aab8867 100644 --- a/homeassistant/components/tado/coordinator.py +++ b/homeassistant/components/tado/coordinator.py @@ -448,7 +448,7 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def set_meter_reading(self, reading: int) -> dict[str, Any]: """Send meter reading to Tado.""" - dt: str = datetime.now().strftime("%Y-%m-%d") # pylint: disable=home-assistant-enforce-naive-now + dt: str = dt_util.now().strftime("%Y-%m-%d") if self._tado is None: raise HomeAssistantError("Tado client is not initialized") diff --git a/homeassistant/components/tailwind/coordinator.py b/homeassistant/components/tailwind/coordinator.py index 1fd490d2ed4c..91677f4d6b0b 100644 --- a/homeassistant/components/tailwind/coordinator.py +++ b/homeassistant/components/tailwind/coordinator.py @@ -26,6 +26,8 @@ type TailwindConfigEntry = ConfigEntry[TailwindDataUpdateCoordinator] class TailwindDataUpdateCoordinator(DataUpdateCoordinator[TailwindDeviceStatus]): """Class to manage fetching Tailwind data.""" + config_entry: TailwindConfigEntry + def __init__(self, hass: HomeAssistant, entry: TailwindConfigEntry) -> None: """Initialize the coordinator.""" self.tailwind = Tailwind( diff --git a/homeassistant/components/tailwind/entity.py b/homeassistant/components/tailwind/entity.py index d3c148392d9e..c854e1e16835 100644 --- a/homeassistant/components/tailwind/entity.py +++ b/homeassistant/components/tailwind/entity.py @@ -1,5 +1,6 @@ """Base entity for the Tailwind integration.""" +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -55,7 +56,11 @@ class TailwindDoorEntity(CoordinatorEntity[TailwindDataUpdateCoordinator]): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{coordinator.data.device_id}-{door_id}")}, - via_device=(DOMAIN, coordinator.data.device_id), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, coordinator.data.device_id), + config_entry_id=coordinator.config_entry.entry_id, + ), name=f"Door {coordinator.data.doors[door_id].index + 1}", manufacturer="Tailwind", model=coordinator.data.product, diff --git a/homeassistant/components/tedee/entity.py b/homeassistant/components/tedee/entity.py index 9f2f7b500aa3..f807523a214a 100644 --- a/homeassistant/components/tedee/entity.py +++ b/homeassistant/components/tedee/entity.py @@ -5,6 +5,7 @@ from typing import override from aiotedee.models import TedeeLock from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -35,7 +36,11 @@ class TedeeEntity(CoordinatorEntity[TedeeApiCoordinator]): manufacturer="Tedee", model=lock.type_name, model_id=lock.type_name, - via_device=(DOMAIN, coordinator.bridge.serial), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, coordinator.bridge.serial), + config_entry_id=coordinator.config_entry.entry_id, + ), ) @property diff --git a/homeassistant/components/tesla_fleet/entity.py b/homeassistant/components/tesla_fleet/entity.py index a09be9ea62b1..a9acc3e21956 100644 --- a/homeassistant/components/tesla_fleet/entity.py +++ b/homeassistant/components/tesla_fleet/entity.py @@ -8,6 +8,7 @@ from tesla_fleet_api.tesla.energysite import EnergySite from tesla_fleet_api.tesla.vehicle.fleet import VehicleFleet from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -205,7 +206,11 @@ class TeslaFleetWallConnectorEntity( identifiers={(DOMAIN, din)}, manufacturer="Tesla", name="Wall Connector", - via_device=(DOMAIN, str(data.id)), + via_device_id=dr.async_get_device_id_by_identifier( + data.live_coordinator.hass, + (DOMAIN, str(data.id)), + config_entry_id=data.live_coordinator.config_entry.entry_id, + ), serial_number=din.rsplit("-", maxsplit=1)[-1], model=model, ) diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index e64af7b6e078..f0d1f671ae37 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -139,6 +139,16 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + TeslemetryBinarySensorEntityDescription( + key="climate_state_is_rear_defroster_on", + polling=True, + streaming_listener=lambda vehicle, callback: vehicle.listen_RearDefrostEnabled( + callback + ), + device_class=BinarySensorDeviceClass.HEAT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_dashcam_state", polling=True, diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index fa80e55ddb6f..717229394816 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -1,6 +1,6 @@ """Teslemetry Data Coordinator.""" -from datetime import datetime, timedelta +from datetime import timedelta from typing import TYPE_CHECKING, Any, override from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint @@ -113,7 +113,6 @@ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching data from the Teslemetry API.""" config_entry: TeslemetryConfigEntry - last_active: datetime def __init__( self, @@ -135,7 +134,6 @@ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.api = api self.data = flatten(product) - self.last_active = datetime.now() # pylint: disable=home-assistant-enforce-naive-now @override async def _async_update_data(self) -> dict[str, Any]: diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py index 548634b8b9f8..48a6de94b984 100644 --- a/homeassistant/components/teslemetry/entity.py +++ b/homeassistant/components/teslemetry/entity.py @@ -7,6 +7,7 @@ from tesla_fleet_api.const import Scope from tesla_fleet_api.teslemetry import EnergySite, Vehicle from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import StateType @@ -224,7 +225,11 @@ class TeslemetryWallConnectorEntity(TeslemetryPollingEntity): manufacturer="Tesla", configuration_url="https://teslemetry.com/console", name="Wall Connector", - via_device=(DOMAIN, str(data.id)), + via_device_id=dr.async_get_device_id_by_identifier( + data.live_coordinator.hass, + (DOMAIN, str(data.id)), + config_entry_id=data.live_coordinator.config_entry.entry_id, + ), serial_number=din.rsplit("-", maxsplit=1)[-1], model=model, ) diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 84957296f254..974171c5b47a 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -104,6 +104,9 @@ "climate_state_is_preconditioning": { "name": "Preconditioning" }, + "climate_state_is_rear_defroster_on": { + "name": "Rear defroster" + }, "components_grid_services_enabled": { "name": "Grid services enabled" }, diff --git a/homeassistant/components/togrill/coordinator.py b/homeassistant/components/togrill/coordinator.py index c68b9bda8f2b..3385c1f09635 100644 --- a/homeassistant/components/togrill/coordinator.py +++ b/homeassistant/components/togrill/coordinator.py @@ -107,7 +107,11 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Pack "probe_number": str(probe_number), }, identifiers={(DOMAIN, f"{self.address}_{probe_number}")}, - via_device=(DOMAIN, self.address), + via_device_id=dr.async_get_device_id_by_identifier( + self.hass, + (DOMAIN, self.address), + config_entry_id=self.config_entry.entry_id, + ), ) @callback diff --git a/homeassistant/components/togrill/number.py b/homeassistant/components/togrill/number.py index 0162c93d164b..0bfdc47aa1ff 100644 --- a/homeassistant/components/togrill/number.py +++ b/homeassistant/components/togrill/number.py @@ -2,12 +2,14 @@ from collections.abc import Callable, Generator, Mapping from dataclasses import dataclass +from datetime import timedelta from typing import Any, override from togrill_bluetooth.packets import ( AlarmType, PacketA0Notify, PacketA6Write, + PacketA7Write, PacketA8Notify, PacketA300Write, PacketA301Write, @@ -121,6 +123,37 @@ def _get_temperature_descriptions( ) +def _get_timer_description(probe_number: int) -> ToGrillNumberEntityDescription: + def _get_timer(coordinator: ToGrillCoordinator) -> float | None: + if not (packet := coordinator.get_packet(PacketA8Notify, probe_number)): + return None + return packet.time.total_seconds() / 60 + + def _set_timer(coordinator: ToGrillCoordinator, value: float) -> PacketWrite: + return PacketA7Write( + probe=probe_number, + time=timedelta(minutes=value), + unknown=1 if value else 0, + ) + + return ToGrillNumberEntityDescription( + key=f"timer_{probe_number}", + translation_key="timer", + translation_placeholders={"probe_number": f"{probe_number}"}, + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + native_min_value=0, + native_max_value=720, + native_step=1, + mode=NumberMode.BOX, + icon="mdi:timer-outline", + set_packet=_set_timer, + get_value=_get_timer, + entity_supported=lambda x: probe_number <= x[CONF_PROBE_COUNT], + probe_number=probe_number, + ) + + def _get_ambient_temperatures( coordinator: ToGrillCoordinator, alarm_type: AlarmType ) -> tuple[float | None, float | None]: @@ -137,6 +170,10 @@ ENTITY_DESCRIPTIONS = ( for probe_number in range(1, MAX_PROBE_COUNT + 1) for description in _get_temperature_descriptions(probe_number) ], + *[ + _get_timer_description(probe_number) + for probe_number in range(1, MAX_PROBE_COUNT + 1) + ], ToGrillNumberEntityDescription( key="ambient_temperature_minimum", translation_key="ambient_temperature_minimum", diff --git a/homeassistant/components/togrill/strings.json b/homeassistant/components/togrill/strings.json index 41b5c036b0ec..4f3b404c4e0d 100644 --- a/homeassistant/components/togrill/strings.json +++ b/homeassistant/components/togrill/strings.json @@ -69,6 +69,9 @@ }, "temperature_target": { "name": "Target temperature" + }, + "timer": { + "name": "Timer" } }, "select": { diff --git a/homeassistant/components/touchline_sl/entity.py b/homeassistant/components/touchline_sl/entity.py index 42441420a5f4..85205eb640e7 100644 --- a/homeassistant/components/touchline_sl/entity.py +++ b/homeassistant/components/touchline_sl/entity.py @@ -4,6 +4,7 @@ from typing import override from pytouchlinesl import Zone +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -24,7 +25,11 @@ class TouchlineSLZoneEntity(CoordinatorEntity[TouchlineSLModuleCoordinator]): identifiers={(DOMAIN, f"{coordinator.data.module.id}-{zone_id}")}, name=self.zone.name, manufacturer="Roth", - via_device=(DOMAIN, coordinator.data.module.id), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, coordinator.data.module.id), + config_entry_id=coordinator.config_entry.entry_id, + ), model="zone", suggested_area=self.zone.name, ) diff --git a/homeassistant/components/tradfri/entity.py b/homeassistant/components/tradfri/entity.py index 8b966ca8d124..9792d77a514d 100644 --- a/homeassistant/components/tradfri/entity.py +++ b/homeassistant/components/tradfri/entity.py @@ -10,6 +10,7 @@ from pytradfri.device import Device from pytradfri.error import RequestError from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -61,7 +62,11 @@ class TradfriBaseEntity(CoordinatorEntity[TradfriDeviceDataUpdateCoordinator]): model=info.model_number, name=self._device.name, sw_version=info.firmware_version, - via_device=(DOMAIN, gateway_id), + via_device_id=dr.async_get_device_id_by_identifier( + device_coordinator.hass, + (DOMAIN, gateway_id), + config_entry_id=device_coordinator.config_entry.entry_id, + ), ) self._attr_unique_id = f"{gateway_id}-{self._device_id}" diff --git a/homeassistant/components/trane/climate.py b/homeassistant/components/trane/climate.py index 19fbfa7f2115..093e53e97736 100644 --- a/homeassistant/components/trane/climate.py +++ b/homeassistant/components/trane/climate.py @@ -55,7 +55,7 @@ async def async_setup_entry( """Set up Trane Local climate entities.""" conn = config_entry.runtime_data async_add_entities( - TraneClimateEntity(conn, config_entry.entry_id, zone_id) + TraneClimateEntity(hass, conn, config_entry.entry_id, zone_id) for zone_id in conn.state.zones ) @@ -76,9 +76,15 @@ class TraneClimateEntity(TraneZoneEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT _attr_target_temperature_step = 1.0 - def __init__(self, conn: ThermostatConnection, entry_id: str, zone_id: str) -> None: + def __init__( + self, + hass: HomeAssistant, + conn: ThermostatConnection, + entry_id: str, + zone_id: str, + ) -> None: """Initialize the climate entity.""" - super().__init__(conn, entry_id, zone_id, "zone") + super().__init__(hass, conn, entry_id, zone_id, "zone") modes: list[HVACMode] = [] for zone_mode in conn.state.supported_modes: ha_mode = ZONE_MODE_TO_HA.get(zone_mode) diff --git a/homeassistant/components/trane/entity.py b/homeassistant/components/trane/entity.py index 830922f0a005..69fb81d5900d 100644 --- a/homeassistant/components/trane/entity.py +++ b/homeassistant/components/trane/entity.py @@ -4,7 +4,8 @@ from typing import Any, override from steamloop import ThermostatConnection, Zone -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -37,6 +38,7 @@ class TraneZoneEntity(TraneEntity): def __init__( self, + hass: HomeAssistant, conn: ThermostatConnection, entry_id: str, zone_id: str, @@ -52,7 +54,9 @@ class TraneZoneEntity(TraneEntity): manufacturer=MANUFACTURER, name=zone_name, suggested_area=zone_name, - via_device=(DOMAIN, entry_id), + via_device_id=dr.async_get_device_id_by_identifier( + hass, (DOMAIN, entry_id), config_entry_id=entry_id + ), ) @property diff --git a/homeassistant/components/trane/switch.py b/homeassistant/components/trane/switch.py index 008828f02910..b70f0ff1e9b3 100644 --- a/homeassistant/components/trane/switch.py +++ b/homeassistant/components/trane/switch.py @@ -22,7 +22,7 @@ async def async_setup_entry( """Set up Trane Local switch entities.""" conn = config_entry.runtime_data async_add_entities( - TraneHoldSwitch(conn, config_entry.entry_id, zone_id) + TraneHoldSwitch(hass, conn, config_entry.entry_id, zone_id) for zone_id in conn.state.zones ) @@ -32,9 +32,15 @@ class TraneHoldSwitch(TraneZoneEntity, SwitchEntity): _attr_translation_key = "hold" - def __init__(self, conn: ThermostatConnection, entry_id: str, zone_id: str) -> None: + def __init__( + self, + hass: HomeAssistant, + conn: ThermostatConnection, + entry_id: str, + zone_id: str, + ) -> None: """Initialize the hold switch.""" - super().__init__(conn, entry_id, zone_id, "hold") + super().__init__(hass, conn, entry_id, zone_id, "hold") @property @override diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 6b01d1ae7d77..2b3d1d9ea7b5 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -116,22 +116,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: UpnpConfigEntry) -> bool }, ) - identifiers = {(DOMAIN, device.usn)} + identifiers = [(DOMAIN, device.usn)] if device.host: - identifiers.add((IDENTIFIER_HOST, device.host)) + identifiers.append((IDENTIFIER_HOST, device.host)) if device.serial_number: - identifiers.add((IDENTIFIER_SERIAL_NUMBER, device.serial_number)) + identifiers.append((IDENTIFIER_SERIAL_NUMBER, device.serial_number)) - connections = {(dr.CONNECTION_UPNP, discovery_info.ssdp_udn)} + connections = [(dr.CONNECTION_UPNP, discovery_info.ssdp_udn)] if discovery_info.ssdp_udn != device.udn: - connections.add((dr.CONNECTION_UPNP, device.udn)) + connections.append((dr.CONNECTION_UPNP, device.udn)) if device_mac_address: - connections.add((dr.CONNECTION_NETWORK_MAC, device_mac_address)) + connections.append((dr.CONNECTION_NETWORK_MAC, device_mac_address)) dev_registry = dr.async_get(hass) - device_entry = dev_registry.async_get_device( - identifiers=identifiers, connections=connections - ) + device_entry = None + for identifier in identifiers: + if device_entry := dev_registry.async_get_device_by_identifier( + identifier, entry.entry_id + ): + break + if device_entry is None: + for connection in connections: + if device_entry := dev_registry.async_get_device_by_connection( + connection, entry.entry_id + ): + break if device_entry: LOGGER.debug( "Found device using connections: %s, device_entry: %s", @@ -142,8 +151,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: UpnpConfigEntry) -> bool # No device found, create new device entry. device_entry = dev_registry.async_get_or_create( config_entry_id=entry.entry_id, - connections=connections, - identifiers=identifiers, + connections=set(connections), + identifiers=set(identifiers), name=device.name, manufacturer=device.manufacturer, model=device.model_name, @@ -155,7 +164,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: UpnpConfigEntry) -> bool # Update identifier. device_entry = dev_registry.async_update_device( device_entry.id, - new_identifiers=identifiers, + new_identifiers=set(identifiers), ) assert device_entry diff --git a/homeassistant/components/uptime_kuma/const.py b/homeassistant/components/uptime_kuma/const.py index 990f8899e6da..8cc39c130146 100644 --- a/homeassistant/components/uptime_kuma/const.py +++ b/homeassistant/components/uptime_kuma/const.py @@ -18,6 +18,7 @@ HAS_PORT = { MonitorType.RADIUS, MonitorType.SNMP, MonitorType.SMTP, + MonitorType.NTP, } HAS_HOST = HAS_PORT | { MonitorType.PING, diff --git a/homeassistant/components/uptime_kuma/manifest.json b/homeassistant/components/uptime_kuma/manifest.json index 670b77bb5af9..d693f3122689 100644 --- a/homeassistant/components/uptime_kuma/manifest.json +++ b/homeassistant/components/uptime_kuma/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["pythonkuma"], "quality_scale": "platinum", - "requirements": ["pythonkuma==0.5.1"] + "requirements": ["pythonkuma==0.5.2"] } diff --git a/homeassistant/components/uptime_kuma/strings.json b/homeassistant/components/uptime_kuma/strings.json index b9020d13ce81..c8a049364756 100644 --- a/homeassistant/components/uptime_kuma/strings.json +++ b/homeassistant/components/uptime_kuma/strings.json @@ -118,8 +118,10 @@ "mongodb": "MongoDB", "mqtt": "MQTT", "mysql": "MySQL/MariaDB", + "ntp": "NTP", "oracledb": "Oracle Database", "ping": "Ping", + "pm2": "PM2 Process", "port": "TCP port", "postgres": "PostgreSQL", "push": "Push", diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index bf05be04a6f8..bb256ac0ffff 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -35,7 +35,6 @@ from .const import ( CONF_APPS_TO_INCLUDE_OR_EXCLUDE, CONF_INCLUDE_OR_EXCLUDE, CONF_VOLUME_STEP, - DEFAULT_DEVICE_CLASS, DEFAULT_NAME, DEFAULT_VOLUME_STEP, DEVICE_ID, @@ -64,14 +63,6 @@ def _get_config_schema(input_dict: dict[str, Any] | None = None) -> vol.Schema: CONF_NAME, default=input_dict.get(CONF_NAME, DEFAULT_NAME) ): str, vol.Required(CONF_HOST, default=input_dict.get(CONF_HOST)): str, - vol.Required( - CONF_DEVICE_CLASS, - default=input_dict.get(CONF_DEVICE_CLASS, DEFAULT_DEVICE_CLASS), - ): vol.All( - str, - vol.Lower, - vol.In([MediaPlayerDeviceClass.TV, MediaPlayerDeviceClass.SPEAKER]), - ), vol.Optional( CONF_ACCESS_TOKEN, default=input_dict.get(CONF_ACCESS_TOKEN, "") ): str, @@ -109,6 +100,17 @@ def _get_device( ) +async def _async_detect_device_class( + hass: HomeAssistant, host: str +) -> MediaPlayerDeviceClass: + """Detect whether the device at host is a TV or a speaker.""" + return ( + MediaPlayerDeviceClass.TV + if await async_is_tv(host, session=async_get_clientsession(hass, False)) + else MediaPlayerDeviceClass.SPEAKER + ) + + async def _async_get_unique_id( hass: HomeAssistant, host: str, device_class: str ) -> str | None: @@ -248,6 +250,11 @@ class VizioConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: # Store current values in case setup fails and user needs to edit self._user_schema = _get_config_schema(user_input) + # Zeroconf discovery provides the device class; detect it otherwise + if CONF_DEVICE_CLASS not in user_input: + user_input[CONF_DEVICE_CLASS] = await _async_detect_device_class( + self.hass, user_input[CONF_HOST] + ) if self.unique_id is None: unique_id = await _async_get_unique_id( self.hass, user_input[CONF_HOST], user_input[CONF_DEVICE_CLASS] @@ -308,11 +315,7 @@ class VizioConfigFlow(ConfigFlow, domain=DOMAIN): num_chars_to_strip = len(discovery_info.type) + 1 name = discovery_info.name[:-num_chars_to_strip] - device_class = ( - MediaPlayerDeviceClass.TV - if await async_is_tv(host) - else MediaPlayerDeviceClass.SPEAKER - ) + device_class = await _async_detect_device_class(self.hass, host) # Set unique ID early for discovery flow so we can abort if needed unique_id = await _async_get_unique_id(self.hass, host, device_class) diff --git a/homeassistant/components/vizio/const.py b/homeassistant/components/vizio/const.py index 101d6e6d9195..06838a8a04a9 100644 --- a/homeassistant/components/vizio/const.py +++ b/homeassistant/components/vizio/const.py @@ -17,7 +17,6 @@ CONF_NAME_SPACE = "NAME_SPACE" CONF_MESSAGE = "MESSAGE" CONF_VOLUME_STEP = "volume_step" -DEFAULT_DEVICE_CLASS = MediaPlayerDeviceClass.TV DEFAULT_NAME = "Vizio SmartCast" DEFAULT_TIMEOUT = 8 DEFAULT_VOLUME_STEP = 1 diff --git a/homeassistant/components/vizio/entity.py b/homeassistant/components/vizio/entity.py new file mode 100644 index 000000000000..e6cea16fe6e4 --- /dev/null +++ b/homeassistant/components/vizio/entity.py @@ -0,0 +1,26 @@ +"""Base entity for Vizio SmartCast devices.""" + +from typing import TYPE_CHECKING + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import VizioConfigEntry, VizioDeviceCoordinator + + +class VizioEntity(CoordinatorEntity[VizioDeviceCoordinator]): + """Base class for Vizio SmartCast entities.""" + + _attr_has_entity_name = True + + def __init__(self, config_entry: VizioConfigEntry) -> None: + """Initialize the Vizio entity.""" + coordinator = config_entry.runtime_data.device_coordinator + super().__init__(coordinator) + self._attr_unique_id = unique_id = config_entry.unique_id + # Guard against config entries missing unique_id, which should never happen + if TYPE_CHECKING: + assert unique_id is not None + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, unique_id)}) + self._device = coordinator.device diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index c75e9d51a638..1196617ad261 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -19,9 +19,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_DEVICE_CLASS, CONF_EXCLUDE, CONF_INCLUDE from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import DATA_APPS from .const import ( @@ -33,7 +31,6 @@ from .const import ( CONF_NAME_SPACE, CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP, - DOMAIN, SUPPORTED_COMMANDS, VIZIO_AUDIO_SETTINGS, VIZIO_MUTE, @@ -41,11 +38,8 @@ from .const import ( VIZIO_SOUND_MODE, VIZIO_VOLUME, ) -from .coordinator import ( - VizioAppsDataUpdateCoordinator, - VizioConfigEntry, - VizioDeviceCoordinator, -) +from .coordinator import VizioAppsDataUpdateCoordinator, VizioConfigEntry +from .entity import VizioEntity from .helpers import async_device_command PARALLEL_UPDATES = 0 @@ -98,7 +92,6 @@ async def async_setup_entry( entity = VizioDevice( config_entry, device_class, - config_entry.runtime_data.device_coordinator, hass.data.get(DATA_APPS) if device_class == MediaPlayerDeviceClass.TV else None, ) @@ -114,10 +107,9 @@ def _app_config_from_conf(config: dict[str, Any]) -> AppConfig: ) -class VizioDevice(CoordinatorEntity[VizioDeviceCoordinator], MediaPlayerEntity): +class VizioDevice(VizioEntity, MediaPlayerEntity): """Media Player implementation which performs REST requests to device.""" - _attr_has_entity_name = True _attr_name = None _current_input: str | None = None _current_app_config: AppConfig | None = None @@ -126,11 +118,10 @@ class VizioDevice(CoordinatorEntity[VizioDeviceCoordinator], MediaPlayerEntity): self, config_entry: VizioConfigEntry, device_class: MediaPlayerDeviceClass, - coordinator: VizioDeviceCoordinator, apps_coordinator: VizioAppsDataUpdateCoordinator | None, ) -> None: """Initialize Vizio device.""" - super().__init__(coordinator) + super().__init__(config_entry) self._config_entry = config_entry self._apps_coordinator = apps_coordinator @@ -142,7 +133,6 @@ class VizioDevice(CoordinatorEntity[VizioDeviceCoordinator], MediaPlayerEntity): self._additional_app_configs = config_entry.data.get(CONF_APPS, {}).get( CONF_ADDITIONAL_CONFIGS, [] ) - self._device = coordinator.device if apps_coordinator: self._device.set_app_catalog(apps_coordinator.data) self._device.set_app_availability(apps_coordinator.availability) @@ -151,13 +141,7 @@ class VizioDevice(CoordinatorEntity[VizioDeviceCoordinator], MediaPlayerEntity): # Entity class attributes that will change with each update (we only include # the ones that are initialized differently from the defaults) self._attr_supported_features = SUPPORTED_COMMANDS[device_class] - - # Entity class attributes that will not change - unique_id = config_entry.unique_id - assert unique_id - self._attr_unique_id = unique_id self._attr_device_class = device_class - self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, unique_id)}) @property def _volume_step(self) -> int: diff --git a/homeassistant/components/vizio/remote.py b/homeassistant/components/vizio/remote.py index 53e9d07d1cb5..dc784e15b8b8 100644 --- a/homeassistant/components/vizio/remote.py +++ b/homeassistant/components/vizio/remote.py @@ -2,7 +2,7 @@ import asyncio from collections.abc import Iterable -from typing import TYPE_CHECKING, Any, override +from typing import Any, override import voluptuous as vol @@ -14,12 +14,11 @@ from homeassistant.components.remote import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import VizioConfigEntry, VizioDeviceCoordinator +from .coordinator import VizioConfigEntry +from .entity import VizioEntity from .helpers import async_device_command PARALLEL_UPDATES = 0 @@ -60,21 +59,12 @@ async def async_setup_entry( async_add_entities([VizioRemote(config_entry)]) -class VizioRemote(CoordinatorEntity[VizioDeviceCoordinator], RemoteEntity): +class VizioRemote(VizioEntity, RemoteEntity): """Remote entity for Vizio SmartCast devices.""" - _attr_has_entity_name = True - def __init__(self, config_entry: VizioConfigEntry) -> None: """Initialize the remote entity.""" - coordinator = config_entry.runtime_data.device_coordinator - super().__init__(coordinator) - self._attr_unique_id = unique_id = config_entry.unique_id - # Guard against config entries missing unique_id, which should never happen - if TYPE_CHECKING: - assert unique_id is not None - self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, unique_id)}) - self._device = coordinator.device + super().__init__(config_entry) valid_keys = set(self._device.available_keys) # Map lowercased native keys to their original uppercase vizaio names self._command_map: dict[str, str] = {key.lower(): key for key in valid_keys} diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json index 585123809a67..05f6403aef64 100644 --- a/homeassistant/components/vizio/strings.json +++ b/homeassistant/components/vizio/strings.json @@ -29,7 +29,6 @@ "user": { "data": { "access_token": "[%key:common::config_flow::data::access_token%]", - "device_class": "Device type", "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" }, diff --git a/homeassistant/components/watts/const.py b/homeassistant/components/watts/const.py index e1ba4a0134e0..8002762467fa 100644 --- a/homeassistant/components/watts/const.py +++ b/homeassistant/components/watts/const.py @@ -23,7 +23,7 @@ OAUTH2_SCOPES = [ # Update intervals UPDATE_INTERVAL_SECONDS = 30 FAST_POLLING_INTERVAL_SECONDS = 5 -DISCOVERY_INTERVAL_MINUTES = 15 +DISCOVERY_INTERVAL_SECONDS = 15 * 60 # Mapping from Watts Vision+ modes to Home Assistant HVAC modes THERMOSTAT_MODE_TO_HVAC: dict[ThermostatMode, HVACMode] = { diff --git a/homeassistant/components/watts/coordinator.py b/homeassistant/components/watts/coordinator.py index 754e268d8c90..28982b63ec05 100644 --- a/homeassistant/components/watts/coordinator.py +++ b/homeassistant/components/watts/coordinator.py @@ -1,8 +1,9 @@ """Data coordinator for Watts Vision integration.""" from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import timedelta import logging +import time from typing import TYPE_CHECKING, override from visionpluspython.client import WattsVisionClient @@ -22,7 +23,7 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( - DISCOVERY_INTERVAL_MINUTES, + DISCOVERY_INTERVAL_SECONDS, DOMAIN, FAST_POLLING_INTERVAL_SECONDS, UPDATE_INTERVAL_SECONDS, @@ -61,18 +62,17 @@ class WattsVisionHubCoordinator(DataUpdateCoordinator[dict[str, Device]]): config_entry=config_entry, ) self.client = client - self.last_discovery: datetime | None = None + self.last_discovery: float | None = None self.previous_devices: set[str] = set() @override async def _async_update_data(self) -> dict[str, Device]: """Fetch data and periodic device discovery.""" - now = datetime.now() # pylint: disable=home-assistant-enforce-naive-now + now = time.time() is_first_refresh = self.last_discovery is None discovery_interval_elapsed = ( self.last_discovery is not None - and now - self.last_discovery - >= timedelta(minutes=DISCOVERY_INTERVAL_MINUTES) + and now - self.last_discovery >= DISCOVERY_INTERVAL_SECONDS ) if is_first_refresh or discovery_interval_elapsed: @@ -185,7 +185,7 @@ class WattsVisionDeviceCoordinator(DataUpdateCoordinator[WattsVisionDeviceData]) self.client = client self.device_id = device_id self.hub_coordinator = hub_coordinator - self.fast_polling_until: datetime | None = None + self.fast_polling_until: float | None = None # Listen to hub coordinator updates self.unsubscribe_hub_listener = hub_coordinator.async_add_listener( @@ -208,7 +208,7 @@ class WattsVisionDeviceCoordinator(DataUpdateCoordinator[WattsVisionDeviceData]) @override async def _async_update_data(self) -> WattsVisionDeviceData: """Refresh specific device.""" - if self.fast_polling_until and datetime.now() > self.fast_polling_until: # pylint: disable=home-assistant-enforce-naive-now + if self.fast_polling_until and time.time() > self.fast_polling_until: self.fast_polling_until = None self.update_interval = None _LOGGER.debug( @@ -244,10 +244,12 @@ class WattsVisionDeviceCoordinator(DataUpdateCoordinator[WattsVisionDeviceData]) _LOGGER.debug("Refreshed device %s", self.device_id) return WattsVisionDeviceData(device=device) - def trigger_fast_polling(self, duration: int = 60) -> None: + def trigger_fast_polling(self, duration_seconds: int = 60) -> None: """Activate fast polling for a specified duration after a command.""" - self.fast_polling_until = datetime.now() + timedelta(seconds=duration) # pylint: disable=home-assistant-enforce-naive-now + self.fast_polling_until = time.time() + duration_seconds self.update_interval = timedelta(seconds=FAST_POLLING_INTERVAL_SECONDS) _LOGGER.debug( - "Device %s: Activated fast polling for %d seconds", self.device_id, duration + "Device %s: Activated fast polling for %d seconds", + self.device_id, + duration_seconds, ) diff --git a/homeassistant/components/watts/diagnostics.py b/homeassistant/components/watts/diagnostics.py index ece46d9cf79a..53b94c54bf97 100644 --- a/homeassistant/components/watts/diagnostics.py +++ b/homeassistant/components/watts/diagnostics.py @@ -1,12 +1,13 @@ """Diagnostics support for Watts Vision +.""" import dataclasses -from datetime import datetime +import time from typing import Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from . import WattsVisionConfigEntry @@ -21,7 +22,7 @@ async def async_get_config_entry_diagnostics( runtime_data = entry.runtime_data hub_coordinator = runtime_data.hub_coordinator device_coordinators = runtime_data.device_coordinators - now = datetime.now() # pylint: disable=home-assistant-enforce-naive-now + now = time.time() return async_redact_data( { @@ -34,7 +35,9 @@ async def async_get_config_entry_diagnostics( else None ), "last_discovery": ( - hub_coordinator.last_discovery.isoformat() + dt_util.utc_from_timestamp( + hub_coordinator.last_discovery + ).isoformat() if hub_coordinator.last_discovery else None ), @@ -54,7 +57,9 @@ async def async_get_config_entry_diagnostics( and coordinator.fast_polling_until > now ), "fast_polling_until": ( - coordinator.fast_polling_until.isoformat() + dt_util.utc_from_timestamp( + coordinator.fast_polling_until + ).isoformat() if coordinator.fast_polling_until is not None and coordinator.fast_polling_until > now else None diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 25ca808b702e..57cf169382a3 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -160,11 +160,37 @@ class LgWebOSMediaPlayerEntity( def _update_states(self) -> None: """Update entity state attributes.""" tv_state = self._client.tv_state + + self._attr_extra_state_attributes = {} + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, cast(str, self.unique_id))}, + manufacturer="LG", + name=self._device_name, + ) + + if tv_state.is_on or not self._supported_features: + supported = SUPPORT_WEBOSTV + if tv_state.sound_output == "external_speaker": + supported = supported | SUPPORT_WEBOSTV_VOLUME + elif tv_state.sound_output != "lineout": + supported = ( + supported + | SUPPORT_WEBOSTV_VOLUME + | MediaPlayerEntityFeature.VOLUME_SET + ) + + self._supported_features = supported + + if not tv_state.is_on: + self._attr_state = MediaPlayerState.OFF + self._attr_assumed_state = False + return + + self._attr_state = MediaPlayerState.ON + self._update_sources() - self._attr_state = ( - MediaPlayerState.ON if tv_state.is_on else MediaPlayerState.OFF - ) self._attr_is_volume_muted = cast(bool, tv_state.muted) self._attr_volume_level = None @@ -193,27 +219,8 @@ class LgWebOSMediaPlayerEntity( icon = tv_state.apps[tv_state.current_app_id]["icon"] self._attr_media_image_url = icon - if self.state != MediaPlayerState.OFF or not self._supported_features: - supported = SUPPORT_WEBOSTV - if tv_state.sound_output == "external_speaker": - supported = supported | SUPPORT_WEBOSTV_VOLUME - elif tv_state.sound_output != "lineout": - supported = ( - supported - | SUPPORT_WEBOSTV_VOLUME - | MediaPlayerEntityFeature.VOLUME_SET - ) - - self._supported_features = supported - - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, cast(str, self.unique_id))}, - manufacturer="LG", - name=self._device_name, - ) - self._attr_assumed_state = True - if tv_state.is_on and tv_state.media_state: + if tv_state.media_state: self._attr_assumed_state = False for entry in tv_state.media_state: if entry.get("playState") == "playing": @@ -224,20 +231,18 @@ class LgWebOSMediaPlayerEntity( self._attr_state = MediaPlayerState.IDLE tv_info = self._client.tv_info - if self.state != MediaPlayerState.OFF: - maj_v = tv_info.software.get("major_ver") - min_v = tv_info.software.get("minor_ver") - if maj_v and min_v: - self._attr_device_info["sw_version"] = f"{maj_v}.{min_v}" + maj_v = tv_info.software.get("major_ver") + min_v = tv_info.software.get("minor_ver") + if maj_v and min_v: + self._attr_device_info["sw_version"] = f"{maj_v}.{min_v}" - if model := tv_info.system.get("modelName"): - self._attr_device_info["model"] = model + if model := tv_info.system.get("modelName"): + self._attr_device_info["model"] = model - if serial_number := tv_info.system.get("serialNumber"): - self._attr_device_info["serial_number"] = serial_number + if serial_number := tv_info.system.get("serialNumber"): + self._attr_device_info["serial_number"] = serial_number - self._attr_extra_state_attributes = {} - if tv_state.sound_output is not None or self.state != MediaPlayerState.OFF: + if tv_state.sound_output is not None: self._attr_extra_state_attributes = { ATTR_SOUND_OUTPUT: tv_state.sound_output } diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 5bc658490e65..fe87a4e9a6bc 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -86,6 +86,7 @@ from homeassistant.setup import ( async_get_setup_timings, async_wait_component, ) +from homeassistant.util import slugify from homeassistant.util.json import format_unserializable_data from . import const, decorators, messages @@ -126,6 +127,7 @@ def async_register_commands( async_reg(hass, handle_manifest_list) async_reg(hass, handle_ping) async_reg(hass, handle_render_template) + async_reg(hass, handle_slugify) async_reg(hass, handle_subscribe_bootstrap_integrations) async_reg(hass, handle_subscribe_condition) async_reg(hass, handle_subscribe_condition_platforms) @@ -726,6 +728,17 @@ def handle_ping( connection.send_message(pong_message(msg["id"])) +@callback +@decorators.websocket_command( + {vol.Required("type"): "slugify", vol.Required("text"): str} +) +def handle_slugify( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle slugify command.""" + connection.send_result(msg["id"], {"slug": slugify(msg["text"])}) + + @lru_cache def _cached_template(template_str: str, hass: HomeAssistant) -> template.Template: """Return a cached template.""" diff --git a/homeassistant/components/wiim/quality_scale.yaml b/homeassistant/components/wiim/quality_scale.yaml index 2babd6a5006f..f5b7a95f9f95 100644 --- a/homeassistant/components/wiim/quality_scale.yaml +++ b/homeassistant/components/wiim/quality_scale.yaml @@ -45,10 +45,7 @@ rules: log-when-unavailable: done parallel-updates: todo reauthentication-flow: todo - test-coverage: - status: todo - comment: | - - Increase test coverage for the media_player platform + test-coverage: done # Gold devices: done diff --git a/homeassistant/components/xiaomi_miio/entity.py b/homeassistant/components/xiaomi_miio/entity.py index 7f769ab56562..45929fc5981e 100644 --- a/homeassistant/components/xiaomi_miio/entity.py +++ b/homeassistant/components/xiaomi_miio/entity.py @@ -176,7 +176,11 @@ class XiaomiGatewayDevice(CoordinatorEntity[GatewayDeviceCoordinator], Entity): assert self._entry.unique_id is not None return DeviceInfo( identifiers={(DOMAIN, self._sub_device.sid)}, - via_device=(DOMAIN, self._entry.unique_id), + via_device_id=dr.async_get_device_id_by_identifier( + self.hass, + (DOMAIN, self._entry.unique_id), + config_entry_id=self._entry.entry_id, + ), manufacturer="Xiaomi", name=self._sub_device.name, model=self._sub_device.model, diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index c19060a9435e..340caab8481c 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -696,11 +696,15 @@ class ControllerEvents: node_id_device = self.dev_reg.async_get_device_by_identifier( device_id, self.config_entry.entry_id ) - via_identifier = None + via_device_id: str | None = None controller = driver.controller # Get the controller node device ID if this node is not the controller if controller.own_node and controller.own_node != node: - via_identifier = get_device_id(driver, controller.own_node) + via_device_id = dr.async_get_device_id_by_identifier( + self.hass, + get_device_id(driver, controller.own_node), + config_entry_id=self.config_entry.entry_id, + ) if device_id_ext: # If there is a device with this node ID but with a different hardware @@ -747,7 +751,7 @@ class ControllerEvents: model=node.device_config.label, manufacturer=node.device_config.manufacturer, suggested_area=node.location or UNDEFINED, - via_device=via_identifier, + via_device_id=via_device_id, ) async_dispatcher_send(self.hass, EVENT_DEVICE_ADDED_TO_REGISTRY, device) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index d8af704cf05c..abeeed3ffce3 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -1141,6 +1141,14 @@ async def websocket_provision_smart_start_node( manufacturer = device_info.manufacturer model = device_info.label + via_device_id: str | None = None + if driver.controller.own_node: + via_device_id = dr.async_get_device_id_by_identifier( + hass, + get_device_id(driver, driver.controller.own_node), + config_entry_id=entry.entry_id, + ) + # Create an empty device device = dev_reg.async_get_or_create( config_entry_id=entry.entry_id, @@ -1148,11 +1156,7 @@ async def websocket_provision_smart_start_node( name=device_name, manufacturer=manufacturer, model=model, - via_device=( - get_device_id(driver, driver.controller.own_node) - if driver.controller.own_node - else None - ), + via_device_id=via_device_id, ) dev_reg.async_update_device( device.id, area_id=msg.get(AREA_ID), name_by_user=device_name diff --git a/homeassistant/components/zwave_me/light.py b/homeassistant/components/zwave_me/light.py index ce4ed32f171d..fde70e45f912 100644 --- a/homeassistant/components/zwave_me/light.py +++ b/homeassistant/components/zwave_me/light.py @@ -61,10 +61,21 @@ class ZWaveMeRGB(ZWaveMeEntity, LightEntity): self._attr_supported_features = LightEntityFeature.TRANSITION self._attr_supported_color_modes: set[ColorMode] = {self._attr_color_mode} + @staticmethod + def _transition_to_duration(transition: float) -> int: + rounded_transition = round(transition) + if rounded_transition <= 127: + return rounded_transition + return min(127, round(rounded_transition / 60)) + 127 + @override def turn_off(self, **kwargs: Any) -> None: - """Turn the device on.""" - self.controller.zwave_api.send_command(self.device.id, "off") + """Turn the device off.""" + command = "off" + transition = kwargs.get(ATTR_TRANSITION) + if transition is not None: + command = f"exactSmooth?level=0&duration={self._transition_to_duration(transition)}" + self.controller.zwave_api.send_command(self.device.id, command) @override def turn_on(self, **kwargs: Any) -> None: @@ -92,11 +103,7 @@ class ZWaveMeRGB(ZWaveMeEntity, LightEntity): if transition is not None: command_id = "exactSmooth" - if transition < 127: - duration = round(transition) - else: - duration = min(127, round((transition) / 60)) + 127 - command_args["duration"] = str(duration) + command_args["duration"] = str(self._transition_to_duration(transition)) cmd = command_id if command_args: diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index f4549792bb9b..9451757998c6 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -623,8 +623,6 @@ class EntityTriggerBase(Trigger): if not self.is_valid_state(to_state, report_not_triggered): return - # The trigger should never fire if the origin state is excluded - # or the transition is not valid. if ( from_state.state in self._excluded_from_states or not self.is_valid_transition(from_state, to_state) @@ -657,9 +655,6 @@ class EntityTriggerBase(Trigger): @callback def call_action() -> None: """Call action with right context.""" - # After a `for` delay, keep the original triggering event payload. - # `async_track_same_state` only verifies the state remained valid - # for the configured duration before firing the action. run_action( { ATTR_ENTITY_ID: entity_id, @@ -672,7 +667,6 @@ class EntityTriggerBase(Trigger): ) if not self._duration: - # Call action immediately if duration is not specified or 0 call_action() return diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e9a1162cabc5..86a5110b6eb4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==6.26.5 hass-nabucasa==2.2.0 hassil==3.10.0 home-assistant-bluetooth==2.0.0 -home-assistant-frontend==20260729.1 +home-assistant-frontend==20260729.3 home-assistant-intents==2026.7.30 httpx==0.28.1 ifaddr==0.2.0 @@ -70,7 +70,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.15.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.11.31 +uv==0.11.32 voluptuous-openapi==0.4.1 voluptuous-serialize==2.7.0 voluptuous==0.15.2 diff --git a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py index 3c8db7a8c107..086ec55cbf46 100644 --- a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py +++ b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py @@ -5,7 +5,7 @@ To update, run python3 -m script.hassfest from typing import Final -FRONTEND_VERSION: Final[str] = "20260729.1" +FRONTEND_VERSION: Final[str] = "20260729.3" MDI_ICONS: Final[set[str]] = { "ab-testing", diff --git a/pyproject.toml b/pyproject.toml index 2c7bf1895401..f74df5070bcc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ dependencies = [ "typing-extensions>=4.15.0,<5.0", "ulid-transform==2.2.9", "urllib3>=2.0", - "uv==0.11.31", + "uv==0.11.32", "voluptuous==0.15.2", "voluptuous-serialize==2.7.0", "voluptuous-openapi==0.4.1", diff --git a/requirements.txt b/requirements.txt index 247e7ea7e510..5c0c6fd46d07 100644 --- a/requirements.txt +++ b/requirements.txt @@ -55,7 +55,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.15.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.11.31 +uv==0.11.32 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 c21c95f4cc3c..b61f572ace20 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -151,7 +151,7 @@ adguardhome==0.8.1 advantage-air==0.4.4 # homeassistant.components.frontier_silicon -afsapi==1.0.1 +afsapi==1.0.2 # homeassistant.components.agent_dvr agent-py==0.0.24 @@ -264,7 +264,7 @@ aioesphomeapi==45.6.1 # homeassistant.components.matrix # homeassistant.components.slack -aiofiles==24.1.0 +aiofiles==25.1.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -300,7 +300,7 @@ aiohomekit==4.0.0 aiohttp_sse==2.2.0 # homeassistant.components.hue -aiohue==4.8.2 +aiohue==4.9.0 # homeassistant.components.imap aioimaplib==2.0.1 @@ -889,7 +889,7 @@ ecoaliface==0.4.0 egauge-async==0.4.0 # homeassistant.components.eheimdigital -eheimdigital==1.7.0 +eheimdigital==1.7.1 # homeassistant.components.ekeybionyx ekey-bionyxpy==1.0.1 @@ -922,7 +922,7 @@ emoji==2.8.0 emulated-roku==0.3.0 # homeassistant.components.energieleser -energieleser==0.1.5 +energieleser==0.1.6 # homeassistant.components.huisbaasje energyflip-client==0.2.2 @@ -961,7 +961,7 @@ eq3btsmart==2.3.0 esios_api==4.4.0 # homeassistant.components.esphome -esphome-dashboard-api==1.3.0 +esphome-dashboard-api==1.4.0 # homeassistant.components.essent essent-dynamic-pricing==0.3.1 @@ -1278,7 +1278,7 @@ hole==0.9.2 holidays==0.101 # homeassistant.components.frontend -home-assistant-frontend==20260729.1 +home-assistant-frontend==20260729.3 # homeassistant.components.conversation home-assistant-intents==2026.7.30 @@ -1438,7 +1438,7 @@ knocki==0.4.2 knx-frontend==2026.7.23.145751 # homeassistant.components.knx -knx-telegram-store[sqlite,postgres]==0.11.1 +knx-telegram-store[sqlite,postgres]==0.11.2 # homeassistant.components.kraken krakenex==2.2.2 @@ -1688,7 +1688,7 @@ nextdns==5.0.1 nhc==0.8.0 # homeassistant.components.nibe_heatpump -nibe==2.22.0 +nibe==2.24.0 # homeassistant.components.nice_go nice-go==1.0.2 @@ -2488,7 +2488,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.portainer -pyportainer==1.0.42 +pyportainer==1.0.43 # homeassistant.components.probe_plus pyprobeplus==1.1.2 @@ -2689,7 +2689,7 @@ python-google-weather-api==0.0.6 python-homeassistant-analytics==0.9.0 # homeassistant.components.homewizard -python-homewizard-energy==10.1.0 +python-homewizard-energy==10.2.0 # homeassistant.components.hp_ilo python-hpilo==4.4.3 @@ -2786,7 +2786,7 @@ python-xbox==0.2.0 pythonegardia==1.0.52 # homeassistant.components.uptime_kuma -pythonkuma==0.5.1 +pythonkuma==0.5.2 # homeassistant.components.tile pytile==2024.12.0 @@ -2916,7 +2916,7 @@ renault-api==0.5.12 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.21.7 +reolink-aio==0.21.8 # homeassistant.components.radio_frequency rf-protocols==4.3.0 @@ -3406,10 +3406,10 @@ wyoming==1.10.0 xiaomi-ble==1.11.0 # homeassistant.components.knx -xknx==3.17.0 +xknx==3.18.0 # homeassistant.components.knx -xknxproject==3.9.0 +xknxproject==3.10.0 # homeassistant.components.fritz # homeassistant.components.rest diff --git a/requirements_test.txt b/requirements_test.txt index 868c2e3f7285..d74ebe71a752 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -40,20 +40,20 @@ requests-mock==1.12.1 respx==0.23.1 syrupy==5.5.3 tqdm==4.67.1 -types-aiofiles==24.1.0.20250822 +types-aiofiles==25.1.0.20260518 types-atomicwrites==1.4.5.1 -types-croniter==6.2.2.20260408 +types-croniter==6.2.4.20260711 types-caldav==1.3.0.20250516 types-chardet==0.1.5 -types-decorator==5.2.0.20260408 -types-pexpect==4.9.0.20260408 +types-decorator==5.2.0.20260712 +types-pexpect==4.9.0.20260518 types-protobuf==6.32.1.20260221 -types-psutil==7.2.2.20260408 -types-pyserial==3.5.0.20260408 -types-python-dateutil==2.9.0.20260408 +types-psutil==7.2.2.20260518 +types-pyserial==3.5.0.20260712 +types-python-dateutil==2.9.0.20260716 types-python-slugify==8.0.2.20240310 -types-pytz==2026.1.1.20260408 -types-PyYAML==6.0.12.20260408 -types-requests==2.33.0.20260408 -types-xmltodict==1.0.1.20260408 +types-pytz==2026.3.1.20260727 +types-PyYAML==6.0.12.20260724 +types-requests==2.33.0.20260712 +types-xmltodict==1.0.1.20260518 unidiff==1.0.0 diff --git a/tests/components/actron_air/test_init.py b/tests/components/actron_air/test_init.py index 5a13dd618531..b4af9a47cffb 100644 --- a/tests/components/actron_air/test_init.py +++ b/tests/components/actron_air/test_init.py @@ -3,9 +3,12 @@ from unittest.mock import AsyncMock from actron_neo_api import ActronAirAPIError, ActronAirAuthError +import pytest +from homeassistant.components.actron_air.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from . import setup_integration @@ -36,3 +39,22 @@ async def test_setup_entry_api_error( await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("init_integration_with_zone") +async def test_zone_device_via_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the zone device links to the AC system device via via_device_id.""" + system_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "123456"), mock_config_entry.entry_id + ) + assert system_device is not None + + zone_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "123456_zone_0"), mock_config_entry.entry_id + ) + assert zone_device is not None + assert zone_device.via_device_id == system_device.id diff --git a/tests/components/advantage_air/test_init.py b/tests/components/advantage_air/test_init.py index e700485c75a1..ff5c8ebc506b 100644 --- a/tests/components/advantage_air/test_init.py +++ b/tests/components/advantage_air/test_init.py @@ -3,9 +3,12 @@ from unittest.mock import AsyncMock from advantage_air import ApiError +import pytest +from homeassistant.components.advantage_air.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from . import add_mock_config, patch_get @@ -21,6 +24,36 @@ async def test_async_setup_entry(hass: HomeAssistant, mock_get: AsyncMock) -> No assert entry.state is ConfigEntryState.NOT_LOADED +@pytest.mark.usefixtures("mock_get") +@pytest.mark.parametrize( + "child_identifier", + [ + "uniqueid-ac1", # AC device + "uniqueid-100", # myLights light device + "uniqueid-203", # myThings device + ], +) +async def test_child_devices_via_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + child_identifier: str, +) -> None: + """Test child devices link to the system device via via_device_id.""" + + entry = await add_mock_config(hass) + + parent = device_registry.async_get_device_by_identifier( + (DOMAIN, "uniqueid"), entry.entry_id + ) + assert parent is not None + + child = device_registry.async_get_device_by_identifier( + (DOMAIN, child_identifier), entry.entry_id + ) + assert child is not None + assert child.via_device_id == parent.id + + async def test_async_setup_entry_failure(hass: HomeAssistant) -> None: """Test a unsuccessful setup entry.""" diff --git a/tests/components/airzone/test_init.py b/tests/components/airzone/test_init.py index a2783cb7c2ff..ef16285d8415 100644 --- a/tests/components/airzone/test_init.py +++ b/tests/components/airzone/test_init.py @@ -9,9 +9,16 @@ from homeassistant.components.airzone.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ID 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 .util import CONFIG, HVAC_MOCK, HVAC_VERSION_MOCK, HVAC_WEBSERVER_MOCK, USER_INPUT +from .util import ( + CONFIG, + HVAC_MOCK, + HVAC_VERSION_MOCK, + HVAC_WEBSERVER_MOCK, + USER_INPUT, + async_init_integration, +) from tests.common import MockConfigEntry @@ -121,6 +128,31 @@ async def test_unload_entry(hass: HomeAssistant) -> None: assert config_entry.state is ConfigEntryState.NOT_LOADED +async def test_device_via_device_links( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test that child devices link to their registered parent via via_device_id.""" + + config_entry = await async_init_integration(hass) + + ws_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{config_entry.entry_id}_ws"), config_entry.entry_id + ) + assert ws_device is not None + + system_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{config_entry.entry_id}_1"), config_entry.entry_id + ) + assert system_device is not None + assert system_device.via_device_id == ws_device.id + + zone_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{config_entry.entry_id}_1:1"), config_entry.entry_id + ) + assert zone_device is not None + assert zone_device.via_device_id == system_device.id + + async def test_migrate_entry_v2(hass: HomeAssistant) -> None: """Test entry migration to v2.""" diff --git a/tests/components/airzone_cloud/test_init.py b/tests/components/airzone_cloud/test_init.py index 6cab0be6e7c4..bee4a2bc2de0 100644 --- a/tests/components/airzone_cloud/test_init.py +++ b/tests/components/airzone_cloud/test_init.py @@ -7,8 +7,9 @@ from aioairzone_cloud.exceptions import AirzoneTimeout from homeassistant.components.airzone_cloud.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr -from .util import CONFIG +from .util import CONFIG, WS_ID, WS_ID_AIDOO, async_init_integration from tests.common import MockConfigEntry @@ -54,6 +55,51 @@ async def test_unload_entry(hass: HomeAssistant) -> None: assert config_entry.state is ConfigEntryState.NOT_LOADED +async def test_device_via_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that child devices are linked to their via_device parents.""" + await async_init_integration(hass) + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + + ws_device = device_registry.async_get_device_by_identifier( + (DOMAIN, WS_ID), config_entry.entry_id + ) + assert ws_device is not None + assert ws_device.via_device_id is None + + ws_aidoo_device = device_registry.async_get_device_by_identifier( + (DOMAIN, WS_ID_AIDOO), config_entry.entry_id + ) + assert ws_aidoo_device is not None + + system_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "system1"), config_entry.entry_id + ) + assert system_device is not None + assert system_device.via_device_id == ws_device.id + + zone_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "zone1"), config_entry.entry_id + ) + assert zone_device is not None + assert zone_device.via_device_id == system_device.id + + dhw_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "dhw1"), config_entry.entry_id + ) + assert dhw_device is not None + assert dhw_device.via_device_id == ws_device.id + + aidoo_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "aidoo1"), config_entry.entry_id + ) + assert aidoo_device is not None + assert aidoo_device.via_device_id == ws_aidoo_device.id + + async def test_init_api_timeout(hass: HomeAssistant) -> None: """Test API timeouts when loading the Airzone Cloud integration.""" diff --git a/tests/components/aquacell/conftest.py b/tests/components/aquacell/conftest.py index 3c812089541f..bec327970d71 100644 --- a/tests/components/aquacell/conftest.py +++ b/tests/components/aquacell/conftest.py @@ -1,7 +1,7 @@ """Common fixtures for the Aquacell tests.""" from collections.abc import Generator -from datetime import datetime +import time from unittest.mock import AsyncMock, MagicMock, patch from aioaquacell import AquacellApi, Softener @@ -73,7 +73,7 @@ def mock_config_entry() -> MockConfigEntry: unique_id=TEST_CONFIG_ENTRY[CONF_EMAIL], data={ **TEST_CONFIG_ENTRY, - CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(), # pylint: disable=home-assistant-enforce-naive-now + CONF_REFRESH_TOKEN_CREATION_TIME: time.time(), }, ) @@ -87,6 +87,6 @@ def mock_config_entry_without_brand() -> MockConfigEntry: unique_id=TEST_CONFIG_ENTRY[CONF_EMAIL], data={ **TEST_CONFIG_ENTRY_WITHOUT_BRAND, - CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(), # pylint: disable=home-assistant-enforce-naive-now + CONF_REFRESH_TOKEN_CREATION_TIME: time.time(), }, ) diff --git a/tests/components/aquacell/test_config_flow.py b/tests/components/aquacell/test_config_flow.py index f812d6239ff8..63e733e5cab1 100644 --- a/tests/components/aquacell/test_config_flow.py +++ b/tests/components/aquacell/test_config_flow.py @@ -1,6 +1,6 @@ """Test the Aquacell config flow.""" -from datetime import datetime +import time from unittest.mock import AsyncMock from aioaquacell import ApiException, AuthenticationFailed @@ -151,10 +151,7 @@ async def test_reauth_flow( assert mock_config_entry.data[CONF_PASSWORD] == "new-password" assert mock_config_entry.data[CONF_REFRESH_TOKEN] == "refresh-token" - assert ( - mock_config_entry.data[CONF_REFRESH_TOKEN_CREATION_TIME] - == datetime.now().timestamp() # pylint: disable=home-assistant-enforce-naive-now - ) + assert mock_config_entry.data[CONF_REFRESH_TOKEN_CREATION_TIME] == time.time() @pytest.mark.parametrize( diff --git a/tests/components/aquacell/test_init.py b/tests/components/aquacell/test_init.py index 102ee4d343e1..982986ad9d15 100644 --- a/tests/components/aquacell/test_init.py +++ b/tests/components/aquacell/test_init.py @@ -1,9 +1,10 @@ """Test the Aquacell init module.""" -from datetime import datetime -from unittest.mock import AsyncMock, patch +import time +from unittest.mock import AsyncMock from aioaquacell import AquacellApiException, AuthenticationFailed +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.aquacell.const import ( @@ -65,18 +66,14 @@ async def test_coordinator_update_valid_refresh_token( async def test_coordinator_update_expired_refresh_token( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_aquacell_api: AsyncMock, mock_config_entry_expired: MockConfigEntry, ) -> None: """Test load and unload entry.""" mock_aquacell_api.authenticate.return_value = "new-refresh-token" - now = datetime.now() # pylint: disable=home-assistant-enforce-naive-now - with patch( - "homeassistant.components.aquacell.coordinator.datetime" - ) as datetime_mock: - datetime_mock.now.return_value = now - await setup_integration(hass, mock_config_entry_expired) + await setup_integration(hass, mock_config_entry_expired) entry = hass.config_entries.async_entries(DOMAIN)[0] @@ -87,7 +84,7 @@ async def test_coordinator_update_expired_refresh_token( assert len(mock_aquacell_api.get_all_softeners.mock_calls) == 1 assert entry.data[CONF_REFRESH_TOKEN] == "new-refresh-token" - assert entry.data[CONF_REFRESH_TOKEN_CREATION_TIME] == now.timestamp() + assert entry.data[CONF_REFRESH_TOKEN_CREATION_TIME] == time.time() @pytest.mark.parametrize( diff --git a/tests/components/backblaze_b2/test_init.py b/tests/components/backblaze_b2/test_init.py index c42a892542b7..dd27b17e8d0b 100644 --- a/tests/components/backblaze_b2/test_init.py +++ b/tests/components/backblaze_b2/test_init.py @@ -1,6 +1,5 @@ """Test the Backblaze B2 storage integration.""" -from datetime import datetime from unittest.mock import AsyncMock, MagicMock, patch from b2sdk.v2 import exception @@ -9,6 +8,7 @@ import pytest from homeassistant.components.backblaze_b2.const import CONF_APPLICATION_KEY from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from . import setup_integration @@ -101,7 +101,7 @@ async def test_periodic_issue_check( ): await setup_integration(hass, mock_config_entry) assert captured_callback is not None - await captured_callback(datetime.now()) # pylint: disable=home-assistant-enforce-naive-now + await captured_callback(dt_util.utcnow()) assert mock_check.call_count == 2 # setup + callback mock_check.assert_called_with(hass, mock_config_entry) diff --git a/tests/components/compit/conftest.py b/tests/components/compit/conftest.py index 8232dd72521f..4efd6522c582 100644 --- a/tests/components/compit/conftest.py +++ b/tests/components/compit/conftest.py @@ -90,7 +90,11 @@ def mock_connector(): return all_devices.get(device_id) def get_current_value(device_id: int, parameter_code: CompitParameter): - code = PARAMS[parameter_code][all_devices[device_id].definition.code] + # Not every device supports every parameter; fall back instead of + # raising when this device has no mapping for it. + code = PARAMS.get(parameter_code, {}).get( + all_devices[device_id].definition.code, parameter_code.value + ) param = next( (p for p in all_devices[device_id].state.params if p.code == code), None, diff --git a/tests/components/compit/snapshots/test_binary_sensor.ambr b/tests/components/compit/snapshots/test_binary_sensor.ambr index 26d626485ac0..1b9b1b2c3c7b 100644 --- a/tests/components/compit/snapshots/test_binary_sensor.ambr +++ b/tests/components/compit/snapshots/test_binary_sensor.ambr @@ -101,3 +101,54 @@ 'state': 'off', }) # --- +# name: test_binary_sensor_entities_snapshot[binary_sensor.nano_color_2_ground_heat_exchanger_attached-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.nano_color_2_ground_heat_exchanger_attached', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Ground heat exchanger attached', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Ground heat exchanger attached', + 'platform': 'compit', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ground_heat_exchanger_attached', + 'unique_id': '2_GWC', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_entities_snapshot[binary_sensor.nano_color_2_ground_heat_exchanger_attached-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'connectivity', + : 'Nano Color 2 Ground heat exchanger attached', + }), + 'context': , + 'entity_id': 'binary_sensor.nano_color_2_ground_heat_exchanger_attached', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/coolmaster/test_config_flow.py b/tests/components/coolmaster/test_config_flow.py index 162cf1345991..11166407554a 100644 --- a/tests/components/coolmaster/test_config_flow.py +++ b/tests/components/coolmaster/test_config_flow.py @@ -10,6 +10,8 @@ from homeassistant.components.coolmaster.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + def _flow_data(send_wakeup_prompt: bool = False) -> dict: options: dict = {"host": "1.1.1.1"} @@ -109,3 +111,26 @@ async def test_form_no_units(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "no_units"} + + +async def test_form_duplicate_host(hass: HomeAssistant) -> None: + """Test we abort when a bridge on this host is already configured.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "host": "1.1.1.1", + "port": 10102, + "supported_modes": AVAILABLE_MODES, + }, + ) + entry.add_to_hass(hass) + + 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"], _flow_data() + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/directv/test_media_player.py b/tests/components/directv/test_media_player.py index 91a63f48be44..eefbe5b39153 100644 --- a/tests/components/directv/test_media_player.py +++ b/tests/components/directv/test_media_player.py @@ -6,6 +6,7 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory import pytest +from homeassistant.components.directv.const import DOMAIN from homeassistant.components.directv.media_player import ( ATTR_MEDIA_CURRENTLY_RECORDING, ATTR_MEDIA_RATING, @@ -46,10 +47,10 @@ 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 homeassistant.util import dt as dt_util -from . import setup_integration +from . import RECEIVER_ID, setup_integration from tests.test_util.aiohttp import AiohttpClientMocker @@ -162,6 +163,29 @@ async def test_unique_id( assert unavailable_client.unique_id == "9XXXXXXXXXX9" +async def test_client_device_via_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test a client's device links to the receiver device via via_device_id.""" + entry = await setup_integration(hass, aioclient_mock) + + receiver_device = device_registry.async_get_device_by_identifier( + (DOMAIN, RECEIVER_ID), entry.entry_id + ) + assert receiver_device is not None + + client_entity = entity_registry.async_get(CLIENT_ENTITY_ID) + assert client_entity is not None + assert client_entity.device_id is not None + + client_device = device_registry.async_get(client_entity.device_id) + assert client_device is not None + assert client_device.via_device_id == receiver_device.id + + async def test_supported_features( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/eheimdigital/test_init.py b/tests/components/eheimdigital/test_init.py index a985e385a7d8..d0eaf0078dc0 100644 --- a/tests/components/eheimdigital/test_init.py +++ b/tests/components/eheimdigital/test_init.py @@ -117,3 +117,32 @@ async def test_entry_setup_error( eheimdigital_hub_mock.return_value.connect.side_effect = EheimDigitalClientError() await init_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_child_device_via_device( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that child devices are linked to the main device.""" + await init_integration(hass, mock_config_entry) + + for device_address in eheimdigital_hub_mock.return_value.devices: + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + device_address, + eheimdigital_hub_mock.return_value.devices[device_address].device_type, + ) + await hass.async_block_till_done() + + main_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "00:00:00:00:00:01"), mock_config_entry.entry_id + ) + assert main_device is not None + assert main_device.via_device_id is None + + child_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "00:00:00:00:00:02"), mock_config_entry.entry_id + ) + assert child_device is not None + assert child_device.via_device_id == main_device.id diff --git a/tests/components/elkm1/test_init.py b/tests/components/elkm1/test_init.py new file mode 100644 index 000000000000..970725a4238c --- /dev/null +++ b/tests/components/elkm1/test_init.py @@ -0,0 +1,92 @@ +"""Tests for the Elk-M1 Control init.""" + +from unittest.mock import MagicMock, patch + +from homeassistant.components.elkm1.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PREFIX, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import MOCK_MAC, _patch_discovery, mock_elk + +from tests.common import MockConfigEntry + + +def _mocked_elk_with_light() -> MagicMock: + """Return a mocked Elk that exposes a single PLC light and nothing else.""" + light = MagicMock() + light.index = 0 + light.name = "Test Light" + light.default_name.return_value = "test_light" + light.configured = True + light.status = 0 + light.as_dict.return_value = {} + + elk = mock_elk(sync_complete=True) + elk.is_connected.return_value = True + for collection in ( + "areas", + "tasks", + "counters", + "keypads", + "zones", + "outputs", + "settings", + "thermostats", + ): + setattr(elk, collection, []) + elk.lights = [light] + # The panel sensor is an attached entity; skip it so the only registered + # device besides the system device is the light's own (via_device) device. + elk.panel.configured = False + elk.panel.elkm1_version = "1.0.0" + elk.panel.temperature_units = "F" + return elk + + +async def test_light_via_device_links_to_system_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A child (light) device links to the system device registered at setup. + + With auto configure and only a light present, no sibling attached entity + creates the system device, so the link resolves only because setup + registers the system device before platforms are forwarded. + """ + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "elks://1.2.3.4", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + CONF_PREFIX: "", + "auto_configure": True, + }, + unique_id=MOCK_MAC, + ) + config_entry.add_to_hass(hass) + + with ( + _patch_discovery(), + patch( + "homeassistant.components.elkm1.Elk", + return_value=_mocked_elk_with_light(), + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + system_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "_system"), config_entry.entry_id + ) + assert system_device is not None + assert system_device.name == "ElkM1" + + light_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "elkm1_test_light"), config_entry.entry_id + ) + assert light_device is not None + assert light_device.via_device_id == system_device.id diff --git a/tests/components/feedreader/conftest.py b/tests/components/feedreader/conftest.py index 296d345cca77..d3eac915863d 100644 --- a/tests/components/feedreader/conftest.py +++ b/tests/components/feedreader/conftest.py @@ -75,6 +75,18 @@ def fixture_feed_atom_htmlentities(hass: HomeAssistant) -> bytes: return load_fixture_bytes("feedreader10.xml", DOMAIN) +@pytest.fixture(name="feed_unsorted") +def fixture_feed_unsorted(hass: HomeAssistant) -> bytes: + """Load test ATOM feed data with HTML Entities.""" + return load_fixture_bytes("feedreader11.xml", DOMAIN) + + +@pytest.fixture(name="feed_unsorted_update") +def fixture_feed_unsorted_update(hass: HomeAssistant) -> bytes: + """Load test ATOM feed data with HTML Entities.""" + return load_fixture_bytes("feedreader12.xml", DOMAIN) + + @pytest.fixture(name="events") async def fixture_events(hass: HomeAssistant) -> list[Event]: """Fixture that catches alexa events.""" diff --git a/tests/components/feedreader/fixtures/feedreader11.xml b/tests/components/feedreader/fixtures/feedreader11.xml new file mode 100644 index 000000000000..e10ca5747033 --- /dev/null +++ b/tests/components/feedreader/fixtures/feedreader11.xml @@ -0,0 +1,26 @@ + + + + RSS Sample + This is an example of an RSS feed + http://www.example.com/main.html + Mon, 30 Apr 2018 12:00:00 +1000 + Mon, 30 Apr 2018 15:00:00 +1000 + 1800 + + Title 3 + Mon, 30 Apr 2018 15:02:00 +1000 + Content 3 + + + Title 1 + Mon, 30 Apr 2018 15:00:00 +1000 + Content 1 + + + Title 2 + Mon, 30 Apr 2018 15:01:00 +1000 + Content 2 + + + diff --git a/tests/components/feedreader/fixtures/feedreader12.xml b/tests/components/feedreader/fixtures/feedreader12.xml new file mode 100644 index 000000000000..2926d8f97fbb --- /dev/null +++ b/tests/components/feedreader/fixtures/feedreader12.xml @@ -0,0 +1,31 @@ + + + + RSS Sample + This is an example of an RSS feed + http://www.example.com/main.html + Mon, 30 Apr 2018 12:00:00 +1000 + Mon, 30 Apr 2018 15:00:00 +1000 + 1800 + + Title 3 + Mon, 30 Apr 2018 15:02:00 +1000 + Content 3 + + + Title 4 + Mon, 30 Apr 2018 15:03:00 +1000 + Content 4 + + + Title 1 + Mon, 30 Apr 2018 15:00:00 +1000 + Content 1 + + + Title 2 + Mon, 30 Apr 2018 15:01:00 +1000 + Content 2 + + + diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py index f7d64eafbe88..b9ab11b7c779 100644 --- a/tests/components/feedreader/test_init.py +++ b/tests/components/feedreader/test_init.py @@ -237,6 +237,34 @@ async def test_feed_updates( assert len(events) == 2 +async def test_unsorted_feed_updates( + hass: HomeAssistant, events, feed_unsorted, feed_unsorted_update +) -> None: + """Test feed updates.""" + side_effect = [ + feed_unsorted, + feed_unsorted_update, + ] + + entry = create_mock_entry(VALID_CONFIG_DEFAULT) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.feedreader.coordinator.feedparser.http.get", + side_effect=side_effect, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(events) == 3 + + # Change time and fetch one more unordered entry + future = dt_util.utcnow() + timedelta(hours=1, seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done(wait_background_tasks=True) + + assert len(events) == 4 + + async def test_feed_default_max_length( hass: HomeAssistant, events, feed_21_events ) -> None: diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index 0afbf7728775..faef670fab98 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -16,6 +16,8 @@ from homeassistant.components.google_generative_ai_conversation.const import ( CONF_RECOMMENDED, CONF_SEXUAL_BLOCK_THRESHOLD, CONF_TEMPERATURE, + CONF_THINKING_BUDGET, + CONF_THINKING_LEVEL, CONF_TOP_K, CONF_TOP_P, CONF_USE_GOOGLE_SEARCH_TOOL, @@ -31,6 +33,8 @@ from homeassistant.components.google_generative_ai_conversation.const import ( RECOMMENDED_MAX_TOKENS, RECOMMENDED_STT_MODEL, RECOMMENDED_STT_OPTIONS, + RECOMMENDED_THINKING_BUDGET, + RECOMMENDED_THINKING_LEVEL, RECOMMENDED_TOP_K, RECOMMENDED_TOP_P, RECOMMENDED_TTS_MODEL, @@ -247,6 +251,8 @@ async def test_creating_subentry( CONF_TOP_P: 1.0, CONF_TOP_K: 1, CONF_MAX_TOKENS: 1024, + CONF_THINKING_BUDGET: RECOMMENDED_THINKING_BUDGET, + CONF_THINKING_LEVEL: RECOMMENDED_THINKING_LEVEL, CONF_HARASSMENT_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", CONF_HATE_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", CONF_SEXUAL_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", @@ -265,6 +271,8 @@ async def test_creating_subentry( CONF_TOP_P: 1.0, CONF_TOP_K: 1, CONF_MAX_TOKENS: 1024, + CONF_THINKING_BUDGET: RECOMMENDED_THINKING_BUDGET, + CONF_THINKING_LEVEL: RECOMMENDED_THINKING_LEVEL, CONF_HARASSMENT_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", CONF_HATE_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", CONF_SEXUAL_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", @@ -290,6 +298,8 @@ async def test_creating_subentry( CONF_TOP_P: 1.0, CONF_TOP_K: 1, CONF_MAX_TOKENS: 1024, + CONF_THINKING_BUDGET: RECOMMENDED_THINKING_BUDGET, + CONF_THINKING_LEVEL: RECOMMENDED_THINKING_LEVEL, CONF_HARASSMENT_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", CONF_HATE_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", CONF_SEXUAL_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", @@ -417,6 +427,8 @@ def will_options_be_rendered_again(current_options, new_options) -> bool: CONF_TOP_P: RECOMMENDED_TOP_P, CONF_TOP_K: RECOMMENDED_TOP_K, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_THINKING_BUDGET: RECOMMENDED_THINKING_BUDGET, + CONF_THINKING_LEVEL: RECOMMENDED_THINKING_LEVEL, CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, @@ -434,6 +446,8 @@ def will_options_be_rendered_again(current_options, new_options) -> bool: CONF_TOP_P: RECOMMENDED_TOP_P, CONF_TOP_K: RECOMMENDED_TOP_K, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_THINKING_BUDGET: RECOMMENDED_THINKING_BUDGET, + CONF_THINKING_LEVEL: RECOMMENDED_THINKING_LEVEL, CONF_USE_GOOGLE_SEARCH_TOOL: True, }, { @@ -457,6 +471,8 @@ def will_options_be_rendered_again(current_options, new_options) -> bool: CONF_TOP_P: RECOMMENDED_TOP_P, CONF_TOP_K: RECOMMENDED_TOP_K, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_THINKING_BUDGET: RECOMMENDED_THINKING_BUDGET, + CONF_THINKING_LEVEL: RECOMMENDED_THINKING_LEVEL, CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, @@ -471,6 +487,8 @@ def will_options_be_rendered_again(current_options, new_options) -> bool: CONF_TOP_P: RECOMMENDED_TOP_P, CONF_TOP_K: RECOMMENDED_TOP_K, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_THINKING_BUDGET: RECOMMENDED_THINKING_BUDGET, + CONF_THINKING_LEVEL: RECOMMENDED_THINKING_LEVEL, CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, @@ -485,6 +503,8 @@ def will_options_be_rendered_again(current_options, new_options) -> bool: CONF_TOP_P: RECOMMENDED_TOP_P, CONF_TOP_K: RECOMMENDED_TOP_K, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_THINKING_BUDGET: RECOMMENDED_THINKING_BUDGET, + CONF_THINKING_LEVEL: RECOMMENDED_THINKING_LEVEL, CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, @@ -502,6 +522,8 @@ def will_options_be_rendered_again(current_options, new_options) -> bool: CONF_TOP_P: RECOMMENDED_TOP_P, CONF_TOP_K: RECOMMENDED_TOP_K, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_THINKING_BUDGET: RECOMMENDED_THINKING_BUDGET, + CONF_THINKING_LEVEL: RECOMMENDED_THINKING_LEVEL, CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, @@ -517,6 +539,8 @@ def will_options_be_rendered_again(current_options, new_options) -> bool: CONF_TOP_P: RECOMMENDED_TOP_P, CONF_TOP_K: RECOMMENDED_TOP_K, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_THINKING_BUDGET: RECOMMENDED_THINKING_BUDGET, + CONF_THINKING_LEVEL: RECOMMENDED_THINKING_LEVEL, CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, @@ -531,6 +555,8 @@ def will_options_be_rendered_again(current_options, new_options) -> bool: CONF_TOP_P: RECOMMENDED_TOP_P, CONF_TOP_K: RECOMMENDED_TOP_K, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_THINKING_BUDGET: RECOMMENDED_THINKING_BUDGET, + CONF_THINKING_LEVEL: RECOMMENDED_THINKING_LEVEL, CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 9b524f3cd5e8..a65f6278e931 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -4,7 +4,7 @@ import datetime from unittest.mock import AsyncMock, patch from freezegun import freeze_time -from google.genai.types import GenerateContentResponse +from google.genai.types import GenerateContentResponse, ThinkingLevel import pytest from syrupy.assertion import SnapshotAssertion @@ -17,6 +17,7 @@ from homeassistant.components.conversation import ( ) from homeassistant.components.google_generative_ai_conversation.entity import ( ERROR_GETTING_RESPONSE, + _create_thinking_config, _escape_decode, _format_schema, ) @@ -800,6 +801,142 @@ async def test_history_always_user_first_turn( assert actual_history[1].role == "model" +# --- Tests for _create_thinking_config --- + + +@pytest.mark.parametrize( + ("model", "thinking_budget", "thinking_level", "expected"), + [ + # Non-thinking models return None + ("models/gemini-1.5-flash", -1, None, None), + ("gemini-2.0-flash", -1, None, None), + # TTS/image models are excluded even if prefix matches + ("models/gemini-2.5-flash-preview-tts", -1, None, None), + ("models/gemini-2.5-pro-image", -1, None, None), + ("models/gemini-3-flash-tts", -1, None, None), + ], +) +def test_create_thinking_config_non_thinking_models( + model: str, + thinking_budget: int, + thinking_level: str | None, + expected: None, +) -> None: + """Test that non-thinking models return None.""" + assert _create_thinking_config(model, thinking_budget, thinking_level) is expected + + +@pytest.mark.parametrize( + ("model", "thinking_level"), + [ + ("models/gemini-3-flash", "minimal"), + ("models/gemini-3-flash", "low"), + ("gemini-3-pro", "medium"), + ("models/gemini-3-ultra", "high"), + ], +) +def test_create_thinking_config_gemini3_levels( + model: str, + thinking_level: str, +) -> None: + """Test Gemini 3 models with explicit thinking levels.""" + level_map = { + "minimal": ThinkingLevel.MINIMAL, + "low": ThinkingLevel.LOW, + "medium": ThinkingLevel.MEDIUM, + "high": ThinkingLevel.HIGH, + } + + result = _create_thinking_config(model, -1, thinking_level) + assert result is not None + assert result.include_thoughts is True + assert result.thinking_level == level_map[thinking_level] + + +@pytest.mark.parametrize( + ("model", "thinking_level"), + [ + ("models/gemini-3-flash", "auto"), + ("models/gemini-3-flash", None), + ("gemini-3-pro", "minimal"), + ], +) +def test_create_thinking_config_gemini3_auto( + model: str, + thinking_level: str | None, +) -> None: + """Test Gemini 3 with 'auto' or unset level defers to the API.""" + result = _create_thinking_config(model, -1, thinking_level) + assert result is not None + assert result.include_thoughts is True + assert result.thinking_level is None + + +@pytest.mark.parametrize( + ("model", "thinking_budget", "expected_budget"), + [ + # Pro: budget < 128 is clamped to 128 + ("models/gemini-2.5-pro", 0, 128), + ("models/gemini-2.5-pro", 1, 128), + ("models/gemini-2.5-pro", 127, 128), + ("models/gemini-2.5-pro-preview-05-06", 50, 128), + # Pro: budget >= 128 is passed through + ("models/gemini-2.5-pro", 128, 128), + ("models/gemini-2.5-pro", 1000, 1000), + ("models/gemini-2.5-pro", 8192, 8192), + ], +) +def test_create_thinking_config_gemini25_pro_clamping( + model: str, + thinking_budget: int, + expected_budget: int, +) -> None: + """Test Gemini 2.5 Pro clamps budgets below 128.""" + result = _create_thinking_config(model, thinking_budget) + assert result is not None + assert result.include_thoughts is True + assert result.thinking_budget == expected_budget + + +def test_create_thinking_config_gemini25_pro_automatic() -> None: + """Test Gemini 2.5 Pro with automatic budget (-1).""" + result = _create_thinking_config("models/gemini-2.5-pro", -1) + assert result is not None + assert result.include_thoughts is True + assert result.thinking_budget is None + + +@pytest.mark.parametrize( + "model", + [ + "models/gemini-2.5-flash", + "gemini-2.5-flash-preview-04-17", + ], +) +def test_create_thinking_config_gemini25_flash_disable(model: str) -> None: + """Test Gemini 2.5 Flash with budget 0 disables thinking.""" + result = _create_thinking_config(model, 0) + assert result is not None + assert result.include_thoughts is False + assert result.thinking_budget == 0 + + +def test_create_thinking_config_gemini25_flash_automatic() -> None: + """Test Gemini 2.5 Flash with automatic budget (-1).""" + result = _create_thinking_config("models/gemini-2.5-flash", -1) + assert result is not None + assert result.include_thoughts is True + assert result.thinking_budget is None + + +def test_create_thinking_config_gemini25_flash_custom() -> None: + """Test Gemini 2.5 Flash with a custom budget passes through.""" + result = _create_thinking_config("models/gemini-2.5-flash", 2048) + assert result is not None + assert result.include_thoughts is True + assert result.thinking_budget == 2048 + + @pytest.mark.usefixtures("mock_init_component") async def test_token_stats_reported( hass: HomeAssistant, diff --git a/tests/components/google_health/conftest.py b/tests/components/google_health/conftest.py index 79708d612481..e4aacb2d0607 100644 --- a/tests/components/google_health/conftest.py +++ b/tests/components/google_health/conftest.py @@ -78,9 +78,9 @@ def mock_expires_at() -> int: @pytest.fixture -def scopes() -> list[str]: +def scopes(request: pytest.FixtureRequest) -> list[str]: """Fixture with scopes to set up.""" - return OAUTH_SCOPES + return getattr(request, "param", OAUTH_SCOPES) @pytest.fixture(name="token_entry") diff --git a/tests/components/google_health/snapshots/test_sensor.ambr b/tests/components/google_health/snapshots/test_sensor.ambr index fc8e4cb9e3b1..5f084d060e53 100644 --- a/tests/components/google_health/snapshots/test_sensor.ambr +++ b/tests/components/google_health/snapshots/test_sensor.ambr @@ -14,7 +14,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.device_battery', 'has_entity_name': True, 'hidden_by': None, @@ -67,7 +67,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.device_last_sync_time', 'has_entity_name': True, 'hidden_by': None, @@ -120,7 +120,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.fitbit_charge_6_battery', 'has_entity_name': True, 'hidden_by': None, @@ -173,7 +173,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.fitbit_charge_6_last_sync_time', 'has_entity_name': True, 'hidden_by': None, @@ -237,8 +237,11 @@ 'name': None, 'object_id_base': 'Active calories', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Active calories', 'platform': 'google_health', @@ -253,6 +256,7 @@ # name: test_all_entities[sensor.google_health_active_calories-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'energy', : 'Google Health Active calories', : , : , @@ -345,8 +349,11 @@ 'name': None, 'object_id_base': 'Calories consumed', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Calories consumed', 'platform': 'google_health', @@ -361,6 +368,7 @@ # name: test_all_entities[sensor.google_health_calories_consumed-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'energy', : 'Google Health Calories consumed', : , : , @@ -402,6 +410,9 @@ 'sensor': dict({ 'suggested_display_precision': 2, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -412,7 +423,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_distance', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.google_health_distance-state] @@ -421,14 +432,14 @@ : 'distance', : 'Google Health Distance', : , - : , + : , }), 'context': , 'entity_id': 'sensor.google_health_distance', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '5000.0', + 'state': '5.0', }) # --- # name: test_all_entities[sensor.google_health_floors-entry] @@ -908,8 +919,11 @@ 'name': None, 'object_id_base': 'Total calories', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Total calories', 'platform': 'google_health', @@ -924,6 +938,7 @@ # name: test_all_entities[sensor.google_health_total_calories-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'energy', : 'Google Health Total calories', : , : , @@ -1067,7 +1082,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.scale_battery', 'has_entity_name': True, 'hidden_by': None, @@ -1120,7 +1135,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.scale_last_sync_time', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/google_health/test_sensor.py b/tests/components/google_health/test_sensor.py index 9e75cbfe8309..1d5d8e6b5e2b 100644 --- a/tests/components/google_health/test_sensor.py +++ b/tests/components/google_health/test_sensor.py @@ -1,15 +1,22 @@ """Tests for Google Health sensor platform.""" from collections.abc import Awaitable, Callable -from unittest.mock import AsyncMock, patch +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch -from google_health_api.model import ListDataPointResult, _ListDataPointsModel +from google_health_api.const import HealthApiScope import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.google_health.const import DOMAIN from homeassistant.const import Platform 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 homeassistant.util.unit_system import ( + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, +) from tests.common import MockConfigEntry, snapshot_platform @@ -80,12 +87,91 @@ async def test_sensor_empty_sleep( integration_setup: Callable[[], Awaitable[bool]], ) -> None: """Test sleep sensors when the sleep endpoint returns no data.""" - mock_google_health_client.sleep.list.return_value = ListDataPointResult( - _ListDataPointsModel(data_points=[]) - ) + mock_google_health_client.sleep.list.return_value = MagicMock(data_points=[]) assert await integration_setup() time_asleep_state = hass.states.get("sensor.google_health_time_asleep") assert time_asleep_state is not None assert time_asleep_state.state == "unknown" + + +@pytest.mark.parametrize( + ("unit_system", "expected_sensors"), + [ + pytest.param( + METRIC_SYSTEM, + { + "sensor.google_health_weight": (pytest.approx(80.0), "kg"), + "sensor.google_health_distance": (pytest.approx(5.0), "km"), + "sensor.google_health_water_intake": (pytest.approx(2.5), "L"), + }, + id="metric", + ), + pytest.param( + US_CUSTOMARY_SYSTEM, + { + "sensor.google_health_weight": (pytest.approx(176.37, abs=1e-2), "lb"), + "sensor.google_health_distance": ( + pytest.approx(3.11, abs=1e-2), + "mi", + ), + "sensor.google_health_water_intake": ( + pytest.approx(84.54, abs=1e-1), + "fl. oz.", + ), + }, + id="us_customary", + ), + ], +) +@pytest.mark.usefixtures("mock_google_health_client") +async def test_sensor_unit_conversions( + hass: HomeAssistant, + integration_setup: Callable[[], Awaitable[bool]], + unit_system: UnitSystem, + expected_sensors: dict[str, tuple[Any, str]], +) -> None: + """Test sensors dynamically convert states and units under different unit systems.""" + hass.config.units = unit_system + + assert await integration_setup() + + for entity_id, (expected_state, expected_unit) in expected_sensors.items(): + state = hass.states.get(entity_id) + assert state is not None + assert float(state.state) == expected_state + assert state.attributes.get("unit_of_measurement") == expected_unit + + +@pytest.mark.parametrize( + "scopes", + [[HealthApiScope.PROFILE_READ, HealthApiScope.SETTINGS_READ]], + indirect=True, +) +@pytest.mark.usefixtures("mock_google_health_client") +async def test_device_sensor_via_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], +) -> None: + """Test a paired device is linked to the account device via via_device_id. + + Only the profile and settings scopes are granted so the account device + can only come from the up-front registration, not from account-level + sensors that scopes outside of this test would also create. + """ + with patch("homeassistant.components.google_health._PLATFORMS", [Platform.SENSOR]): + assert await integration_setup() + + account_device = device_registry.async_get_device_by_identifier( + (DOMAIN, config_entry.entry_id), config_entry.entry_id + ) + assert account_device is not None + + paired_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "watch_123"), config_entry.entry_id + ) + assert paired_device is not None + assert paired_device.via_device_id == account_device.id diff --git a/tests/components/green_planet_energy/snapshots/test_services.ambr b/tests/components/green_planet_energy/snapshots/test_services.ambr new file mode 100644 index 000000000000..0a639939d8eb --- /dev/null +++ b/tests/components/green_planet_energy/snapshots/test_services.ambr @@ -0,0 +1,77 @@ +# serializer version: 1 +# name: test_get_prices_basic + dict({ + 'hours_requested': 1.0, + 'prices': list([ + dict({ + 'end': '2026-03-24T14:15:00-07:00', + 'price': 0.34, + 'start': '2026-03-24T14:00:00-07:00', + }), + dict({ + 'end': '2026-03-24T14:30:00-07:00', + 'price': 0.3415, + 'start': '2026-03-24T14:15:00-07:00', + }), + dict({ + 'end': '2026-03-24T14:45:00-07:00', + 'price': 0.343, + 'start': '2026-03-24T14:30:00-07:00', + }), + dict({ + 'end': '2026-03-24T15:00:00-07:00', + 'price': 0.3445, + 'start': '2026-03-24T14:45:00-07:00', + }), + ]), + }) +# --- +# name: test_get_prices_crosses_midnight + dict({ + 'hours_requested': 1.0, + 'prices': list([ + dict({ + 'end': '2026-03-25T00:00:00-07:00', + 'price': 0.4345, + 'start': '2026-03-24T23:45:00-07:00', + }), + dict({ + 'end': '2026-03-25T00:15:00-07:00', + 'price': 0.25, + 'start': '2026-03-25T00:00:00-07:00', + }), + dict({ + 'end': '2026-03-25T00:30:00-07:00', + 'price': 0.2515, + 'start': '2026-03-25T00:15:00-07:00', + }), + dict({ + 'end': '2026-03-25T00:45:00-07:00', + 'price': 0.253, + 'start': '2026-03-25T00:30:00-07:00', + }), + ]), + }) +# --- +# name: test_get_prices_missing_slots_omitted + dict({ + 'hours_requested': 1.0, + 'prices': list([ + dict({ + 'end': '2026-03-24T14:15:00-07:00', + 'price': 0.34, + 'start': '2026-03-24T14:00:00-07:00', + }), + dict({ + 'end': '2026-03-24T14:45:00-07:00', + 'price': 0.343, + 'start': '2026-03-24T14:30:00-07:00', + }), + dict({ + 'end': '2026-03-24T15:00:00-07:00', + 'price': 0.3445, + 'start': '2026-03-24T14:45:00-07:00', + }), + ]), + }) +# --- diff --git a/tests/components/green_planet_energy/test_services.py b/tests/components/green_planet_energy/test_services.py index 153c07e4a707..49c4988573e3 100644 --- a/tests/components/green_planet_energy/test_services.py +++ b/tests/components/green_planet_energy/test_services.py @@ -1,11 +1,18 @@ -"""Test Green Planet Energy services.""" +"""Tests for Green Planet Energy services.""" from unittest.mock import MagicMock from freezegun import freeze_time import pytest +from syrupy.assertion import SnapshotAssertion +import voluptuous as vol from homeassistant.components.green_planet_energy.const import DOMAIN +from homeassistant.components.green_planet_energy.services import ( + ATTR_HOURS, + SERVICE_GET_PRICES, +) +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component @@ -13,28 +20,172 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +async def _call_get_prices(hass: HomeAssistant, hours: float, entry_id: str) -> dict: + """Call get_prices and return the service response.""" + return await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRICES, + {ATTR_CONFIG_ENTRY_ID: entry_id, ATTR_HOURS: hours}, + blocking=True, + return_response=True, + ) + + +async def _call_get_cheapest_duration( + hass: HomeAssistant, duration: float, time_range: str | None = None +) -> dict: + """Call get_cheapest_duration and return the service response.""" + data: dict[str, float | str] = {"duration": duration} + if time_range is not None: + data["time_range"] = time_range + + return await hass.services.async_call( + DOMAIN, + "get_cheapest_duration", + data, + blocking=True, + return_response=True, + ) + + +@pytest.mark.freeze_time("2026-03-24 14:07:00-07:00") +async def test_get_prices_basic( + hass: HomeAssistant, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Requesting 1 hour returns the expected response.""" + result = await _call_get_prices(hass, 1, init_integration.entry_id) + + assert result == snapshot + + +@pytest.mark.freeze_time("2026-03-24 14:07:00-07:00") +async def test_get_prices_slot_start_snapped( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Slot start is snapped to the current 15-minute boundary.""" + result = await _call_get_prices(hass, 0.25, init_integration.entry_id) + + prices = result["prices"] + assert len(prices) == 1 + assert prices[0]["start"].startswith("2026-03-24T14:00:00") + assert prices[0]["end"].startswith("2026-03-24T14:15:00") + + +@pytest.mark.freeze_time("2026-03-24 14:07:00-07:00") +async def test_get_prices_correct_values( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Prices match the expected 15-minute mock data values.""" + result = await _call_get_prices(hass, 1, init_integration.entry_id) + prices = result["prices"] + + expected = [ + (14, 0), + (14, 15), + (14, 30), + (14, 45), + ] + for slot, (hour, minute) in zip(prices, expected, strict=True): + expected_price = round((20.0 + hour + minute / 100) / 100, 6) + assert slot["price"] == pytest.approx(expected_price) + + +@pytest.mark.freeze_time("2026-03-24 23:45:00-07:00") +async def test_get_prices_crosses_midnight( + hass: HomeAssistant, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Slots that cross midnight use the expected response data.""" + result = await _call_get_prices(hass, 1, init_integration.entry_id) + + assert result == snapshot + + +@pytest.mark.freeze_time("2026-03-24 14:07:00-07:00") +async def test_get_prices_missing_slots_omitted( + hass: HomeAssistant, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Missing data keys are omitted from the returned slots.""" + coordinator = init_integration.runtime_data + del coordinator.data["gpe_price_14_15"] + + result = await _call_get_prices(hass, 1, init_integration.entry_id) + + assert result == snapshot + + +async def test_get_prices_entry_not_found(hass: HomeAssistant) -> None: + """Service raises when the config entry does not exist.""" + await async_setup_component(hass, DOMAIN, {}) + with pytest.raises(ServiceValidationError) as exc_info: + await _call_get_prices(hass, 1, "non_existent_entry_id") + assert exc_info.value.translation_key == "service_config_entry_not_found" + + +async def test_get_prices_entry_not_loaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Service raises when the config entry exists but is not loaded.""" + await async_setup_component(hass, DOMAIN, {}) + mock_config_entry.add_to_hass(hass) + with pytest.raises(ServiceValidationError) as exc_info: + await _call_get_prices(hass, 1, mock_config_entry.entry_id) + assert exc_info.value.translation_key == "service_config_entry_not_loaded" + + +@pytest.mark.freeze_time("2026-03-24 14:07:00-07:00") +async def test_get_prices_quarter_hour( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Requesting 0.25 h returns exactly one slot.""" + result = await _call_get_prices(hass, 0.25, init_integration.entry_id) + assert len(result["prices"]) == 1 + assert result["hours_requested"] == 0.25 + + +@pytest.mark.freeze_time("2026-03-24 14:07:00-07:00") +async def test_get_prices_non_quarter_hour_rejected( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Hours must be a multiple of 0.25 according to schema validation.""" + with pytest.raises(vol.Invalid): + await _call_get_prices(hass, 0.3, init_integration.entry_id) + + +@pytest.mark.freeze_time("2026-03-24 00:00:00-07:00") +async def test_get_prices_max_hours( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Requesting 24 h from midnight returns one day of quarter-hour slots.""" + result = await _call_get_prices(hass, 24, init_integration.entry_id) + assert result["hours_requested"] == 24.0 + assert len(result["prices"]) == 96 + + @freeze_time("2024-01-01 08:00:00+00:00") async def test_get_cheapest_duration_day( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock, ) -> None: - """Test get_cheapest_duration service with day time range.""" + """get_cheapest_duration returns expected result for day range.""" await hass.config.async_set_time_zone("UTC") mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - response = await hass.services.async_call( - DOMAIN, - "get_cheapest_duration", - { - "duration": 2.5, - "time_range": "day", - }, - blocking=True, - return_response=True, - ) + response = await _call_get_cheapest_duration(hass, 2.5, "day") assert response["duration"] == 2.5 assert response["average_price"] == 0.266 @@ -51,22 +202,13 @@ async def test_get_cheapest_duration_night( mock_config_entry: MockConfigEntry, mock_api: MagicMock, ) -> None: - """Test get_cheapest_duration service with night time range.""" + """get_cheapest_duration returns expected result for night range.""" await hass.config.async_set_time_zone("UTC") mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - response = await hass.services.async_call( - DOMAIN, - "get_cheapest_duration", - { - "duration": 2.5, - "time_range": "night", - }, - blocking=True, - return_response=True, - ) + response = await _call_get_cheapest_duration(hass, 2.5, "night") assert response["duration"] == 2.5 assert response["average_price"] == 0.258 @@ -83,7 +225,7 @@ async def test_get_cheapest_duration_full_day( mock_config_entry: MockConfigEntry, mock_api: MagicMock, ) -> None: - """Test get_cheapest_duration service with full_day time range.""" + """get_cheapest_duration returns expected result for full day range.""" await hass.config.async_set_time_zone("UTC") mock_api.get_cheapest_duration.return_value = (25.0, 12) @@ -91,16 +233,7 @@ async def test_get_cheapest_duration_full_day( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - response = await hass.services.async_call( - DOMAIN, - "get_cheapest_duration", - { - "duration": 3.0, - "time_range": "full_day", - }, - blocking=True, - return_response=True, - ) + response = await _call_get_cheapest_duration(hass, 3.0, "full_day") assert response["duration"] == 3.0 assert response["average_price"] == 0.25 @@ -117,7 +250,7 @@ async def test_get_cheapest_duration_default_time_range( mock_config_entry: MockConfigEntry, mock_api: MagicMock, ) -> None: - """Test get_cheapest_duration service with default time range.""" + """get_cheapest_duration uses full_day as default time range.""" await hass.config.async_set_time_zone("UTC") mock_api.get_cheapest_duration.return_value = (25.0, 10) @@ -125,15 +258,7 @@ async def test_get_cheapest_duration_default_time_range( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - response = await hass.services.async_call( - DOMAIN, - "get_cheapest_duration", - { - "duration": 1.5, - }, - blocking=True, - return_response=True, - ) + response = await _call_get_cheapest_duration(hass, 1.5) assert response["time_range"] == "full_day" assert response["duration"] == 1.5 @@ -143,45 +268,27 @@ async def test_get_cheapest_duration_default_time_range( assert response["hours_until_start"] == 2.0 -async def test_get_cheapest_duration_no_config_entry( - hass: HomeAssistant, -) -> None: - """Test service error when no config entry exists.""" - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) +async def test_get_cheapest_duration_no_config_entry(hass: HomeAssistant) -> None: + """Service raises when no integration config entry exists.""" + await async_setup_component(hass, DOMAIN, {}) - with pytest.raises( - ServiceValidationError, - match="No matching integration instance was found", - ): - await hass.services.async_call( - DOMAIN, - "get_cheapest_duration", - {"duration": 2.5}, - blocking=True, - return_response=True, - ) + with pytest.raises(ServiceValidationError) as exc_info: + await _call_get_cheapest_duration(hass, 2.5) + assert exc_info.value.translation_key == "no_config_entry" async def test_get_cheapest_duration_config_entry_not_loaded( hass: HomeAssistant, mock_config_entry: MockConfigEntry, ) -> None: - """Test service error when config entry is not loaded.""" - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + """Service raises when config entry exists but is not loaded.""" + await async_setup_component(hass, DOMAIN, {}) mock_config_entry.add_to_hass(hass) - with pytest.raises( - ServiceValidationError, - match="This integration instance is not currently loaded", - ): - await hass.services.async_call( - DOMAIN, - "get_cheapest_duration", - {"duration": 2.5}, - blocking=True, - return_response=True, - ) + with pytest.raises(ServiceValidationError) as exc_info: + await _call_get_cheapest_duration(hass, 2.5) + assert exc_info.value.translation_key == "config_entry_not_loaded" @freeze_time("2024-01-01 08:00:00+00:00") @@ -190,27 +297,16 @@ async def test_get_cheapest_duration_no_data_available( mock_config_entry: MockConfigEntry, mock_api: MagicMock, ) -> None: - """Test service fails when no price data is available.""" + """Service raises when cheapest-duration calculation has no data.""" mock_api.get_cheapest_duration_day.return_value = (None, None) mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - with pytest.raises( - ServiceValidationError, - match="No price data available for the requested duration and time range", - ): - await hass.services.async_call( - DOMAIN, - "get_cheapest_duration", - { - "duration": 2.5, - "time_range": "day", - }, - blocking=True, - return_response=True, - ) + with pytest.raises(ServiceValidationError) as exc_info: + await _call_get_cheapest_duration(hass, 2.5, "day") + assert exc_info.value.translation_key == "no_data_available" @freeze_time("2024-01-01 20:00:00+00:00") @@ -219,27 +315,15 @@ async def test_get_cheapest_duration_past_start_time( mock_config_entry: MockConfigEntry, mock_api: MagicMock, ) -> None: - """Test service handles start times that are in the past (tomorrow).""" - # Mock returns hour 6, but we're at hour 20, so result should be tomorrow + """Service shifts start time to tomorrow when computed start is in the past.""" mock_api.get_cheapest_duration_day.return_value = (26.6, 6) mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - response = await hass.services.async_call( - DOMAIN, - "get_cheapest_duration", - { - "duration": 2.5, - "time_range": "day", - }, - blocking=True, - return_response=True, - ) + response = await _call_get_cheapest_duration(hass, 2.5, "day") - # Start time should be tomorrow since we're past 6:00 today - # hours_until_start should be positive (sometime in the future) assert response["duration"] == 2.5 assert response["hours_until_start"] > 0 assert "start_time" in response diff --git a/tests/components/homekit_controller/fixtures/multi_valve_irrigation_control.json b/tests/components/homekit_controller/fixtures/multi_valve_irrigation_control.json new file mode 100644 index 000000000000..c58a2968f216 --- /dev/null +++ b/tests/components/homekit_controller/fixtures/multi_valve_irrigation_control.json @@ -0,0 +1,424 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "primary": false, + "hidden": false, + "linked": [], + "characteristics": [ + { + "iid": 2, + "type": "00000014-0000-1000-8000-0026BB765291", + "format": "bool", + "perms": ["pw"] + }, + { + "iid": 3, + "type": "00000020-0000-1000-8000-0026BB765291", + "description": "Manufacturer", + "format": "string", + "value": "GARDENA", + "perms": ["pr"] + }, + { + "iid": 4, + "type": "00000021-0000-1000-8000-0026BB765291", + "description": "Model", + "format": "string", + "value": "Irrigation Control", + "perms": ["pr"] + }, + { + "iid": 5, + "type": "00000023-0000-1000-8000-0026BB765291", + "description": "Name", + "format": "string", + "value": "Irrigation Control 00000000", + "perms": ["pr"] + }, + { + "iid": 6, + "type": "00000030-0000-1000-8000-0026BB765291", + "description": "Serial Number", + "format": "string", + "value": "**REDACTED**", + "perms": ["pr"] + }, + { + "iid": 7, + "type": "00000052-0000-1000-8000-0026BB765291", + "description": "Firmware Revision", + "format": "string", + "value": "2.5.0", + "perms": ["pr"] + }, + { + "iid": 8, + "type": "00000053-0000-1000-8000-0026BB765291", + "description": "Hardware Revision", + "format": "string", + "value": "0.0.0", + "perms": ["pr"] + } + ] + }, + { + "iid": 256, + "type": "000000CF-0000-1000-8000-0026BB765291", + "primary": false, + "hidden": false, + "linked": [512, 544, 576], + "characteristics": [ + { + "iid": 258, + "type": "00000023-0000-1000-8000-0026BB765291", + "description": "Name", + "format": "string", + "value": "Irrigation Control 00000000", + "perms": ["pr"] + }, + { + "iid": 259, + "type": "000000B0-0000-1000-8000-0026BB765291", + "description": "Active", + "format": "uint8", + "value": 0, + "minValue": 0, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "pw", "ev"] + }, + { + "iid": 260, + "type": "000000D1-0000-1000-8000-0026BB765291", + "description": "Program Mode", + "format": "uint8", + "value": 1, + "minValue": 0, + "maxValue": 2, + "minStep": 1, + "perms": ["pr", "ev"] + }, + { + "iid": 261, + "type": "000000D2-0000-1000-8000-0026BB765291", + "description": "In Use", + "format": "uint8", + "value": 0, + "minValue": 0, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "ev"] + }, + { + "iid": 262, + "type": "000000D4-0000-1000-8000-0026BB765291", + "description": "Remaining Duration", + "format": "uint32", + "value": 0, + "minValue": 0, + "maxValue": 36000, + "minStep": 1, + "perms": ["pr", "ev"] + }, + { + "iid": 263, + "type": "00000077-0000-1000-8000-0026BB765291", + "description": "Status Fault", + "format": "uint8", + "value": 0, + "minValue": 0, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "ev"] + } + ] + }, + { + "iid": 512, + "type": "000000D0-0000-1000-8000-0026BB765291", + "primary": false, + "hidden": false, + "linked": [], + "characteristics": [ + { + "iid": 514, + "type": "00000023-0000-1000-8000-0026BB765291", + "description": "Name", + "format": "string", + "value": "Valve 1", + "perms": ["pr"] + }, + { + "iid": 515, + "type": "000000B0-0000-1000-8000-0026BB765291", + "description": "Active", + "format": "uint8", + "value": 0, + "minValue": 0, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "pw", "ev"] + }, + { + "iid": 516, + "type": "000000D2-0000-1000-8000-0026BB765291", + "description": "In Use", + "format": "uint8", + "value": 0, + "minValue": 0, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "ev"] + }, + { + "iid": 517, + "type": "000000D5-0000-1000-8000-0026BB765291", + "description": "Valve Type", + "format": "uint8", + "value": 1, + "minValue": 0, + "maxValue": 3, + "minStep": 1, + "perms": ["pr", "ev"] + }, + { + "iid": 518, + "type": "000000D3-0000-1000-8000-0026BB765291", + "description": "Set Duration", + "format": "uint32", + "value": 1200, + "minValue": 30, + "maxValue": 5400, + "minStep": 1, + "perms": ["pr", "pw", "ev"] + }, + { + "iid": 519, + "type": "000000D4-0000-1000-8000-0026BB765291", + "description": "Remaining Duration", + "format": "uint32", + "value": 0, + "minValue": 0, + "maxValue": 36000, + "minStep": 1, + "perms": ["pr", "ev"] + }, + { + "iid": 520, + "type": "000000CB-0000-1000-8000-0026BB765291", + "description": "Service Label Index", + "format": "uint8", + "value": 1, + "minValue": 1, + "maxValue": 6, + "minStep": 1, + "perms": ["pr"] + }, + { + "iid": 521, + "type": "00000077-0000-1000-8000-0026BB765291", + "description": "Status Fault", + "format": "uint8", + "value": 1, + "minValue": 0, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "ev"] + } + ] + }, + { + "iid": 544, + "type": "000000D0-0000-1000-8000-0026BB765291", + "primary": false, + "hidden": false, + "linked": [], + "characteristics": [ + { + "iid": 546, + "type": "00000023-0000-1000-8000-0026BB765291", + "description": "Name", + "format": "string", + "value": "Valve 2", + "perms": ["pr"] + }, + { + "iid": 547, + "type": "000000B0-0000-1000-8000-0026BB765291", + "description": "Active", + "format": "uint8", + "value": 1, + "minValue": 0, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "pw", "ev"] + }, + { + "iid": 548, + "type": "000000D2-0000-1000-8000-0026BB765291", + "description": "In Use", + "format": "uint8", + "value": 1, + "minValue": 0, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "ev"] + }, + { + "iid": 549, + "type": "000000D5-0000-1000-8000-0026BB765291", + "description": "Valve Type", + "format": "uint8", + "value": 1, + "minValue": 0, + "maxValue": 3, + "minStep": 1, + "perms": ["pr", "ev"] + }, + { + "iid": 550, + "type": "000000D3-0000-1000-8000-0026BB765291", + "description": "Set Duration", + "format": "uint32", + "value": 1200, + "minValue": 30, + "maxValue": 5400, + "minStep": 1, + "perms": ["pr", "pw", "ev"] + }, + { + "iid": 551, + "type": "000000D4-0000-1000-8000-0026BB765291", + "description": "Remaining Duration", + "format": "uint32", + "value": 1163, + "minValue": 0, + "maxValue": 36000, + "minStep": 1, + "perms": ["pr", "ev"] + }, + { + "iid": 552, + "type": "000000CB-0000-1000-8000-0026BB765291", + "description": "Service Label Index", + "format": "uint8", + "value": 2, + "minValue": 1, + "maxValue": 6, + "minStep": 1, + "perms": ["pr"] + }, + { + "iid": 553, + "type": "00000077-0000-1000-8000-0026BB765291", + "description": "Status Fault", + "format": "uint8", + "value": 0, + "minValue": 0, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "ev"] + } + ] + }, + { + "iid": 576, + "type": "000000D0-0000-1000-8000-0026BB765291", + "primary": false, + "hidden": false, + "linked": [], + "characteristics": [ + { + "iid": 578, + "type": "00000023-0000-1000-8000-0026BB765291", + "description": "Name", + "format": "string", + "value": "Valve 3", + "perms": ["pr"] + }, + { + "iid": 579, + "type": "000000B0-0000-1000-8000-0026BB765291", + "description": "Active", + "format": "uint8", + "value": 1, + "minValue": 0, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "pw", "ev"] + }, + { + "iid": 580, + "type": "000000D2-0000-1000-8000-0026BB765291", + "description": "In Use", + "format": "uint8", + "value": 1, + "minValue": 0, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "ev"] + }, + { + "iid": 581, + "type": "000000D5-0000-1000-8000-0026BB765291", + "description": "Valve Type", + "format": "uint8", + "value": 1, + "minValue": 0, + "maxValue": 3, + "minStep": 1, + "perms": ["pr", "ev"] + }, + { + "iid": 582, + "type": "000000D3-0000-1000-8000-0026BB765291", + "description": "Set Duration", + "format": "uint32", + "value": 1200, + "minValue": 30, + "maxValue": 5400, + "minStep": 1, + "perms": ["pr", "pw", "ev"] + }, + { + "iid": 583, + "type": "000000D4-0000-1000-8000-0026BB765291", + "description": "Remaining Duration", + "format": "uint32", + "value": 1166, + "minValue": 0, + "maxValue": 36000, + "minStep": 1, + "perms": ["pr", "ev"] + }, + { + "iid": 584, + "type": "000000CB-0000-1000-8000-0026BB765291", + "description": "Service Label Index", + "format": "uint8", + "value": 3, + "minValue": 1, + "maxValue": 6, + "minStep": 1, + "perms": ["pr"] + }, + { + "iid": 585, + "type": "00000077-0000-1000-8000-0026BB765291", + "description": "Status Fault", + "format": "uint8", + "value": 0, + "minValue": 0, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "ev"] + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index dac4c4d2a22b..e21e84376994 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -3412,6 +3412,51 @@ 'state': 'off', }), }), + dict({ + '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.basement_low_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Basement Low Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Low Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_55_4113', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + : 'battery', + : 'Basement Low Battery', + }), + 'entity_id': 'binary_sensor.basement_low_battery', + 'state': 'off', + }), + }), dict({ 'entry': EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -4079,6 +4124,51 @@ 'state': 'off', }), }), + dict({ + '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.kitchen_low_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Kitchen Low Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Kitchen Low Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_55_2065', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + : 'battery', + : 'Kitchen Low Battery', + }), + 'entity_id': 'binary_sensor.kitchen_low_battery', + 'state': 'off', + }), + }), dict({ 'entry': EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -4253,6 +4343,51 @@ 'state': 'off', }), }), + dict({ + '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.porch_low_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Porch Low Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Porch Low Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_55_3089', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + : 'battery', + : 'Porch Low Battery', + }), + 'entity_id': 'binary_sensor.porch_low_battery', + 'state': 'off', + }), + }), dict({ 'entry': EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -8511,6 +8646,51 @@ 'state': 'off', }), }), + dict({ + '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.basement_low_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Basement Low Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Low Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_56_4109', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + : 'battery', + : 'Basement Low Battery', + }), + 'entity_id': 'binary_sensor.basement_low_battery', + 'state': 'off', + }), + }), dict({ 'entry': EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -8937,6 +9117,51 @@ 'state': 'off', }), }), + dict({ + '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.kitchen_low_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Kitchen Low Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Kitchen Low Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_55_2065', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + : 'battery', + : 'Kitchen Low Battery', + }), + 'entity_id': 'binary_sensor.kitchen_low_battery', + 'state': 'off', + }), + }), dict({ 'entry': EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -9111,6 +9336,51 @@ 'state': 'off', }), }), + dict({ + '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.porch_low_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Porch Low Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Porch Low Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_55_3089', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + : 'battery', + : 'Porch Low Battery', + }), + 'entity_id': 'binary_sensor.porch_low_battery', + 'state': 'off', + }), + }), dict({ 'entry': EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -18944,6 +19214,570 @@ }), ]) # --- +# name: test_snapshots[multi_valve_irrigation_control] + list([ + dict({ + 'device': DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '0.0.0', + 'id': , + 'identifiers': set({ + tuple( + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'GARDENA', + 'model': 'Irrigation Control', + 'model_id': None, + 'name': 'Irrigation Control 00000000', + 'name_by_user': None, + 'serial_number': '**REDACTED**', + 'sw_version': '2.5.0', + 'via_device_id': None, + }), + 'entities': list([ + dict({ + '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.irrigation_control_00000000_problem', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Irrigation Control 00000000 Problem', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Irrigation Control 00000000 Problem', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_256_263', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + : 'problem', + : 'Irrigation Control 00000000 Problem', + }), + 'entity_id': 'binary_sensor.irrigation_control_00000000_problem', + 'state': 'off', + }), + }), + dict({ + '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.irrigation_control_00000000_problem_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Irrigation Control 00000000 Problem', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Irrigation Control 00000000 Problem', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_512_521', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + : 'problem', + : 'Irrigation Control 00000000 Problem', + }), + 'entity_id': 'binary_sensor.irrigation_control_00000000_problem_2', + 'state': 'on', + }), + }), + dict({ + '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.irrigation_control_00000000_problem_3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Irrigation Control 00000000 Problem', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Irrigation Control 00000000 Problem', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_544_553', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + : 'problem', + : 'Irrigation Control 00000000 Problem', + }), + 'entity_id': 'binary_sensor.irrigation_control_00000000_problem_3', + 'state': 'off', + }), + }), + dict({ + '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.irrigation_control_00000000_problem_4', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Irrigation Control 00000000 Problem', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Irrigation Control 00000000 Problem', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_576_585', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + : 'problem', + : 'Irrigation Control 00000000 Problem', + }), + 'entity_id': 'binary_sensor.irrigation_control_00000000_problem_4', + 'state': 'off', + }), + }), + dict({ + 'entry': EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.irrigation_control_00000000_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Irrigation Control 00000000 Identify', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Irrigation Control 00000000 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + : 'identify', + : 'Irrigation Control 00000000 Identify', + }), + 'entity_id': 'button.irrigation_control_00000000_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 5400, + : 30, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.irrigation_control_00000000_duration', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Irrigation Control 00000000 Duration', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Irrigation Control 00000000 Duration', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'duration', + 'unique_id': '00:00:00:00:00:00_1_512_518', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + : 'duration', + : 'Irrigation Control 00000000 Duration', + : 5400, + : 30, + : , + : 1, + : , + }), + 'entity_id': 'number.irrigation_control_00000000_duration', + 'state': '1200', + }), + }), + dict({ + 'entry': EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 5400, + : 30, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.irrigation_control_00000000_duration_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Irrigation Control 00000000 Duration', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Irrigation Control 00000000 Duration', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'duration', + 'unique_id': '00:00:00:00:00:00_1_544_550', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + : 'duration', + : 'Irrigation Control 00000000 Duration', + : 5400, + : 30, + : , + : 1, + : , + }), + 'entity_id': 'number.irrigation_control_00000000_duration_2', + 'state': '1200', + }), + }), + dict({ + 'entry': EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 5400, + : 30, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.irrigation_control_00000000_duration_3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Irrigation Control 00000000 Duration', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Irrigation Control 00000000 Duration', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'duration', + 'unique_id': '00:00:00:00:00:00_1_576_582', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + : 'duration', + : 'Irrigation Control 00000000 Duration', + : 5400, + : 30, + : , + : 1, + : , + }), + 'entity_id': 'number.irrigation_control_00000000_duration_3', + 'state': '1200', + }), + }), + dict({ + 'entry': EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.irrigation_control_00000000_valve_1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Irrigation Control 00000000 Valve 1', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Irrigation Control 00000000 Valve 1', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'valve', + 'unique_id': '00:00:00:00:00:00_1_512', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + : 'Irrigation Control 00000000 Valve 1', + 'in_use': False, + 'remaining_duration': 0, + }), + 'entity_id': 'switch.irrigation_control_00000000_valve_1', + 'state': 'off', + }), + }), + dict({ + 'entry': EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.irrigation_control_00000000_valve_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Irrigation Control 00000000 Valve 2', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Irrigation Control 00000000 Valve 2', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'valve', + 'unique_id': '00:00:00:00:00:00_1_544', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + : 'Irrigation Control 00000000 Valve 2', + 'in_use': True, + 'remaining_duration': 1163, + }), + 'entity_id': 'switch.irrigation_control_00000000_valve_2', + 'state': 'on', + }), + }), + dict({ + 'entry': EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.irrigation_control_00000000_valve_3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Irrigation Control 00000000 Valve 3', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Irrigation Control 00000000 Valve 3', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'valve', + 'unique_id': '00:00:00:00:00:00_1_576', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + : 'Irrigation Control 00000000 Valve 3', + 'in_use': True, + 'remaining_duration': 1166, + }), + 'entity_id': 'switch.irrigation_control_00000000_valve_3', + 'state': 'on', + }), + }), + ]), + }), + ]) +# --- # name: test_snapshots[mysa_living] list([ dict({ @@ -20018,6 +20852,51 @@ 'state': 'off', }), }), + dict({ + '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.smart_co_alarm_problem', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Smart CO Alarm Problem', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Smart CO Alarm Problem', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_22_231', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + : 'problem', + : 'Smart CO Alarm Problem', + }), + 'entity_id': 'binary_sensor.smart_co_alarm_problem', + 'state': 'off', + }), + }), dict({ 'entry': EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -20475,6 +21354,446 @@ 'state': 'unknown', }), }), + dict({ + 'entry': EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 86400, + : 0.0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.rainmachine_00ce4a_duration', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'RainMachine-00ce4a Duration', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RainMachine-00ce4a Duration', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'duration', + 'unique_id': '00:00:00:00:00:00_1_512_624', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + : 'duration', + : 'RainMachine-00ce4a Duration', + : 86400, + : 0.0, + : , + : 1, + : , + }), + 'entity_id': 'number.rainmachine_00ce4a_duration', + 'state': '300', + }), + }), + dict({ + 'entry': EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 86400, + : 0.0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.rainmachine_00ce4a_duration_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'RainMachine-00ce4a Duration', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RainMachine-00ce4a Duration', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'duration', + 'unique_id': '00:00:00:00:00:00_1_768_880', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + : 'duration', + : 'RainMachine-00ce4a Duration', + : 86400, + : 0.0, + : , + : 1, + : , + }), + 'entity_id': 'number.rainmachine_00ce4a_duration_2', + 'state': '300', + }), + }), + dict({ + 'entry': EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 86400, + : 0.0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.rainmachine_00ce4a_duration_3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'RainMachine-00ce4a Duration', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RainMachine-00ce4a Duration', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'duration', + 'unique_id': '00:00:00:00:00:00_1_1024_1136', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + : 'duration', + : 'RainMachine-00ce4a Duration', + : 86400, + : 0.0, + : , + : 1, + : , + }), + 'entity_id': 'number.rainmachine_00ce4a_duration_3', + 'state': '300', + }), + }), + dict({ + 'entry': EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 86400, + : 0.0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.rainmachine_00ce4a_duration_4', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'RainMachine-00ce4a Duration', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RainMachine-00ce4a Duration', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'duration', + 'unique_id': '00:00:00:00:00:00_1_1280_1392', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + : 'duration', + : 'RainMachine-00ce4a Duration', + : 86400, + : 0.0, + : , + : 1, + : , + }), + 'entity_id': 'number.rainmachine_00ce4a_duration_4', + 'state': '300', + }), + }), + dict({ + 'entry': EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 86400, + : 0.0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.rainmachine_00ce4a_duration_5', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'RainMachine-00ce4a Duration', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RainMachine-00ce4a Duration', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'duration', + 'unique_id': '00:00:00:00:00:00_1_1536_1648', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + : 'duration', + : 'RainMachine-00ce4a Duration', + : 86400, + : 0.0, + : , + : 1, + : , + }), + 'entity_id': 'number.rainmachine_00ce4a_duration_5', + 'state': '300', + }), + }), + dict({ + 'entry': EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 86400, + : 0.0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.rainmachine_00ce4a_duration_6', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'RainMachine-00ce4a Duration', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RainMachine-00ce4a Duration', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'duration', + 'unique_id': '00:00:00:00:00:00_1_1792_1904', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + : 'duration', + : 'RainMachine-00ce4a Duration', + : 86400, + : 0.0, + : , + : 1, + : , + }), + 'entity_id': 'number.rainmachine_00ce4a_duration_6', + 'state': '300', + }), + }), + dict({ + 'entry': EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 86400, + : 0.0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.rainmachine_00ce4a_duration_7', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'RainMachine-00ce4a Duration', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RainMachine-00ce4a Duration', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'duration', + 'unique_id': '00:00:00:00:00:00_1_2048_2160', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + : 'duration', + : 'RainMachine-00ce4a Duration', + : 86400, + : 0.0, + : , + : 1, + : , + }), + 'entity_id': 'number.rainmachine_00ce4a_duration_7', + 'state': '300', + }), + }), + dict({ + 'entry': EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 86400, + : 0.0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.rainmachine_00ce4a_duration_8', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'RainMachine-00ce4a Duration', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RainMachine-00ce4a Duration', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'duration', + 'unique_id': '00:00:00:00:00:00_1_2304_2416', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + : 'duration', + : 'RainMachine-00ce4a Duration', + : 86400, + : 0.0, + : , + : 1, + : , + }), + 'entity_id': 'number.rainmachine_00ce4a_duration_8', + 'state': '300', + }), + }), dict({ 'entry': EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/homekit_controller/test_binary_sensor.py b/tests/components/homekit_controller/test_binary_sensor.py index a46d5eca2f5a..3686823361c3 100644 --- a/tests/components/homekit_controller/test_binary_sensor.py +++ b/tests/components/homekit_controller/test_binary_sensor.py @@ -4,13 +4,13 @@ from collections.abc import Callable from aiohomekit.model import Accessory from aiohomekit.model.characteristics import CharacteristicsTypes -from aiohomekit.model.services import ServicesTypes +from aiohomekit.model.services import Service, ServicesTypes from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import setup_test_component +from .common import Helper, setup_test_accessories, setup_test_component def create_motion_sensor_service(accessory: Accessory) -> None: @@ -178,6 +178,112 @@ def create_leak_sensor_service(accessory: Accessory) -> None: cur_state.value = 0 +def create_valve_with_status_characteristics(accessory: Accessory) -> Service: + """Define valve characteristics with status binary sensors.""" + service = accessory.add_service(ServicesTypes.VALVE, name="TestDevice") + + active = service.add_char(CharacteristicsTypes.ACTIVE) + active.value = False + + low_battery = service.add_char(CharacteristicsTypes.STATUS_LO_BATT) + low_battery.value = 0 + + fault = service.add_char(CharacteristicsTypes.STATUS_FAULT) + fault.value = 0 + + return service + + +def create_sensor_with_duplicate_low_battery_characteristics( + accessory: Accessory, +) -> None: + """Define sensor services that repeat the same low battery status.""" + for service_type, characteristic_type in ( + (ServicesTypes.TEMPERATURE_SENSOR, CharacteristicsTypes.TEMPERATURE_CURRENT), + ( + ServicesTypes.HUMIDITY_SENSOR, + CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT, + ), + ): + service = accessory.add_service(service_type, name="Shared Sensor") + + current = service.add_char(characteristic_type) + current.value = 0 + + low_battery = service.add_char(CharacteristicsTypes.STATUS_LO_BATT) + low_battery.value = 0 + + +def create_sensor_with_unnamed_low_battery_characteristics( + accessory: Accessory, +) -> None: + """Define unnamed sensor services that repeat the same low battery status.""" + for service_type, characteristic_type in ( + (ServicesTypes.TEMPERATURE_SENSOR, CharacteristicsTypes.TEMPERATURE_CURRENT), + ( + ServicesTypes.HUMIDITY_SENSOR, + CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT, + ), + ): + service = accessory.add_service(service_type) + + current = service.add_char(characteristic_type) + current.value = 0 + + low_battery = service.add_char(CharacteristicsTypes.STATUS_LO_BATT) + low_battery.value = 0 + + +def create_sensor_with_named_low_battery_characteristic(accessory: Accessory) -> None: + """Define a named sensor service with low battery status.""" + service = accessory.add_service( + ServicesTypes.TEMPERATURE_SENSOR, name="Temperature" + ) + + current = service.add_char(CharacteristicsTypes.TEMPERATURE_CURRENT) + current.value = 0 + + low_battery = service.add_char(CharacteristicsTypes.STATUS_LO_BATT) + low_battery.value = 0 + + +def create_labeled_valves_with_low_battery_characteristics( + accessory: Accessory, +) -> None: + """Define labeled valve services with low battery status.""" + for label_index in (1.0, 2.0): + service = accessory.add_service(ServicesTypes.VALVE, name="Valve") + + service_label_index = service.add_char(CharacteristicsTypes.SERVICE_LABEL_INDEX) + service_label_index.value = label_index + + active = service.add_char(CharacteristicsTypes.ACTIVE) + active.value = False + + low_battery = service.add_char(CharacteristicsTypes.STATUS_LO_BATT) + low_battery.value = 0 + + +def create_sensor_with_battery_service(accessory: Accessory) -> None: + """Define a sensor with its own battery service.""" + service = accessory.add_service( + ServicesTypes.TEMPERATURE_SENSOR, name="Temperature" + ) + + current = service.add_char(CharacteristicsTypes.TEMPERATURE_CURRENT) + current.value = 0 + + low_battery = service.add_char(CharacteristicsTypes.STATUS_LO_BATT) + low_battery.value = 0 + + battery = accessory.add_service(ServicesTypes.BATTERY_SERVICE, name="Battery") + battery_level = battery.add_char(CharacteristicsTypes.BATTERY_LEVEL) + battery_level.value = 100 + + battery_low = battery.add_char(CharacteristicsTypes.STATUS_LO_BATT) + battery_low.value = 0 + + async def test_leak_sensor_read_state( hass: HomeAssistant, get_next_aid: Callable[[], int] ) -> None: @@ -201,6 +307,133 @@ async def test_leak_sensor_read_state( assert state.attributes["device_class"] == BinarySensorDeviceClass.MOISTURE +async def test_valve_status_binary_sensors( + hass: HomeAssistant, + get_next_aid: Callable[[], int], +) -> None: + """Test valve status characteristics are exposed as binary sensors.""" + helper = await setup_test_component( + hass, get_next_aid(), create_valve_with_status_characteristics + ) + + low_battery = Helper( + hass, + "binary_sensor.testdevice_low_battery", + helper.pairing, + helper.accessory, + helper.config_entry, + ) + fault = Helper( + hass, + "binary_sensor.testdevice_problem", + helper.pairing, + helper.accessory, + helper.config_entry, + ) + + state = await low_battery.poll_and_get_state() + assert state.state == "off" + assert state.attributes["device_class"] == BinarySensorDeviceClass.BATTERY + + state = await low_battery.async_update( + ServicesTypes.VALVE, + {CharacteristicsTypes.STATUS_LO_BATT: 1}, + ) + assert state.state == "on" + + state = await fault.poll_and_get_state() + assert state.state == "off" + assert state.attributes["device_class"] == BinarySensorDeviceClass.PROBLEM + + state = await fault.async_update( + ServicesTypes.VALVE, + {CharacteristicsTypes.STATUS_FAULT: 1}, + ) + assert state.state == "on" + + +async def test_duplicate_low_battery_characteristics_create_single_binary_sensor( + hass: HomeAssistant, + get_next_aid: Callable[[], int], +) -> None: + """Test repeated low battery characteristics on one sensor create one entity.""" + accessory = Accessory.create_with_info( + get_next_aid(), "Shared Sensor", "example.com", "Test", "0001", "0.1" + ) + create_sensor_with_duplicate_low_battery_characteristics(accessory) + + await setup_test_accessories(hass, [accessory]) + + low_battery = hass.states.get("binary_sensor.shared_sensor_low_battery") + assert low_battery + assert hass.states.get("binary_sensor.shared_sensor_low_battery_2") is None + + +async def test_unnamed_low_battery_characteristics_create_single_binary_sensor( + hass: HomeAssistant, + get_next_aid: Callable[[], int], +) -> None: + """Test unnamed low battery characteristics on one sensor create one entity.""" + accessory = Accessory.create_with_info( + get_next_aid(), "Unnamed Sensor", "example.com", "Test", "0001", "0.1" + ) + create_sensor_with_unnamed_low_battery_characteristics(accessory) + + await setup_test_accessories(hass, [accessory]) + + low_battery = hass.states.get("binary_sensor.unnamed_sensor_low_battery") + assert low_battery + assert hass.states.get("binary_sensor.unnamed_sensor_low_battery_2") is None + + +async def test_named_low_battery_characteristic_creates_binary_sensor( + hass: HomeAssistant, + get_next_aid: Callable[[], int], +) -> None: + """Test low battery characteristics on named services create an entity.""" + accessory = Accessory.create_with_info( + get_next_aid(), "Outdoor Sensor", "example.com", "Test", "0001", "0.1" + ) + create_sensor_with_named_low_battery_characteristic(accessory) + + await setup_test_accessories(hass, [accessory]) + + low_battery = hass.states.get("binary_sensor.outdoor_sensor_low_battery") + assert low_battery + + +async def test_labeled_low_battery_characteristics_create_binary_sensors( + hass: HomeAssistant, + get_next_aid: Callable[[], int], +) -> None: + """Test low battery characteristics on labeled services create entities.""" + await setup_test_component( + hass, get_next_aid(), create_labeled_valves_with_low_battery_characteristics + ) + + valve_1 = hass.states.get("binary_sensor.testdevice_low_battery") + assert valve_1 + + valve_2 = hass.states.get("binary_sensor.testdevice_low_battery_2") + assert valve_2 + + +async def test_low_battery_characteristic_ignored_with_battery_service( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test low battery characteristics are ignored when a battery service exists.""" + accessory = Accessory.create_with_info( + get_next_aid(), "Outdoor Sensor", "example.com", "Test", "0001", "0.1" + ) + create_sensor_with_battery_service(accessory) + + await setup_test_accessories(hass, [accessory]) + + assert hass.states.get("sensor.outdoor_sensor_battery") + assert hass.states.get("binary_sensor.outdoor_sensor_battery") is None + assert hass.states.get("binary_sensor.outdoor_sensor_low_battery") is None + + async def test_migrate_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/homekit_controller/test_number.py b/tests/components/homekit_controller/test_number.py index b476b4f294c7..f507e2bf4b6c 100644 --- a/tests/components/homekit_controller/test_number.py +++ b/tests/components/homekit_controller/test_number.py @@ -6,6 +6,8 @@ from aiohomekit.model import Accessory from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import Service, ServicesTypes +from homeassistant.components.number import NumberDeviceClass +from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -33,6 +35,22 @@ def create_switch_with_spray_level(accessory: Accessory) -> Service: return service +def create_valve_with_set_duration(accessory: Accessory) -> Service: + """Define valve characteristics with a set duration.""" + service = accessory.add_service(ServicesTypes.VALVE) + + active = service.add_char(CharacteristicsTypes.ACTIVE) + active.value = False + + set_duration = service.add_char(CharacteristicsTypes.SET_DURATION) + set_duration.value = 1200 + set_duration.minValue = 0 + set_duration.maxValue = 5400 + set_duration.minStep = 60 + + return service + + async def test_migrate_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -124,3 +142,46 @@ async def test_write_number( ServicesTypes.OUTLET, {CharacteristicsTypes.VENDOR_VOCOLINC_HUMIDIFIER_SPRAY_LEVEL: 3}, ) + + +async def test_valve_set_duration_number( + hass: HomeAssistant, + get_next_aid: Callable[[], int], +) -> None: + """Test a valve service set duration characteristic is correctly handled.""" + helper = await setup_test_component( + hass, get_next_aid(), create_valve_with_set_duration + ) + + set_duration = Helper( + hass, + "number.testdevice_duration", + helper.pairing, + helper.accessory, + helper.config_entry, + ) + + state = await set_duration.poll_and_get_state() + assert state.state == "1200" + assert state.attributes["device_class"] == NumberDeviceClass.DURATION + assert state.attributes["unit_of_measurement"] == UnitOfTime.SECONDS + assert state.attributes["step"] == 60 + assert state.attributes["min"] == 0 + assert state.attributes["max"] == 5400 + + state = await set_duration.async_update( + ServicesTypes.VALVE, + {CharacteristicsTypes.SET_DURATION: 1800}, + ) + assert state.state == "1800" + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": "number.testdevice_duration", "value": 600}, + blocking=True, + ) + set_duration.async_assert_service_values( + ServicesTypes.VALVE, + {CharacteristicsTypes.SET_DURATION: 600}, + ) diff --git a/tests/components/homewizard/snapshots/test_button.ambr b/tests/components/homewizard/snapshots/test_button.ambr index 6d717631cbe2..8256f2fa6d1f 100644 --- a/tests/components/homewizard/snapshots/test_button.ambr +++ b/tests/components/homewizard/snapshots/test_button.ambr @@ -75,7 +75,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index c465608be87d..eed458518a05 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -99,7 +99,7 @@ 'api_version': '1.0.0', 'firmware_version': '3.06', 'id': '**REDACTED**', - 'model_name': 'Wi-Fi kWh Meter 1-phase', + 'model_name': 'kWh Meter 1-phase', 'product_name': 'kWh meter', 'product_type': 'HWE-KWH1', 'serial': '**REDACTED**', @@ -191,7 +191,7 @@ 'api_version': '1.0.0', 'firmware_version': '3.06', 'id': '**REDACTED**', - 'model_name': 'Wi-Fi kWh Meter 3-phase', + 'model_name': 'kWh Meter 3-phase', 'product_name': 'KWh meter 3-phase', 'product_type': 'HWE-KWH3', 'serial': '**REDACTED**', @@ -294,7 +294,7 @@ 'api_version': '1.0.0', 'firmware_version': '4.19', 'id': '**REDACTED**', - 'model_name': 'Wi-Fi P1 Meter', + 'model_name': 'P1 Meter', 'product_name': 'P1 meter', 'product_type': 'HWE-P1', 'serial': '**REDACTED**', @@ -422,7 +422,7 @@ 'api_version': '1.0.0', 'firmware_version': '3.03', 'id': '**REDACTED**', - 'model_name': 'Wi-Fi Energy Socket', + 'model_name': 'Energy Socket', 'product_name': 'Energy Socket', 'product_type': 'HWE-SKT', 'serial': '**REDACTED**', @@ -518,7 +518,7 @@ 'api_version': '1.0.0', 'firmware_version': '4.07', 'id': '**REDACTED**', - 'model_name': 'Wi-Fi Energy Socket', + 'model_name': 'Energy Socket', 'product_name': 'Energy Socket', 'product_type': 'HWE-SKT', 'serial': '**REDACTED**', @@ -614,7 +614,7 @@ 'api_version': '1.0.0', 'firmware_version': '2.03', 'id': '**REDACTED**', - 'model_name': 'Wi-Fi Watermeter', + 'model_name': 'Watermeter', 'product_name': 'Watermeter', 'product_type': 'HWE-WTR', 'serial': '**REDACTED**', @@ -706,7 +706,7 @@ 'api_version': '1.0.0', 'firmware_version': '3.06', 'id': '**REDACTED**', - 'model_name': 'Wi-Fi kWh Meter 1-phase', + 'model_name': 'kWh Meter 1-phase', 'product_name': 'kWh meter', 'product_type': 'SDM230-wifi', 'serial': '**REDACTED**', @@ -798,7 +798,7 @@ 'api_version': '1.0.0', 'firmware_version': '3.06', 'id': '**REDACTED**', - 'model_name': 'Wi-Fi kWh Meter 3-phase', + 'model_name': 'kWh Meter 3-phase', 'product_name': 'KWh meter 3-phase', 'product_type': 'SDM630-wifi', 'serial': '**REDACTED**', diff --git a/tests/components/homewizard/snapshots/test_number.ambr b/tests/components/homewizard/snapshots/test_number.ambr index e06105aa381d..67b9797f65e3 100644 --- a/tests/components/homewizard/snapshots/test_number.ambr +++ b/tests/components/homewizard/snapshots/test_number.ambr @@ -84,7 +84,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Energy Socket', + 'model': 'Energy Socket', 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, @@ -178,7 +178,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Energy Socket', + 'model': 'Energy Socket', 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, diff --git a/tests/components/homewizard/snapshots/test_select.ambr b/tests/components/homewizard/snapshots/test_select.ambr index 7c1138e33c03..f7972b40b667 100644 --- a/tests/components/homewizard/snapshots/test_select.ambr +++ b/tests/components/homewizard/snapshots/test_select.ambr @@ -85,7 +85,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index a54dc0cc69b3..108bec8ad412 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -977,7 +977,7 @@ 'supported_features': 0, 'translation_key': 'wifi_rssi', 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_rssi', - 'unit_of_measurement': 'dB', + 'unit_of_measurement': 'dBm', }) # --- # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_wi_fi_rssi:state] @@ -985,7 +985,7 @@ 'attributes': ReadOnlyDict({ : 'Device Wi-Fi RSSI', : , - : 'dB', + : 'dBm', }), 'context': , 'entity_id': 'sensor.device_wi_fi_rssi', @@ -1104,7 +1104,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 1-phase', + 'model': 'kWh Meter 1-phase', 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, @@ -1196,7 +1196,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 1-phase', + 'model': 'kWh Meter 1-phase', 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, @@ -1288,7 +1288,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 1-phase', + 'model': 'kWh Meter 1-phase', 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, @@ -1380,7 +1380,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 1-phase', + 'model': 'kWh Meter 1-phase', 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, @@ -1472,7 +1472,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 1-phase', + 'model': 'kWh Meter 1-phase', 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, @@ -1564,7 +1564,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 1-phase', + 'model': 'kWh Meter 1-phase', 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, @@ -1656,7 +1656,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 1-phase', + 'model': 'kWh Meter 1-phase', 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, @@ -1745,7 +1745,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 1-phase', + 'model': 'kWh Meter 1-phase', 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, @@ -1837,7 +1837,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 1-phase', + 'model': 'kWh Meter 1-phase', 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, @@ -1929,7 +1929,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 1-phase', + 'model': 'kWh Meter 1-phase', 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, @@ -2021,7 +2021,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 1-phase', + 'model': 'kWh Meter 1-phase', 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, @@ -2105,7 +2105,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 1-phase', + 'model': 'kWh Meter 1-phase', 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, @@ -2193,7 +2193,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -2285,7 +2285,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -2377,7 +2377,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -2469,7 +2469,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -2561,7 +2561,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -2653,7 +2653,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -2745,7 +2745,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -2837,7 +2837,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -2929,7 +2929,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -3021,7 +3021,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -3113,7 +3113,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -3205,7 +3205,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -3297,7 +3297,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -3386,7 +3386,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -3475,7 +3475,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -3564,7 +3564,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -3656,7 +3656,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -3748,7 +3748,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -3840,7 +3840,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -3932,7 +3932,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -4024,7 +4024,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -4116,7 +4116,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -4208,7 +4208,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -4300,7 +4300,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -4392,7 +4392,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -4484,7 +4484,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -4576,7 +4576,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -4660,7 +4660,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -4748,7 +4748,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -4837,7 +4837,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -4929,7 +4929,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -5021,7 +5021,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -5113,7 +5113,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -5205,7 +5205,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -5297,7 +5297,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -5381,7 +5381,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -5473,7 +5473,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -5565,7 +5565,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -5657,7 +5657,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -5749,7 +5749,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -5841,7 +5841,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -5933,7 +5933,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -6025,7 +6025,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -6117,7 +6117,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -6209,7 +6209,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -6301,7 +6301,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -6393,7 +6393,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -6477,7 +6477,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -6566,7 +6566,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -6658,7 +6658,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -6742,7 +6742,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -6834,7 +6834,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -6926,7 +6926,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -7018,7 +7018,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -7102,7 +7102,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -7186,7 +7186,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -7284,7 +7284,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -7376,7 +7376,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -7468,7 +7468,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -7560,7 +7560,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -7652,7 +7652,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -7736,7 +7736,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -7820,7 +7820,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -7904,7 +7904,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -7988,7 +7988,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -8072,7 +8072,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -8156,7 +8156,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -8248,7 +8248,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -8332,7 +8332,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -8416,7 +8416,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Gas meter', 'name_by_user': None, @@ -8504,7 +8504,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Heat meter', 'name_by_user': None, @@ -8592,7 +8592,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Inlet heat meter', 'name_by_user': None, @@ -8676,7 +8676,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Warm water meter', 'name_by_user': None, @@ -8764,7 +8764,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Water meter', 'name_by_user': None, @@ -8856,7 +8856,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -8945,7 +8945,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -9037,7 +9037,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -9129,7 +9129,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -9221,7 +9221,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -9305,7 +9305,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -9397,7 +9397,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -9489,7 +9489,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -9581,7 +9581,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -9673,7 +9673,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -9765,7 +9765,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -9857,7 +9857,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -9949,7 +9949,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -10041,7 +10041,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -10133,7 +10133,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -10225,7 +10225,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -10317,7 +10317,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -10401,7 +10401,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -10490,7 +10490,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -10582,7 +10582,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -10666,7 +10666,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -10758,7 +10758,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -10850,7 +10850,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -10942,7 +10942,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -11026,7 +11026,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -11110,7 +11110,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -11208,7 +11208,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -11300,7 +11300,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -11392,7 +11392,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -11484,7 +11484,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -11576,7 +11576,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -11660,7 +11660,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -11744,7 +11744,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -11828,7 +11828,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -11912,7 +11912,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -11996,7 +11996,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -12080,7 +12080,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -12172,7 +12172,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -12256,7 +12256,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -12340,7 +12340,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Gas meter', 'name_by_user': None, @@ -12428,7 +12428,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Heat meter', 'name_by_user': None, @@ -12516,7 +12516,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Inlet heat meter', 'name_by_user': None, @@ -12600,7 +12600,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Warm water meter', 'name_by_user': None, @@ -12688,7 +12688,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Water meter', 'name_by_user': None, @@ -12780,7 +12780,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -12869,7 +12869,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -12961,7 +12961,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -13053,7 +13053,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -13145,7 +13145,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -13237,7 +13237,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -13329,7 +13329,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -13413,7 +13413,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -13505,7 +13505,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -13597,7 +13597,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -13689,7 +13689,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -13781,7 +13781,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -13873,7 +13873,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -13965,7 +13965,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -14057,7 +14057,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -14149,7 +14149,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -14241,7 +14241,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -14333,7 +14333,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -14425,7 +14425,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -14509,7 +14509,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -14598,7 +14598,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -14690,7 +14690,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -14774,7 +14774,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -14866,7 +14866,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -14958,7 +14958,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -15050,7 +15050,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -15134,7 +15134,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -15218,7 +15218,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -15316,7 +15316,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -15408,7 +15408,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -15500,7 +15500,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -15592,7 +15592,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -15684,7 +15684,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -15768,7 +15768,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -15852,7 +15852,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -15936,7 +15936,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -16020,7 +16020,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -16104,7 +16104,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -16188,7 +16188,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -16280,7 +16280,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -16364,7 +16364,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -16448,7 +16448,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Gas meter', 'name_by_user': None, @@ -16536,7 +16536,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Heat meter', 'name_by_user': None, @@ -16624,7 +16624,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Inlet heat meter', 'name_by_user': None, @@ -16708,7 +16708,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Warm water meter', 'name_by_user': None, @@ -16796,7 +16796,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Water meter', 'name_by_user': None, @@ -16888,7 +16888,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -16977,7 +16977,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -17069,7 +17069,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -17161,7 +17161,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -17253,7 +17253,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -17345,7 +17345,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -17437,7 +17437,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -17529,7 +17529,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -17621,7 +17621,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -17713,7 +17713,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -17805,7 +17805,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -17897,7 +17897,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -17989,7 +17989,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -18081,7 +18081,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -18173,7 +18173,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -18265,7 +18265,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -18349,7 +18349,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -18441,7 +18441,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -18525,7 +18525,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -18617,7 +18617,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -18709,7 +18709,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -18801,7 +18801,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -18893,7 +18893,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -18985,7 +18985,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -19077,7 +19077,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -19169,7 +19169,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -19253,7 +19253,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -19337,7 +19337,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -19421,7 +19421,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -19505,7 +19505,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -19589,7 +19589,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -19673,7 +19673,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -19765,7 +19765,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -19849,7 +19849,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi P1 Meter', + 'model': 'P1 Meter', 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, @@ -19937,7 +19937,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Energy Socket', + 'model': 'Energy Socket', 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, @@ -20029,7 +20029,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Energy Socket', + 'model': 'Energy Socket', 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, @@ -20121,7 +20121,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Energy Socket', + 'model': 'Energy Socket', 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, @@ -20213,7 +20213,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Energy Socket', + 'model': 'Energy Socket', 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, @@ -20305,7 +20305,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Energy Socket', + 'model': 'Energy Socket', 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, @@ -20397,7 +20397,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Energy Socket', + 'model': 'Energy Socket', 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, @@ -20481,7 +20481,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Energy Socket', + 'model': 'Energy Socket', 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, @@ -20569,7 +20569,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Energy Socket', + 'model': 'Energy Socket', 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, @@ -20661,7 +20661,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Energy Socket', + 'model': 'Energy Socket', 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, @@ -20753,7 +20753,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Energy Socket', + 'model': 'Energy Socket', 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, @@ -20845,7 +20845,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Energy Socket', + 'model': 'Energy Socket', 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, @@ -20937,7 +20937,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Energy Socket', + 'model': 'Energy Socket', 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, @@ -21029,7 +21029,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Energy Socket', + 'model': 'Energy Socket', 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, @@ -21121,7 +21121,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Energy Socket', + 'model': 'Energy Socket', 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, @@ -21210,7 +21210,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Energy Socket', + 'model': 'Energy Socket', 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, @@ -21302,7 +21302,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Energy Socket', + 'model': 'Energy Socket', 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, @@ -21394,7 +21394,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Energy Socket', + 'model': 'Energy Socket', 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, @@ -21486,7 +21486,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Energy Socket', + 'model': 'Energy Socket', 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, @@ -21578,7 +21578,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Energy Socket', + 'model': 'Energy Socket', 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, @@ -21662,7 +21662,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Energy Socket', + 'model': 'Energy Socket', 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, @@ -21750,7 +21750,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Watermeter', + 'model': 'Watermeter', 'model_id': 'HWE-WTR', 'name': 'Device', 'name_by_user': None, @@ -21842,7 +21842,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Watermeter', + 'model': 'Watermeter', 'model_id': 'HWE-WTR', 'name': 'Device', 'name_by_user': None, @@ -21934,7 +21934,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Watermeter', + 'model': 'Watermeter', 'model_id': 'HWE-WTR', 'name': 'Device', 'name_by_user': None, @@ -22018,7 +22018,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Watermeter', + 'model': 'Watermeter', 'model_id': 'HWE-WTR', 'name': 'Device', 'name_by_user': None, @@ -22106,7 +22106,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 1-phase', + 'model': 'kWh Meter 1-phase', 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, @@ -22198,7 +22198,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 1-phase', + 'model': 'kWh Meter 1-phase', 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, @@ -22290,7 +22290,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 1-phase', + 'model': 'kWh Meter 1-phase', 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, @@ -22382,7 +22382,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 1-phase', + 'model': 'kWh Meter 1-phase', 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, @@ -22474,7 +22474,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 1-phase', + 'model': 'kWh Meter 1-phase', 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, @@ -22566,7 +22566,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 1-phase', + 'model': 'kWh Meter 1-phase', 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, @@ -22658,7 +22658,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 1-phase', + 'model': 'kWh Meter 1-phase', 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, @@ -22747,7 +22747,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 1-phase', + 'model': 'kWh Meter 1-phase', 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, @@ -22839,7 +22839,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 1-phase', + 'model': 'kWh Meter 1-phase', 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, @@ -22931,7 +22931,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 1-phase', + 'model': 'kWh Meter 1-phase', 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, @@ -23023,7 +23023,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 1-phase', + 'model': 'kWh Meter 1-phase', 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, @@ -23107,7 +23107,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 1-phase', + 'model': 'kWh Meter 1-phase', 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, @@ -23195,7 +23195,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, @@ -23287,7 +23287,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, @@ -23379,7 +23379,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, @@ -23471,7 +23471,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, @@ -23563,7 +23563,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, @@ -23655,7 +23655,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, @@ -23747,7 +23747,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, @@ -23839,7 +23839,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, @@ -23931,7 +23931,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, @@ -24023,7 +24023,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, @@ -24115,7 +24115,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, @@ -24207,7 +24207,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, @@ -24299,7 +24299,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, @@ -24388,7 +24388,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, @@ -24477,7 +24477,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, @@ -24566,7 +24566,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, @@ -24658,7 +24658,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, @@ -24750,7 +24750,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, @@ -24842,7 +24842,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, @@ -24934,7 +24934,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, @@ -25026,7 +25026,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, @@ -25118,7 +25118,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, @@ -25210,7 +25210,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, @@ -25302,7 +25302,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, @@ -25394,7 +25394,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, @@ -25486,7 +25486,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, @@ -25578,7 +25578,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, @@ -25662,7 +25662,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, diff --git a/tests/components/homewizard/snapshots/test_switch.ambr b/tests/components/homewizard/snapshots/test_switch.ambr index eb2a6c0e5483..86491faa87a8 100644 --- a/tests/components/homewizard/snapshots/test_switch.ambr +++ b/tests/components/homewizard/snapshots/test_switch.ambr @@ -74,7 +74,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 1-phase', + 'model': 'kWh Meter 1-phase', 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, @@ -158,7 +158,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, @@ -243,7 +243,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Energy Socket', + 'model': 'Energy Socket', 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, @@ -327,7 +327,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Energy Socket', + 'model': 'Energy Socket', 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, @@ -411,7 +411,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Energy Socket', + 'model': 'Energy Socket', 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, @@ -496,7 +496,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Energy Socket', + 'model': 'Energy Socket', 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, @@ -580,7 +580,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Energy Socket', + 'model': 'Energy Socket', 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, @@ -664,7 +664,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Energy Socket', + 'model': 'Energy Socket', 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, @@ -748,7 +748,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi Watermeter', + 'model': 'Watermeter', 'model_id': 'HWE-WTR', 'name': 'Device', 'name_by_user': None, @@ -832,7 +832,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 1-phase', + 'model': 'kWh Meter 1-phase', 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, @@ -916,7 +916,7 @@ 'labels': set({ }), 'manufacturer': 'HomeWizard', - 'model': 'Wi-Fi kWh Meter 3-phase', + 'model': 'kWh Meter 3-phase', 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, diff --git a/tests/components/hue/conftest.py b/tests/components/hue/conftest.py index 078c560d1264..2c951c60ae27 100644 --- a/tests/components/hue/conftest.py +++ b/tests/components/hue/conftest.py @@ -128,6 +128,8 @@ def create_mock_api_v1() -> Mock: software_version="1935144040", ) api.config.name = "Home" + # aiohue exposes the bridge id as both `bridge_id` and `bridgeid` + api.config.bridgeid = api.config.bridge_id api.lights = aiohue_v1.Lights(logger, {}, mock_request) api.groups = aiohue_v1.Groups(logger, {}, mock_request) diff --git a/tests/components/hue/const.py b/tests/components/hue/const.py index 57a590ab1af4..12cae5db14e8 100644 --- a/tests/components/hue/const.py +++ b/tests/components/hue/const.py @@ -138,3 +138,27 @@ FAKE_ROTARY = { }, "type": "relative_rotary", } + +FAKE_BEHAVIOR_SCRIPT = { + "configuration_schema": {}, + "description": "Generic switches script", + "id": "fake_behavior_script_id_1", + "metadata": {"category": "accessory", "name": "Hue Accessories"}, + "state_schema": {}, + "supported_features": [], + "trigger_schema": {}, + "type": "behavior_script", + "version": "0.0.1", +} + +FAKE_BEHAVIOR_INSTANCE = { + "configuration": {}, + "dependees": [], # codespell:ignore dependees + "enabled": True, + "id": "fake_behavior_instance_id_1", + "last_error": "", + "metadata": {"name": "Wall switch Hallway"}, + "script_id": "fake_behavior_script_id_1", + "status": "running", + "type": "behavior_instance", +} diff --git a/tests/components/hue/fixtures/v2_resources.json b/tests/components/hue/fixtures/v2_resources.json index d567360634e3..831a499bd593 100644 --- a/tests/components/hue/fixtures/v2_resources.json +++ b/tests/components/hue/fixtures/v2_resources.json @@ -2235,6 +2235,7 @@ "description": "Countdown Timer", "id": "e73bc72d-96b1-46f8-aa57-729861f80c78", "metadata": { + "category": "automation", "name": "Timers" }, "state_schema": { diff --git a/tests/components/hue/test_binary_sensor.py b/tests/components/hue/test_binary_sensor.py index 45c0ce04cbb8..75de2af66f29 100644 --- a/tests/components/hue/test_binary_sensor.py +++ b/tests/components/hue/test_binary_sensor.py @@ -1,7 +1,10 @@ """Philips Hue binary_sensor platform tests for V2 bridge/api.""" +from typing import Any from unittest.mock import Mock +import pytest + from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.util.json import JsonArrayType @@ -9,6 +12,60 @@ from homeassistant.util.json import JsonArrayType from .conftest import setup_platform from .const import FAKE_BINARY_SENSOR, FAKE_DEVICE, FAKE_ZIGBEE_CONNECTIVITY +MOTION_AWARE_ENTITY_ID = "binary_sensor.test_room_test_room_motion_aware_sensor_1" +MOTION_AREA_CONFIGURATION_ID = "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b" +AREA_MOTION_SERVICE_IDS = { + "convenience_area_motion": "4f317b69-9da0-4b4f-84f2-7ca07b9fe345", + "security_area_motion": "8b7e4f82-9c3d-4e1a-a5f6-8d9c7b2a3e4f", +} + +MOTION_DETECTED = { + "motion": True, + "motion_valid": True, + "motion_report": {"changed": "2023-09-23T08:20:51.384Z", "motion": True}, +} +MOTION_CLEARED = { + "motion": False, + "motion_valid": True, + "motion_report": {"changed": "2023-09-23T08:13:42.394Z", "motion": False}, +} +MOTION_INVALID = { + "motion": False, + "motion_valid": False, + "motion_report": {"changed": "2023-09-23T05:54:08.166Z", "motion": False}, +} + + +def area_motion_service( + service_type: str, + *, + enabled: bool = True, + motion: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build a service of the MotionAware zone, without `motion` when none is given.""" + service = { + "id": AREA_MOTION_SERVICE_IDS[service_type], + "owner": { + "rid": MOTION_AREA_CONFIGURATION_ID, + "rtype": "motion_area_configuration", + }, + "enabled": enabled, + "type": service_type, + } + if motion is not None: + service["motion"] = motion + return service + + +def replace_resources( + data: JsonArrayType, resources: list[dict[str, Any]] +) -> JsonArrayType: + """Return the test data with each resource of the same id replaced.""" + replacements = {resource["id"]: resource for resource in resources} + missing = replacements.keys() - {resource["id"] for resource in data} + assert not missing, f"resource id(s) not present in the test data: {missing}" + return [replacements.get(resource["id"], resource) for resource in data] + async def test_binary_sensors( hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType @@ -88,7 +145,7 @@ async def test_binary_sensors( assert sensor.attributes["device_class"] == "motion" # test motion aware sensor - sensor = hass.states.get("binary_sensor.test_room_test_room_motion_aware_sensor_1") + sensor = hass.states.get(MOTION_AWARE_ENTITY_ID) assert sensor is not None assert sensor.state == "off" assert sensor.name == "Test Room Motion Aware Sensor 1" @@ -195,15 +252,17 @@ async def test_motion_aware_sensor( await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR) # test motion aware sensor exists and has correct state - sensor = hass.states.get("binary_sensor.test_room_test_room_motion_aware_sensor_1") + sensor = hass.states.get(MOTION_AWARE_ENTITY_ID) assert sensor is not None assert sensor.state == "off" assert sensor.attributes["device_class"] == "motion" # test update of motion aware sensor works on incoming event + # the zone in the test data has its convenience service enabled, so that is the + # service reporting its motion updated_sensor = { - "id": "8b7e4f82-9c3d-4e1a-a5f6-8d9c7b2a3e4f", - "type": "security_area_motion", + "id": "4f317b69-9da0-4b4f-84f2-7ca07b9fe345", + "type": "convenience_area_motion", "motion": { "motion": True, "motion_valid": True, @@ -212,7 +271,7 @@ async def test_motion_aware_sensor( } mock_bridge_v2.api.emit_event("update", updated_sensor) await hass.async_block_till_done() - sensor = hass.states.get("binary_sensor.test_room_test_room_motion_aware_sensor_1") + sensor = hass.states.get(MOTION_AWARE_ENTITY_ID) assert sensor.state == "on" # test name update when motion area configuration name changes @@ -225,6 +284,191 @@ async def test_motion_aware_sensor( await hass.async_block_till_done() # The entity name is derived from the motion area configuration name # but the entity ID doesn't change - we just verify the sensor still exists - sensor = hass.states.get("binary_sensor.test_room_test_room_motion_aware_sensor_1") + sensor = hass.states.get(MOTION_AWARE_ENTITY_ID) assert sensor is not None assert sensor.name == "Test Room Updated Motion Area" + + +@pytest.mark.parametrize( + ("services", "expected_state"), + [ + pytest.param( + [ + area_motion_service("security_area_motion", motion=MOTION_CLEARED), + area_motion_service("convenience_area_motion", motion=MOTION_DETECTED), + ], + "on", + id="bound_to_lights_reads_convenience", + ), + pytest.param( + [ + area_motion_service("security_area_motion", motion=MOTION_CLEARED), + area_motion_service( + "convenience_area_motion", enabled=False, motion=MOTION_DETECTED + ), + ], + "off", + id="not_bound_to_lights_reads_security", + ), + pytest.param( + [ + area_motion_service("security_area_motion"), + area_motion_service("convenience_area_motion", motion=MOTION_DETECTED), + ], + "on", + id="hue_secure_security_without_motion_reads_convenience", + ), + pytest.param( + [ + area_motion_service("security_area_motion", motion=MOTION_INVALID), + area_motion_service( + "convenience_area_motion", enabled=False, motion=MOTION_DETECTED + ), + ], + "unknown", + id="not_bound_to_lights_without_valid_reading", + ), + # a real zone can have its convenience service enabled while only the security + # service reports, so an enabled service without a reading must not win + pytest.param( + [ + area_motion_service("security_area_motion", motion=MOTION_DETECTED), + area_motion_service("convenience_area_motion", motion=MOTION_INVALID), + ], + "on", + id="falls_back_to_security_when_convenience_has_no_reading", + ), + ], +) +async def test_motion_aware_sensor_motion_source( + hass: HomeAssistant, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, + services: list[dict[str, Any]], + expected_state: str, +) -> None: + """Test the MotionAware sensor reads the zone service that reports motion.""" + # every case gives the service that must be ignored the opposite state, so + # reading the wrong one results in a state other than the asserted one + await mock_bridge_v2.api.load_test_data( + replace_resources(v2_resources_test_data, services) + ) + await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR) + + assert hass.states.get(MOTION_AWARE_ENTITY_ID).state == expected_state + + +async def test_motion_aware_sensor_follows_convenience_service( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test the MotionAware sensor updates on events of the convenience service.""" + await mock_bridge_v2.api.load_test_data( + replace_resources( + v2_resources_test_data, + [ + area_motion_service("security_area_motion"), + area_motion_service("convenience_area_motion", motion=MOTION_CLEARED), + ], + ) + ) + await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR) + + assert hass.states.get(MOTION_AWARE_ENTITY_ID).state == "off" + + mock_bridge_v2.api.emit_event( + "update", + area_motion_service("convenience_area_motion", motion=MOTION_DETECTED), + ) + await hass.async_block_till_done() + assert hass.states.get(MOTION_AWARE_ENTITY_ID).state == "on" + + +async def test_motion_aware_sensor_follows_security_service( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test the MotionAware sensor updates on events of the security service.""" + await mock_bridge_v2.api.load_test_data( + replace_resources( + v2_resources_test_data, + [ + area_motion_service("security_area_motion", motion=MOTION_CLEARED), + area_motion_service( + "convenience_area_motion", enabled=False, motion=MOTION_CLEARED + ), + ], + ) + ) + await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR) + + assert hass.states.get(MOTION_AWARE_ENTITY_ID).state == "off" + + mock_bridge_v2.api.emit_event( + "update", + area_motion_service("security_area_motion", motion=MOTION_DETECTED), + ) + await hass.async_block_till_done() + assert hass.states.get(MOTION_AWARE_ENTITY_ID).state == "on" + + +async def test_motion_aware_sensor_without_convenience_resource( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test the MotionAware sensor works when the convenience service is missing.""" + # the zone still lists the service, but the bridge never delivered the resource + data = replace_resources( + v2_resources_test_data, + [area_motion_service("security_area_motion", motion=MOTION_DETECTED)], + ) + convenience_id = AREA_MOTION_SERVICE_IDS["convenience_area_motion"] + await mock_bridge_v2.api.load_test_data( + [resource for resource in data if resource["id"] != convenience_id] + ) + await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR) + + assert hass.states.get(MOTION_AWARE_ENTITY_ID).state == "on" + + +@pytest.mark.parametrize( + ("zone_update", "zone_restore"), + [ + pytest.param({"enabled": False}, {"enabled": True}, id="zone_switched_off"), + pytest.param( + {"health": "not_running"}, {"health": "healthy"}, id="zone_not_running" + ), + ], +) +async def test_motion_aware_sensor_zone_not_reporting( + hass: HomeAssistant, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, + zone_update: dict[str, Any], + zone_restore: dict[str, Any], +) -> None: + """Test the MotionAware sensor reports unknown while its zone is not reporting.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR) + + assert hass.states.get(MOTION_AWARE_ENTITY_ID).state == "off" + + # the services keep reporting a valid state while the zone itself does not + mock_bridge_v2.api.emit_event( + "update", + { + "id": MOTION_AREA_CONFIGURATION_ID, + "type": "motion_area_configuration", + **zone_update, + }, + ) + await hass.async_block_till_done() + assert hass.states.get(MOTION_AWARE_ENTITY_ID).state == "unknown" + + mock_bridge_v2.api.emit_event( + "update", + { + "id": MOTION_AREA_CONFIGURATION_ID, + "type": "motion_area_configuration", + **zone_restore, + }, + ) + await hass.async_block_till_done() + assert hass.states.get(MOTION_AWARE_ENTITY_ID).state == "off" diff --git a/tests/components/hue/test_bridge.py b/tests/components/hue/test_bridge.py index be7a6738617c..be283a4e7dc4 100644 --- a/tests/components/hue/test_bridge.py +++ b/tests/components/hue/test_bridge.py @@ -9,7 +9,7 @@ from aiohue.v1 import HueBridgeV1 from aiohue.v2 import HueBridgeV2 import pytest -from homeassistant.components.hue import bridge +from homeassistant.components.hue import bridge, migration from homeassistant.components.hue.const import ( CONF_ALLOW_HUE_GROUPS, CONF_ALLOW_UNREACHABLE, @@ -17,21 +17,41 @@ from homeassistant.components.hue.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.util.json import JsonArrayType -from tests.common import MockConfigEntry +from .conftest import setup_platform +from .test_light_v1 import LIGHT_RESPONSE + +from tests.common import MockConfigEntry, async_capture_events -async def test_bridge_setup_v1(hass: HomeAssistant, mock_api_v1: Mock) -> None: +async def test_bridge_setup_v1( + hass: HomeAssistant, mock_api_v1: Mock, device_registry: dr.DeviceRegistry +) -> None: """Test a successful setup for V1 bridge.""" config_entry = MockConfigEntry( domain=DOMAIN, data={"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, options={CONF_ALLOW_HUE_GROUPS: False, CONF_ALLOW_UNREACHABLE: False}, ) + config_entry.add_to_hass(hass) + + def assert_bridge_device_registered(*args: object, **kwargs: object) -> None: + # The bridge device must already be registered by the time platforms + # are forwarded, so light/sensor entities can resolve it as their + # via_device parent while they are being added. + assert device_registry.async_get_device_by_identifier( + (DOMAIN, mock_api_v1.config.bridge_id), config_entry.entry_id + ) with ( patch.object(bridge, "HueBridgeV1", return_value=mock_api_v1), - patch.object(hass.config_entries, "async_forward_entry_setups") as mock_forward, + patch.object( + hass.config_entries, + "async_forward_entry_setups", + side_effect=assert_bridge_device_registered, + ) as mock_forward, ): hue_bridge = bridge.HueBridge(hass, config_entry) async with config_entry.setup_lock: @@ -45,6 +65,88 @@ async def test_bridge_setup_v1(hass: HomeAssistant, mock_api_v1: Mock) -> None: assert forward_entries == {"light", "binary_sensor", "sensor"} +async def test_bridge_device_v1( + hass: HomeAssistant, mock_api_v1: Mock, device_registry: dr.DeviceRegistry +) -> None: + """Test the bridge device after a full v1 setup.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, + options={CONF_ALLOW_HUE_GROUPS: False, CONF_ALLOW_UNREACHABLE: False}, + ) + config_entry.add_to_hass(hass) + mock_api_v1.mock_light_responses.append(LIGHT_RESPONSE) + mock_api_v1.mock_group_responses.append({}) + mock_api_v1.mock_sensor_responses.append({}) + events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + + with ( + patch.object(bridge, "HueBridgeV1", return_value=mock_api_v1), + patch.object(migration, "is_v2_bridge", return_value=False), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + bridge_device = device_registry.async_get_device_by_identifier( + (DOMAIN, mock_api_v1.config.bridge_id), config_entry.entry_id + ) + assert bridge_device is not None + assert bridge_device.connections == { + (dr.CONNECTION_NETWORK_MAC, mock_api_v1.config.mac_address) + } + # The bridge device is registered exactly once + create_events = [ + event + for event in events + if event.data["action"] == "create" + and event.data["device_id"] == bridge_device.id + ] + assert len(create_events) == 1 + # The light devices resolve the bridge device as their via_device parent + light_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "456"), config_entry.entry_id + ) + assert light_device is not None + assert light_device.via_device_id == bridge_device.id + + +async def test_bridge_device_v2( + hass: HomeAssistant, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the bridge device after a fresh v2 setup.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + await setup_platform(hass, mock_bridge_v2, []) + + bridge_device = device_registry.async_get_device_by_identifier( + (DOMAIN, mock_bridge_v2.api.config.bridge_id), + mock_bridge_v2.config_entry.entry_id, + ) + assert bridge_device is not None + assert bridge_device.identifiers == { + (DOMAIN, mock_bridge_v2.api.config.bridge_id), + (DOMAIN, mock_bridge_v2.api.config.bridge_device.id), + } + # The bridge device has both the Zigbee MAC connection (set by + # async_setup_devices) and the network MAC connection (merged in by + # _async_register_bridge_device) + assert bridge_device.connections == { + (dr.CONNECTION_NETWORK_MAC, "00:17:88:01:aa:bb:fd:c7"), + (dr.CONNECTION_NETWORK_MAC, mock_bridge_v2.api.config.mac_address), + } + # The bridge device is registered exactly once + create_events = [ + event + for event in events + if event.data["action"] == "create" + and event.data["device_id"] == bridge_device.id + ] + assert len(create_events) == 1 + + async def test_bridge_setup_v2(hass: HomeAssistant, mock_api_v2: Mock) -> None: """Test a successful setup for V2 bridge.""" config_entry = MockConfigEntry( diff --git a/tests/components/hue/test_device_trigger_v2.py b/tests/components/hue/test_device_trigger_v2.py index e2565701d223..b89aa2ed91d3 100644 --- a/tests/components/hue/test_device_trigger_v2.py +++ b/tests/components/hue/test_device_trigger_v2.py @@ -73,6 +73,12 @@ async def test_get_triggers( hue_wall_switch_device = device_registry.async_get_device( identifiers={(hue.DOMAIN, "3ff06175-29e8-44a8-8fe7-af591b0025da")} ) + # The device is linked to the bridge device as its via_device. + bridge_device = device_registry.async_get_device_by_identifier( + (hue.DOMAIN, mock_bridge_v2.api.config.bridge_id), + mock_bridge_v2.config_entry.entry_id, + ) + assert hue_wall_switch_device.via_device_id == bridge_device.id hue_bat_sensor = entity_registry.async_get( "sensor.wall_switch_with_2_controls_battery" ) diff --git a/tests/components/hue/test_light_v1.py b/tests/components/hue/test_light_v1.py index dbd77c700b90..8d661f7e3ec0 100644 --- a/tests/components/hue/test_light_v1.py +++ b/tests/components/hue/test_light_v1.py @@ -191,6 +191,12 @@ async def setup_bridge(hass: HomeAssistant, mock_bridge_v1: Mock) -> None: config_entry.mock_state(hass, ConfigEntryState.LOADED) mock_bridge_v1.config_entry = config_entry config_entry.runtime_data = mock_bridge_v1 + # Register the bridge device so the light entities can resolve it as their + # via_device parent while they are being added. + dr.async_get(hass).async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(hue.DOMAIN, mock_bridge_v1.api.config.bridgeid)}, + ) await hass.config_entries.async_forward_entry_setups(config_entry, [Platform.LIGHT]) # To flush out the service call to update the group await hass.async_block_till_done() diff --git a/tests/components/hue/test_sensor_v1.py b/tests/components/hue/test_sensor_v1.py index ecf4a369b16d..f75e84ee20d6 100644 --- a/tests/components/hue/test_sensor_v1.py +++ b/tests/components/hue/test_sensor_v1.py @@ -488,6 +488,12 @@ async def test_hue_events( hue_tap_device = device_registry.async_get_device( identifiers={(hue.DOMAIN, "00:00:00:00:00:44:23:08")} ) + # The sensor device is linked to the bridge device as its via_device. + bridge_device = device_registry.async_get_device_by_identifier( + (hue.DOMAIN, mock_bridge_v1.api.config.bridgeid), + mock_bridge_v1.config_entry.entry_id, + ) + assert hue_tap_device.via_device_id == bridge_device.id mock_bridge_v1.api.sensors["7"].last_event = {"type": "button"} mock_bridge_v1.api.sensors["8"].last_event = {"type": "button"} diff --git a/tests/components/hue/test_switch.py b/tests/components/hue/test_switch.py index 0b951010f586..2e02f5d2b858 100644 --- a/tests/components/hue/test_switch.py +++ b/tests/components/hue/test_switch.py @@ -2,12 +2,22 @@ from unittest.mock import Mock +import pytest + +from homeassistant.components.hue.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util.json import JsonArrayType from .conftest import setup_platform -from .const import FAKE_BINARY_SENSOR, FAKE_DEVICE, FAKE_ZIGBEE_CONNECTIVITY +from .const import ( + FAKE_BEHAVIOR_INSTANCE, + FAKE_BEHAVIOR_SCRIPT, + FAKE_BINARY_SENSOR, + FAKE_DEVICE, + FAKE_ZIGBEE_CONNECTIVITY, +) async def test_switch( @@ -131,3 +141,60 @@ async def test_switch_added(hass: HomeAssistant, mock_bridge_v2: Mock) -> None: test_entity = hass.states.get(test_entity_id) assert test_entity is not None assert test_entity.state == "off" + + +@pytest.mark.parametrize( + "metadata", + [ + pytest.param( + {"name": "Hue Accessories", "category": "accessory"}, id="accessory" + ), + pytest.param( + {"name": "Light state after streaming", "category": "entertainment"}, + id="entertainment", + ), + pytest.param({"name": "Old bridge script"}, id="no_category"), + ], +) +async def test_internal_behavior_instance_not_added( + hass: HomeAssistant, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, + metadata: dict, +) -> None: + """Test internal behavior instances are not exposed as switches. + + The bridge accepts a change to `enabled` on these but keeps running them, + so a switch for them would silently do nothing. Bridges that do not report + a category at all are skipped for the same reason. + """ + internal_script = {**FAKE_BEHAVIOR_SCRIPT, "metadata": metadata} + await mock_bridge_v2.api.load_test_data( + [*v2_resources_test_data, internal_script, FAKE_BEHAVIOR_INSTANCE] + ) + + await setup_platform(hass, mock_bridge_v2, Platform.SWITCH) + + assert hass.states.get("switch.philips_hue_automation_wall_switch_hallway") is None + assert hass.states.get("switch.philips_hue_automation_timer_test") is not None + assert len(hass.states.async_all()) == 4 + + +async def test_internal_behavior_instance_entity_removed( + hass: HomeAssistant, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, + entity_registry: er.EntityRegistry, +) -> None: + """Test a previously created entity for an internal instance is removed.""" + # Simulate an entity created with a previous version of the integration + stale_entity = entity_registry.async_get_or_create( + Platform.SWITCH, DOMAIN, FAKE_BEHAVIOR_INSTANCE["id"] + ) + await mock_bridge_v2.api.load_test_data( + [*v2_resources_test_data, FAKE_BEHAVIOR_SCRIPT, FAKE_BEHAVIOR_INSTANCE] + ) + + await setup_platform(hass, mock_bridge_v2, Platform.SWITCH) + + assert entity_registry.async_get(stale_entity.entity_id) is None diff --git a/tests/components/integration/test_config_flow.py b/tests/components/integration/test_config_flow.py index 37b0760dc039..d8757ac25c67 100644 --- a/tests/components/integration/test_config_flow.py +++ b/tests/components/integration/test_config_flow.py @@ -6,6 +6,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.integration.const import DOMAIN +from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import selector @@ -151,3 +152,39 @@ async def test_options(hass: HomeAssistant, platform) -> None: state = hass.states.get(f"{platform}.my_integration") assert state.state != "unknown" assert state.attributes["unit_of_measurement"] == "kdogmin" + + +async def test_options_source_selector_with_missing_source_unit( + hass: HomeAssistant, +) -> None: + """Test reconfiguring when the current source unit is missing.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "method": "left", + "name": "My integration", + "round": 1.0, + "source": "sensor.input", + "unit_prefix": "k", + "unit_time": "min", + }, + title="My integration", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + hass.states.async_set("sensor.input", "unavailable") + hass.states.async_set( + "sensor.valid_power", 10, {"unit_of_measurement": UnitOfPower.WATT} + ) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + source = result["data_schema"].schema["source"] + assert isinstance(source, selector.EntitySelector) + assert source.config["domain"] == ["counter", "input_number", "sensor"] + assert "include_entities" not in source.config diff --git a/tests/components/izone/test_climate.py b/tests/components/izone/test_climate.py index 64d1c0d83523..51f5b4389a0a 100644 --- a/tests/components/izone/test_climate.py +++ b/tests/components/izone/test_climate.py @@ -26,6 +26,7 @@ from homeassistant.components.izone.coordinator import UPDATE_INTERVAL from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.device_registry as dr import homeassistant.helpers.entity_registry as er from . import setup_integration @@ -65,6 +66,24 @@ async def test_climate_entities( assert hass.states.get(ZONE_ENTITY) is not None +@pytest.mark.usefixtures("init_integration") +async def test_zone_device_linked_to_controller( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """The zone device is linked to the controller device via via_device_id.""" + controller_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "000000001"), mock_config_entry.entry_id + ) + zone_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "000000001", 0), # type:ignore[arg-type] + mock_config_entry.entry_id, + ) + assert controller_device is not None + assert zone_device is not None + assert zone_device.via_device_id == controller_device.id + + @pytest.mark.parametrize( "mock_controller", [create_mock_controller(ras_mode="RAS", zones_total=1)], diff --git a/tests/components/loqed/conftest.py b/tests/components/loqed/conftest.py index b74d9ef16e7e..59b0deb3bc13 100644 --- a/tests/components/loqed/conftest.py +++ b/tests/components/loqed/conftest.py @@ -1,6 +1,7 @@ """Contains fixtures for Loqed tests.""" -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Callable +from contextlib import contextmanager import json from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -15,6 +16,8 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, async_load_fixture +type PatchLockCreationFlow = Callable[[dict[str, Any], loqed.Lock, str], Any] + @pytest.fixture(name="config_entry") async def config_entry_fixture(hass: HomeAssistant) -> MockConfigEntry: @@ -100,3 +103,36 @@ async def integration_fixture( await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() yield config_entry + + +@pytest.fixture(name="patch_lock_creation_flow") +def patch_lock_creation_flow_fixture() -> PatchLockCreationFlow: + """Patch config-flow calls used when creating a lock entry.""" + + @contextmanager + def _patch_lock_creation_flow( + all_locks_response: dict[str, Any], + lock: loqed.Lock, + webhook_id: str, + ) -> Any: + with ( + patch( + "loqedAPI.cloud_loqed.LoqedCloudAPI.async_get_locks", + return_value=all_locks_response, + ), + patch( + "loqedAPI.loqed.LoqedAPI.async_get_lock", + return_value=lock, + ), + patch( + "homeassistant.components.loqed.async_setup_entry", + return_value=True, + ), + patch( + "homeassistant.components.webhook.async_generate_id", + return_value=webhook_id, + ), + ): + yield + + return _patch_lock_creation_flow diff --git a/tests/components/loqed/test_config_flow.py b/tests/components/loqed/test_config_flow.py index 3bdc8f111309..ecac353e3497 100644 --- a/tests/components/loqed/test_config_flow.py +++ b/tests/components/loqed/test_config_flow.py @@ -1,7 +1,9 @@ """Test the Loqed config flow.""" +from collections.abc import Callable from ipaddress import ip_address import json +from typing import Any from unittest.mock import Mock, patch import aiohttp @@ -9,7 +11,7 @@ from loqedAPI import loqed from homeassistant import config_entries from homeassistant.components.loqed.const import DOMAIN -from homeassistant.const import CONF_API_TOKEN, CONF_NAME, CONF_WEBHOOK_ID +from homeassistant.const import CONF_API_TOKEN, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -17,6 +19,9 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import async_load_fixture from tests.test_util.aiohttp import AiohttpClientMocker +TEST_API_TOKEN = "eyadiuyfasiuasf" +TEST_WEBHOOK_ID = "Webhook_ID" + zeroconf_data = ZeroconfServiceInfo( ip_address=ip_address("192.168.12.34"), ip_addresses=[ip_address("192.168.12.34")], @@ -28,7 +33,37 @@ zeroconf_data = ZeroconfServiceInfo( ) -async def test_create_entry_zeroconf(hass: HomeAssistant) -> None: +async def _async_init_zeroconf_flow(hass: HomeAssistant) -> dict[str, Any]: + """Initialize a zeroconf flow and return the form result.""" + lock_result = json.loads(await async_load_fixture(hass, "status_ok.json", DOMAIN)) + + with patch( + "loqedAPI.loqed.LoqedAPI.async_get_lock_details", + return_value=lock_result, + ): + return await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_data, + ) + + +async def _async_init_user_flow(hass: HomeAssistant) -> dict[str, Any]: + """Initialize a user flow and return the form result.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + return result + + +async def test_create_entry_zeroconf( + hass: HomeAssistant, + patch_lock_creation_flow: Callable[[dict[str, Any], loqed.Lock, str], Any], +) -> None: """Test we get can create a lock via zeroconf.""" lock_result = json.loads(await async_load_fixture(hass, "status_ok.json", DOMAIN)) @@ -51,25 +86,8 @@ async def test_create_entry_zeroconf(hass: HomeAssistant) -> None: await async_load_fixture(hass, "get_all_locks.json", DOMAIN) ) - with ( - patch( - "loqedAPI.cloud_loqed.LoqedCloudAPI.async_get_locks", - return_value=all_locks_response, - ), - patch( - "loqedAPI.loqed.LoqedAPI.async_get_lock", - return_value=mock_lock, - ), - patch( - "homeassistant.components.loqed.async_setup_entry", - return_value=True, - ), - patch( - "homeassistant.components.webhook.async_generate_id", - return_value=webhook_id, - ), - ): - result2 = await hass.config_entries.flow.async_configure( + with patch_lock_creation_flow(all_locks_response, mock_lock, webhook_id): + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_API_TOKEN: "eyadiuyfasiuasf", @@ -78,9 +96,9 @@ async def test_create_entry_zeroconf(hass: HomeAssistant) -> None: await hass.async_block_till_done() found_lock = all_locks_response["data"][0] - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "LOQED Touch Smart Lock" - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "MyLock" + assert result["data"] == { "id": "Foo", "lock_key_key": found_lock["key_secret"], "bridge_key": found_lock["bridge_key"], @@ -95,55 +113,30 @@ async def test_create_entry_zeroconf(hass: HomeAssistant) -> None: async def test_create_entry_user( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + patch_lock_creation_flow: Callable[[dict[str, Any], loqed.Lock, str], Any], ) -> None: """Test we can create a lock via manual entry.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - ) + result = await _async_init_user_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["errors"] is None - - lock_result = json.loads(await async_load_fixture(hass, "status_ok.json", DOMAIN)) mock_lock = Mock(spec=loqed.Lock, id="Foo") - webhook_id = "Webhook_ID" + webhook_id = TEST_WEBHOOK_ID all_locks_response = json.loads( await async_load_fixture(hass, "get_all_locks.json", DOMAIN) ) found_lock = all_locks_response["data"][0] - with ( - patch( - "loqedAPI.cloud_loqed.LoqedCloudAPI.async_get_locks", - return_value=all_locks_response, - ), - patch( - "loqedAPI.loqed.LoqedAPI.async_get_lock", - return_value=mock_lock, - ), - patch( - "homeassistant.components.loqed.async_setup_entry", - return_value=True, - ), - patch( - "homeassistant.components.webhook.async_generate_id", - return_value=webhook_id, - ), - patch( - "loqedAPI.loqed.LoqedAPI.async_get_lock_details", return_value=lock_result - ), - ): - result2 = await hass.config_entries.flow.async_configure( + with patch_lock_creation_flow(all_locks_response, mock_lock, webhook_id): + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_API_TOKEN: "eyadiuyfasiuasf", CONF_NAME: "MyLock"}, + {CONF_API_TOKEN: TEST_API_TOKEN}, ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "LOQED Touch Smart Lock" - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "MyLock" + assert result["data"] == { "id": "Foo", "lock_key_key": found_lock["key_secret"], "bridge_key": found_lock["bridge_key"], @@ -152,7 +145,56 @@ async def test_create_entry_user( "bridge_ip": found_lock["bridge_ip"], "name": found_lock["name"], CONF_WEBHOOK_ID: webhook_id, - CONF_API_TOKEN: "eyadiuyfasiuasf", + CONF_API_TOKEN: TEST_API_TOKEN, + } + mock_lock.getWebhooks.assert_awaited() + + +async def test_create_entry_user_with_pick_lock( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + patch_lock_creation_flow: Callable[[dict[str, Any], loqed.Lock, str], Any], +) -> None: + """Test we can create a lock via manual entry when multiple locks exist.""" + result = await _async_init_user_flow(hass) + + mock_lock = Mock(spec=loqed.Lock, id="Foo") + webhook_id = TEST_WEBHOOK_ID + all_locks_response = json.loads( + await async_load_fixture(hass, "get_all_locks.json", DOMAIN) + ) + second_lock = all_locks_response["data"][0].copy() + second_lock["id"] = "Bar" + second_lock["name"] = "MyOtherLock" + all_locks_response["data"].append(second_lock) + + with patch_lock_creation_flow(all_locks_response, mock_lock, webhook_id): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: TEST_API_TOKEN}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "pick_lock" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"lock_id": second_lock["id"]}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == second_lock["name"] + assert result["data"] == { + "id": second_lock["id"], + "lock_key_key": second_lock["key_secret"], + "bridge_key": second_lock["bridge_key"], + "lock_key_local_id": second_lock["local_id"], + "bridge_mdns_hostname": second_lock["bridge_hostname"], + "bridge_ip": second_lock["bridge_ip"], + "name": second_lock["name"], + CONF_WEBHOOK_ID: webhook_id, + CONF_API_TOKEN: TEST_API_TOKEN, } mock_lock.getWebhooks.assert_awaited() @@ -161,10 +203,121 @@ async def test_cannot_connect( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, + result = await _async_init_user_flow(hass) + + with patch( + "loqedAPI.cloud_loqed.LoqedCloudAPI.async_get_locks", + side_effect=aiohttp.ClientError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: TEST_API_TOKEN}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_recover_after_cannot_connect( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + patch_lock_creation_flow: Callable[[dict[str, Any], loqed.Lock, str], Any], +) -> None: + """Test we can recover from a connection error and create an entry.""" + result = await _async_init_user_flow(hass) + + with patch( + "loqedAPI.cloud_loqed.LoqedCloudAPI.async_get_locks", + side_effect=aiohttp.ClientError, + ): + error_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: TEST_API_TOKEN}, + ) + await hass.async_block_till_done() + + assert error_result["type"] is FlowResultType.FORM + assert error_result["errors"] == {"base": "cannot_connect"} + + mock_lock = Mock(spec=loqed.Lock, id="Foo") + webhook_id = TEST_WEBHOOK_ID + all_locks_response = json.loads( + await async_load_fixture(hass, "get_all_locks.json", DOMAIN) ) + found_lock = all_locks_response["data"][0] + + with patch_lock_creation_flow(all_locks_response, mock_lock, webhook_id): + success_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: TEST_API_TOKEN}, + ) + await hass.async_block_till_done() + + assert success_result["type"] is FlowResultType.CREATE_ENTRY + assert success_result["title"] == "MyLock" + assert success_result["data"] == { + "id": "Foo", + "lock_key_key": found_lock["key_secret"], + "bridge_key": found_lock["bridge_key"], + "lock_key_local_id": found_lock["local_id"], + "bridge_mdns_hostname": found_lock["bridge_hostname"], + "bridge_ip": found_lock["bridge_ip"], + "name": found_lock["name"], + CONF_WEBHOOK_ID: webhook_id, + CONF_API_TOKEN: TEST_API_TOKEN, + } + mock_lock.getWebhooks.assert_awaited() + + +async def test_no_locks( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we handle a situation where the account has no locks.""" + result = await _async_init_user_flow(hass) + + with patch( + "loqedAPI.cloud_loqed.LoqedCloudAPI.async_get_locks", + return_value={"data": []}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: TEST_API_TOKEN}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "no_locks"} + + +async def test_invalid_auth_when_lock_not_found( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we handle a situation where the lock is absent from the cloud API response.""" + result = await _async_init_zeroconf_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + + with patch( + "loqedAPI.cloud_loqed.LoqedCloudAPI.async_get_locks", + return_value={"data": []}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: TEST_API_TOKEN}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_cannot_connect_zeroconf_cloud_api_error( + hass: HomeAssistant, +) -> None: + """Test we handle a cloud API error during zeroconf validate_input.""" + result = await _async_init_zeroconf_flow(hass) assert result["type"] is FlowResultType.FORM assert result["errors"] is None @@ -173,57 +326,21 @@ async def test_cannot_connect( "loqedAPI.cloud_loqed.LoqedCloudAPI.async_get_locks", side_effect=aiohttp.ClientError, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_API_TOKEN: "eyadiuyfasiuasf", CONF_NAME: "MyLock"}, + {CONF_API_TOKEN: TEST_API_TOKEN}, ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_invalid_auth_when_lock_not_found( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test we handle a situation where the user enters an invalid lock name.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] is None - - all_locks_response = json.loads( - await async_load_fixture(hass, "get_all_locks.json", DOMAIN) - ) - - with patch( - "loqedAPI.cloud_loqed.LoqedCloudAPI.async_get_locks", - return_value=all_locks_response, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_TOKEN: "eyadiuyfasiuasf", CONF_NAME: "MyLock2"}, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} + assert result["errors"] == {"base": "cannot_connect"} async def test_cannot_connect_when_lock_not_reachable( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: - """Test we handle a situation where the user enters an invalid lock name.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] is None + """Test we handle a situation where the lock is not reachable.""" + result = await _async_init_user_flow(hass) all_locks_response = json.loads( await async_load_fixture(hass, "get_all_locks.json", DOMAIN) @@ -238,11 +355,11 @@ async def test_cannot_connect_when_lock_not_reachable( "loqedAPI.loqed.LoqedAPI.async_get_lock", side_effect=aiohttp.ClientError ), ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_API_TOKEN: "eyadiuyfasiuasf", CONF_NAME: "MyLock"}, + {CONF_API_TOKEN: TEST_API_TOKEN}, ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/lyngdorf/test_config_flow.py b/tests/components/lyngdorf/test_config_flow.py index e60a79970344..ac001aa7d6e6 100644 --- a/tests/components/lyngdorf/test_config_flow.py +++ b/tests/components/lyngdorf/test_config_flow.py @@ -260,7 +260,9 @@ async def test_ssdp_discovery_no_serial(hass: HomeAssistant) -> None: assert result["reason"] == "cannot_determine_id" -async def test_ssdp_discovery_unsupported_model(hass: HomeAssistant) -> None: +async def test_ssdp_discovery_unsupported_model( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test SSDP discovery aborts when model is not supported.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -278,6 +280,7 @@ async def test_ssdp_discovery_unsupported_model(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unsupported_model" + assert "UNKNOWN-MODEL" in caplog.text async def test_ssdp_discovery_missing_model(hass: HomeAssistant) -> None: diff --git a/tests/components/matter/test_adapter.py b/tests/components/matter/test_adapter.py index eabb47e14078..9a79dcfca64c 100644 --- a/tests/components/matter/test_adapter.py +++ b/tests/components/matter/test_adapter.py @@ -6,7 +6,8 @@ from matter_server.common.models import EventType import pytest from homeassistant.components.matter.adapter import get_clean_name -from homeassistant.components.matter.const import DOMAIN +from homeassistant.components.matter.const import DOMAIN, ID_TYPE_DEVICE_ID +from homeassistant.components.matter.helpers import get_device_id from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -144,6 +145,99 @@ async def test_node_added_subscription( assert entity_state +async def test_endpoint_added_sets_up_bridge_before_child( + hass: HomeAssistant, + matter_client: MagicMock, + device_registry: dr.DeviceRegistry, + integration: MockConfigEntry, +) -> None: + """Test a bridged child endpoint resolves via_device_id set up out of order. + + The bridge device (endpoint 0) must be registered before a bridged child + endpoint, even if the child's ENDPOINT_ADDED event is the only one that + arrives (the bridge itself was never separately set up). + """ + node = create_node_from_fixture("atios_knx_bridge") + matter_client.get_node.return_value = node + + def identifier_for(endpoint_id: int) -> tuple[str, str]: + endpoint = node.endpoints[endpoint_id] + device_id = get_device_id(matter_client.server_info, endpoint) + return (DOMAIN, f"{ID_TYPE_DEVICE_ID}_{device_id}") + + endpoint_added_callback = next( + call.kwargs["callback"] + for call in matter_client.subscribe_events.call_args_list + if call.kwargs["event_filter"] == EventType.ENDPOINT_ADDED + ) + + assert ( + device_registry.async_get_device_by_identifier( + identifier_for(0), integration.entry_id + ) + is None + ) + + endpoint_added_callback( + EventType.ENDPOINT_ADDED, {"node_id": node.node_id, "endpoint_id": 29} + ) + await hass.async_block_till_done() + + bridge_entry = device_registry.async_get_device_by_identifier( + identifier_for(0), integration.entry_id + ) + assert bridge_entry is not None + + child_entry = device_registry.async_get_device_by_identifier( + identifier_for(29), integration.entry_id + ) + assert child_entry is not None + assert child_entry.via_device_id == bridge_entry.id + + +async def test_setup_node_sorts_bridge_before_child( + hass: HomeAssistant, + matter_client: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test initial node setup registers the bridge before a bridged child. + + Endpoints must be processed in endpoint-id order on the startup path + (`_setup_node`), even when the bridged child endpoint precedes endpoint 0 + in the node's raw endpoint order, otherwise resolving the child's + via_device_id would raise. + """ + node = create_node_from_fixture("atios_knx_bridge") + node.endpoints = { + endpoint_id: node.endpoints[endpoint_id] for endpoint_id in (29, 1, 0) + } + + def identifier_for(endpoint_id: int) -> tuple[str, str]: + endpoint = node.endpoints[endpoint_id] + device_id = get_device_id(matter_client.server_info, endpoint) + return (DOMAIN, f"{ID_TYPE_DEVICE_ID}_{device_id}") + + matter_client.get_nodes.return_value = [node] + config_entry = MockConfigEntry( + domain=DOMAIN, data={"url": "ws://localhost:5580/ws"} + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + bridge_entry = device_registry.async_get_device_by_identifier( + identifier_for(0), config_entry.entry_id + ) + assert bridge_entry is not None + + child_entry = device_registry.async_get_device_by_identifier( + identifier_for(29), config_entry.entry_id + ) + assert child_entry is not None + assert child_entry.via_device_id == bridge_entry.id + + @pytest.mark.usefixtures("matter_node") @pytest.mark.parametrize("node_fixture", ["mock_air_purifier"]) async def test_device_registry_single_node_composed_device( diff --git a/tests/components/media_player/test_intent.py b/tests/components/media_player/test_intent.py index f3064a16b14a..7fd729bb257f 100644 --- a/tests/components/media_player/test_intent.py +++ b/tests/components/media_player/test_intent.py @@ -804,19 +804,15 @@ async def test_search_and_play_media_player_intent(hass: HomeAssistant) -> None: # Test no search results search_results.clear() - response = await intent.async_handle( - hass, - "test", - media_player_intent.INTENT_MEDIA_SEARCH_AND_PLAY, - {"search_query": {"value": "another query"}}, - ) + with pytest.raises(intent.IntentHandleError, match="No results found"): + await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_SEARCH_AND_PLAY, + {"search_query": {"value": "another query"}}, + ) await hass.async_block_till_done() - assert response.response_type is intent.IntentResponseType.ACTION_DONE - - # A search failure is indicated by no "media" slot in the response. - assert not response.speech - assert "media" not in response.speech_slots assert len(search_calls) == 2 # Search was called again assert len(play_calls) == 1 # Play was not called again diff --git a/tests/components/met/test_init.py b/tests/components/met/test_init.py index 54f6930513b9..08f9650e331b 100644 --- a/tests/components/met/test_init.py +++ b/tests/components/met/test_init.py @@ -10,7 +10,6 @@ from homeassistant.components.met.const import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.core_config import async_process_ha_core_config -from homeassistant.helpers import device_registry as dr from . import init_integration @@ -50,30 +49,3 @@ async def test_fail_default_home_entry( "Skip setting up met.no integration; No Home location has been set" in caplog.text ) - - -async def test_removing_incorrect_devices( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - caplog: pytest.LogCaptureFixture, - mock_weather, -) -> None: - """Test we remove incorrect devices.""" - entry = await init_integration(hass) - - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - name="Forecast_legacy", - entry_type=dr.DeviceEntryType.SERVICE, - identifiers={(DOMAIN,)}, - manufacturer="Met.no", - model="Forecast", - configuration_url="https://www.met.no/en", - ) - - assert await hass.config_entries.async_reload(entry.entry_id) - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - - assert not device_registry.async_get_device(identifiers={(DOMAIN,)}) - assert device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) - assert "Removing improper device Forecast_legacy" in caplog.text diff --git a/tests/components/met/test_weather.py b/tests/components/met/test_weather.py index 131457e867dd..a07e18b4f6a8 100644 --- a/tests/components/met/test_weather.py +++ b/tests/components/met/test_weather.py @@ -1,7 +1,7 @@ """Test Met weather entity.""" from homeassistant import config_entries -from homeassistant.components.met import DOMAIN +from homeassistant.components.met.const import DOMAIN from homeassistant.components.weather import ( ATTR_CONDITION_CLOUDY, ATTR_WEATHER_DEW_POINT, diff --git a/tests/components/midea/test_climate.py b/tests/components/midea/test_climate.py index 14053f2c5e61..6aa60b4ab7e9 100644 --- a/tests/components/midea/test_climate.py +++ b/tests/components/midea/test_climate.py @@ -2,6 +2,7 @@ from collections.abc import Callable from typing import Any +from unittest.mock import patch from midealocal.const import DeviceType from midealocal.devices.ac import DeviceAttributes as ACAttributes @@ -44,7 +45,7 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.components.midea.climate import FAN_FULL_SPEED, FAN_SILENT -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er @@ -1460,6 +1461,7 @@ async def test_climate_state_snapshot( ) -> None: """Test async_setup_entry creates entities for each device type.""" config_entry = mock_config_entry(device) - await setup_integration(hass, config_entry, device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, config_entry, device) - await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/motion_blinds/test_init.py b/tests/components/motion_blinds/test_init.py new file mode 100644 index 000000000000..8a49ad305f5d --- /dev/null +++ b/tests/components/motion_blinds/test_init.py @@ -0,0 +1,109 @@ +"""Test the Motionblinds setup.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch + +from motionblinds import DEVICE_TYPES_GATEWAY, DEVICE_TYPES_WIFI, BlindType +from motionblinds.motion_blinds import DEVICE_TYPE_BLIND +import pytest + +from homeassistant.components.motion_blinds.const import DEFAULT_INTERFACE, DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + +TEST_HOST = "1.2.3.4" +TEST_API_KEY = "12ab345c-d67e-8f" +TEST_GATEWAY_MAC = "abcdefghijkl" +TEST_BLIND_MAC = "abcdefghijkl0001" + + +@pytest.fixture(name="mock_gateway") +def mock_gateway_fixture() -> Mock: + """Return a mocked gateway with a single sub-blind.""" + blind = Mock() + blind.mac = TEST_BLIND_MAC + blind.device_type = DEVICE_TYPE_BLIND + blind.type = BlindType.RollerBlind + blind.blind_type = BlindType.RollerBlind.name + blind.wireless_name = "RF" + blind.battery_voltage = 0 + blind.limit_status = "Limit2Detected" + blind.position = 0 + blind.angle = 0 + blind.RSSI = -50 + + gateway = Mock() + gateway.mac = TEST_GATEWAY_MAC + gateway.device_type = DEVICE_TYPES_GATEWAY[0] + gateway.firmware = "1.0.0" + gateway.protocol = "1.0" + gateway.device_list = {TEST_BLIND_MAC: blind} + gateway.blind_type_list = {TEST_BLIND_MAC: BlindType.RollerBlind.value} + + blind._gateway = gateway + return gateway + + +@pytest.fixture(name="mock_connect", autouse=True) +def mock_connect_fixture(mock_gateway: Mock) -> Generator[None]: + """Mock the connection to the Motion gateway.""" + with ( + patch( + "homeassistant.components.motion_blinds.AsyncMotionMulticast" + ) as multicast_class, + patch( + "homeassistant.components.motion_blinds.ConnectMotionGateway" + ) as connect_class, + ): + multicast_class.return_value.Start_listen = AsyncMock() + connect = connect_class.return_value + connect.async_check_interface = AsyncMock(return_value=DEFAULT_INTERFACE) + connect.async_connect_gateway = AsyncMock(return_value=True) + connect.gateway_device = mock_gateway + yield + + +@pytest.mark.parametrize( + "gateway_device_type", + [ + pytest.param(DEVICE_TYPES_GATEWAY[0], id="reported-gateway-type"), + pytest.param(DEVICE_TYPES_WIFI[0], id="unexpected-non-gateway-type"), + ], +) +async def test_sub_blind_links_to_gateway_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_gateway: Mock, + gateway_device_type: str, +) -> None: + """Test that a sub-blind device links to the gateway device as its parent. + + The gateway device must be registered up front even when the gateway + self-reports a device_type outside DEVICE_TYPES_GATEWAY, so RF (non-Wi-Fi) + blinds can still resolve it as their via_device parent. + """ + mock_gateway.device_type = gateway_device_type + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_MAC, + data={CONF_HOST: TEST_HOST, CONF_API_KEY: TEST_API_KEY}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + gateway_device = device_registry.async_get_device_by_identifier( + (DOMAIN, TEST_GATEWAY_MAC), entry.entry_id + ) + blind_device = device_registry.async_get_device_by_identifier( + (DOMAIN, TEST_BLIND_MAC), entry.entry_id + ) + + assert gateway_device is not None + assert blind_device is not None + assert blind_device.via_device_id == gateway_device.id diff --git a/tests/components/mqtt/test_event.py b/tests/components/mqtt/test_event.py index fdd227495e0d..dad15506e8f6 100644 --- a/tests/components/mqtt/test_event.py +++ b/tests/components/mqtt/test_event.py @@ -53,7 +53,7 @@ from .common import ( help_test_update_with_json_attrs_not_dict, ) -from tests.common import MockConfigEntry, async_fire_mqtt_message +from tests.common import async_fire_mqtt_message from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { @@ -547,10 +547,9 @@ async def test_entity_device_info_with_hub( ) -> None: """Test MQTT event device registry integration.""" await mqtt_mock_entry() - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) + mqtt_config_entry = hass.config_entries.async_entries(DOMAIN)[0] hub = device_registry.async_get_or_create( - config_entry_id=other_config_entry.entry_id, + config_entry_id=mqtt_config_entry.entry_id, connections=set(), identifiers={("mqtt", "hub-id")}, manufacturer="manufacturer", diff --git a/tests/components/mqtt/test_infrared.py b/tests/components/mqtt/test_infrared.py index 3b98f78d3493..7e51fe380225 100644 --- a/tests/components/mqtt/test_infrared.py +++ b/tests/components/mqtt/test_infrared.py @@ -183,6 +183,12 @@ async def test_receiving_command_success_using_value_template( logging.DEBUG, False, ), + ( + '""', + "Ignoring payload for infrared.test on topic test-topic, with template None", + logging.DEBUG, + False, + ), ( "None", "Ignoring payload for infrared.test on topic test-topic, with template None", diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 90a3a339c122..4f9dd0ba971e 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -76,7 +76,6 @@ from .common import ( ) from tests.common import ( - MockConfigEntry, async_capture_events, async_fire_mqtt_message, async_fire_time_changed, @@ -1585,10 +1584,9 @@ async def test_entity_device_info_with_hub( ) -> None: """Test MQTT sensor device registry integration.""" await mqtt_mock_entry() - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) + mqtt_config_entry = hass.config_entries.async_entries(DOMAIN)[0] hub = device_registry.async_get_or_create( - config_entry_id=other_config_entry.entry_id, + config_entry_id=mqtt_config_entry.entry_id, connections=set(), identifiers={("mqtt", "hub-id")}, manufacturer="manufacturer", diff --git a/tests/components/music_assistant/test_media_browser.py b/tests/components/music_assistant/test_media_browser.py index 69539cb05ca3..90ddac684a92 100644 --- a/tests/components/music_assistant/test_media_browser.py +++ b/tests/components/music_assistant/test_media_browser.py @@ -437,6 +437,213 @@ async def test_browse_artist_search_media_classes( assert browse_item.search_media_classes == [MediaClass.ALBUM, MediaClass.TRACK] +@pytest.mark.parametrize( + "media_content_type", + [MediaType.MUSIC, MediaType.ARTIST, None], +) +async def test_search_within_artist_ignores_surrounding_media_type( + hass: HomeAssistant, + music_assistant_client: MagicMock, + media_content_type: str | None, +) -> None: + """Test that an artist search returns its albums and tracks either way. + + An artist holds no artists, so a surrounding artist media type must not be + taken as the thing to look for, or the whole response gets discarded. + """ + await setup_integration_from_fixtures(hass, music_assistant_client) + + artist = MagicMock() + artist.name = "Test Artist" + mock = MockSearchResults(["album", "track"]) + + with ( + patch.object( + music_assistant_client.music, "get_item_by_uri", return_value=artist + ), + patch.object( + music_assistant_client.music, + "search", + return_value=SearchResults(albums=mock.albums, tracks=mock.tracks), + ) as mock_search, + ): + search_results = await async_search_media( + music_assistant_client, + SearchMediaQuery( + search_query="test", + media_content_type=media_content_type, + media_content_id="library://artist/127", + ), + ) + + assert mock_search.call_args.kwargs["media_types"] == [ + MASSMediaType.ALBUM, + MASSMediaType.TRACK, + ] + assert {item.media_class for item in search_results.result} == { + MediaClass.ALBUM, + MediaClass.TRACK, + } + + +@pytest.mark.parametrize( + "media_content_type", + [MediaType.MUSIC, MediaType.ARTIST], +) +@pytest.mark.parametrize( + ("media_filter_classes", "expected_media_types", "expected_classes"), + [ + ( + None, + [MASSMediaType.ALBUM, MASSMediaType.TRACK], + {MediaClass.ALBUM, MediaClass.TRACK}, + ), + ({MediaClass.ALBUM}, [MASSMediaType.ALBUM], {MediaClass.ALBUM}), + ({MediaClass.TRACK}, [MASSMediaType.TRACK], {MediaClass.TRACK}), + ], +) +async def test_search_within_artist_with_filter_classes( + hass: HomeAssistant, + music_assistant_client: MagicMock, + media_content_type: str, + media_filter_classes: set[MediaClass] | None, + expected_media_types: list[MASSMediaType], + expected_classes: set[MediaClass], +) -> None: + """Test that the filters offered on an artist listing narrow its results. + + A filter is picked by the user, so it has to win from whatever media type + happens to surround the search. + """ + await setup_integration_from_fixtures(hass, music_assistant_client) + + artist = MagicMock() + artist.name = "Test Artist" + mock = MockSearchResults(["album", "track"]) + + with ( + patch.object( + music_assistant_client.music, "get_item_by_uri", return_value=artist + ), + patch.object( + music_assistant_client.music, + "search", + return_value=SearchResults(albums=mock.albums, tracks=mock.tracks), + ) as mock_search, + ): + search_results = await async_search_media( + music_assistant_client, + SearchMediaQuery( + search_query="test", + media_content_type=media_content_type, + media_content_id="library://artist/127", + media_filter_classes=media_filter_classes, + ), + ) + + # the artist name scopes the query that is sent to the search api + assert mock_search.call_args.args[0] == "Test Artist - test" + # a filter narrows what we ask for, instead of asking for everything + # an artist can hold and dropping most of the response again + assert mock_search.call_args.kwargs["media_types"] == expected_media_types + assert {item.media_class for item in search_results.result} == expected_classes + + +@pytest.mark.parametrize( + ("media_content_id", "expected_media_types"), + [ + ( + None, + [ + MASSMediaType.ARTIST, + MASSMediaType.ALBUM, + MASSMediaType.TRACK, + MASSMediaType.PLAYLIST, + ], + ), + # inside an artist there is no more music to be had than their own + ("library://artist/127", [MASSMediaType.ALBUM, MASSMediaType.TRACK]), + ], +) +async def test_search_media_music_class_searches_music( + hass: HomeAssistant, + music_assistant_client: MagicMock, + media_content_id: str | None, + expected_media_types: list[MASSMediaType], +) -> None: + """Test that asking for music searches music instead of radio. + + A voice assistant sends this class for a plain "play something" request, + and we hand radio stations back to HA under the same class, which is why + it used to end up searching radio only. + """ + await setup_integration_from_fixtures(hass, music_assistant_client) + + artist = MagicMock() + artist.name = "Test Artist" + mock = MockSearchResults(["artist", "album", "track", "playlist"]) + + with ( + patch.object( + music_assistant_client.music, "get_item_by_uri", return_value=artist + ), + patch.object( + music_assistant_client.music, + "search", + return_value=SearchResults( + artists=mock.artists, + albums=mock.albums, + tracks=mock.tracks, + playlists=mock.playlists, + ), + ) as mock_search, + ): + search_results = await async_search_media( + music_assistant_client, + SearchMediaQuery( + search_query="some artist", + media_content_id=media_content_id, + media_filter_classes={MediaClass.MUSIC}, + ), + ) + + assert mock_search.call_args.kwargs["media_types"] == expected_media_types + assert search_results.result + + +@pytest.mark.parametrize( + ("media_content_id", "media_filter_classes"), + [ + # an artist holds no playlists, so there is nothing to find + ("library://artist/127", {MediaClass.PLAYLIST}), + # nothing we can search for is an image + ("library://artist/127", {MediaClass.IMAGE}), + (None, {MediaClass.IMAGE}), + ], +) +async def test_search_media_with_unsearchable_filter( + hass: HomeAssistant, + music_assistant_client: MagicMock, + media_content_id: str | None, + media_filter_classes: set[MediaClass], +) -> None: + """Test that a filter we cannot honour returns nothing, not everything.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + + with patch.object(music_assistant_client.music, "search") as mock_search: + search_results = await async_search_media( + music_assistant_client, + SearchMediaQuery( + search_query="test", + media_content_id=media_content_id, + media_filter_classes=media_filter_classes, + ), + ) + + mock_search.assert_not_called() + assert search_results.result == [] + + async def test_search_media_results_are_browsable( hass: HomeAssistant, music_assistant_client: MagicMock, diff --git a/tests/components/music_assistant/test_services.py b/tests/components/music_assistant/test_services.py index 5a6949764caf..bed611b830fe 100644 --- a/tests/components/music_assistant/test_services.py +++ b/tests/components/music_assistant/test_services.py @@ -1,7 +1,8 @@ """Test Music Assistant actions.""" -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, call +from music_assistant_models.enums import MediaType from music_assistant_models.media_items import SearchResults import pytest from syrupy.assertion import SnapshotAssertion @@ -10,6 +11,7 @@ from homeassistant.components.music_assistant.const import ( ATTR_FAVORITE, ATTR_MEDIA_TYPE, ATTR_SEARCH_NAME, + ATTR_USERNAME, DOMAIN, ) from homeassistant.components.music_assistant.services import ( @@ -18,6 +20,7 @@ from homeassistant.components.music_assistant.services import ( ) from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from .common import create_library_albums_from_fixture, setup_integration_from_fixtures @@ -48,6 +51,59 @@ async def test_search_action( assert response == snapshot +async def test_search_action_with_username( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test music assistant search action.""" + entry = await setup_integration_from_fixtures(hass, music_assistant_client) + + # tests for servers supporting the username + music_assistant_client.server_info.schema_version = 35 + music_assistant_client.music.client.send_command = AsyncMock( + return_value={"albums": []} + ) + + # valid user ok and forwarded + await hass.services.async_call( + DOMAIN, + SERVICE_SEARCH, + { + ATTR_CONFIG_ENTRY_ID: entry.entry_id, + ATTR_SEARCH_NAME: "test", + ATTR_USERNAME: "user_user", + }, + blocking=True, + return_response=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "music/search", + search_query="test", + media_types=MediaType.ALL, + limit=5, + library_only=False, + user="user_user", + require_schema=35, + ) + + # not valid because of name, disabled or guest + for username in ("non_existing_user", "party_guest", "user_disabled"): + with pytest.raises(ServiceValidationError) as exc: + await hass.services.async_call( + DOMAIN, + SERVICE_SEARCH, + { + ATTR_CONFIG_ENTRY_ID: entry.entry_id, + ATTR_SEARCH_NAME: "test", + ATTR_USERNAME: username, + }, + blocking=True, + return_response=True, + ) + assert exc.value.translation_key == "invalid_username" + + @pytest.mark.parametrize( "media_type", [ @@ -80,3 +136,55 @@ async def test_get_library_action( return_response=True, ) assert response == snapshot + + +@pytest.mark.parametrize( + "media_type", + [ + "artist", + "album", + "track", + "playlist", + "audiobook", + "podcast", + "radio", + ], +) +async def test_get_library_action_with_username( + hass: HomeAssistant, + music_assistant_client: MagicMock, + media_type: str, +) -> None: + """Test music assistant get_library action with username.""" + entry = await setup_integration_from_fixtures(hass, music_assistant_client) + # username supported from schema 35 and above + music_assistant_client.server_info.schema_version = 35 + + # invalid users + for username in ("non_existing_user", "party_guest", "user_disabled"): + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_LIBRARY, + { + ATTR_CONFIG_ENTRY_ID: entry.entry_id, + ATTR_FAVORITE: False, + ATTR_MEDIA_TYPE: media_type, + ATTR_USERNAME: username, + }, + blocking=True, + return_response=True, + ) + # valid user + await hass.services.async_call( + DOMAIN, + SERVICE_GET_LIBRARY, + { + ATTR_CONFIG_ENTRY_ID: entry.entry_id, + ATTR_FAVORITE: False, + ATTR_MEDIA_TYPE: media_type, + ATTR_USERNAME: "user_user", + }, + blocking=True, + return_response=True, + ) diff --git a/tests/components/netgear/test_init.py b/tests/components/netgear/test_init.py new file mode 100644 index 000000000000..5c11b0204666 --- /dev/null +++ b/tests/components/netgear/test_init.py @@ -0,0 +1,89 @@ +"""Tests for the Netgear integration setup.""" + +from unittest.mock import Mock, patch + +from pynetgear import Device + +from homeassistant.components.netgear.const import DOMAIN +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + +SERIAL = "5ER1AL0000001" +HOST = "10.0.0.1" + +ROUTER_INFOS = { + "DeviceMode": "0", + "ModelName": "RBR20", + "SerialNumber": SERIAL, + "Firmwareversion": "V2.3.5.26", + "Hardwareversion": "N/A", + "DeviceName": "Desk", +} + +TRACKED_DEVICE = Device( + name="Tracked-Device", + ip="10.0.0.10", + mac="AA:BB:CC:DD:EE:FF", + type="wireless", + signal=100, + link_rate=800, + allow_or_block="Allow", + device_type=32, + device_model="iPhone", + ssid="MyWifi", + conn_ap_mac="", +) + + +async def test_tracked_device_links_to_router( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test a tracked device is linked to the router via its via_device.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: 80, + CONF_SSL: False, + CONF_USERNAME: "admin", + CONF_PASSWORD: "password", + }, + unique_id=SERIAL, + ) + entry.add_to_hass(hass) + + with patch("homeassistant.components.netgear.router.Netgear") as netgear_mock: + api = netgear_mock.return_value + api.login_try_port = Mock(return_value=True) + api.get_info = Mock(return_value=ROUTER_INFOS) + api.port = 80 + api.ssl = False + api.get_attached_devices_2 = Mock(return_value=[TRACKED_DEVICE]) + api.get_traffic_meter = Mock(return_value=None) + api.get_new_speed_test_result = Mock(return_value=None) + api.check_new_firmware = Mock(return_value=None) + api.get_system_info = Mock(return_value=None) + api.check_ethernet_link = Mock(return_value=None) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + router_device = device_registry.async_get_device_by_identifier( + (DOMAIN, SERIAL), entry.entry_id + ) + assert router_device is not None + + tracked_device = device_registry.async_get_device_by_connection( + (dr.CONNECTION_NETWORK_MAC, dr.format_mac(TRACKED_DEVICE.mac)), entry.entry_id + ) + assert tracked_device is not None + assert tracked_device.via_device_id == router_device.id diff --git a/tests/components/ntfy/conftest.py b/tests/components/ntfy/conftest.py index 44b55bcb70f3..892c4150090e 100644 --- a/tests/components/ntfy/conftest.py +++ b/tests/components/ntfy/conftest.py @@ -45,8 +45,7 @@ def mock_aiontfy() -> Generator[AsyncMock]: load_fixture("account.json", DOMAIN) ) client.generate_token.return_value = AccountTokenResponse( - token="token", - last_access=datetime.now(), # pylint: disable=home-assistant-enforce-naive-now + token="token", last_access=datetime(1970, 1, 1, 0, 0, 0, tzinfo=UTC) ) client.version.return_value = Version.from_json( load_fixture("version.json", DOMAIN) diff --git a/tests/components/ntfy/test_config_flow.py b/tests/components/ntfy/test_config_flow.py index 37ed5c953113..82bb6b335adf 100644 --- a/tests/components/ntfy/test_config_flow.py +++ b/tests/components/ntfy/test_config_flow.py @@ -1,6 +1,6 @@ """Test the ntfy config flow.""" -from datetime import datetime +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock @@ -450,8 +450,7 @@ async def test_flow_reauth( }, ) mock_aiontfy.generate_token.return_value = AccountTokenResponse( - token="newtoken", - last_access=datetime.now(), # pylint: disable=home-assistant-enforce-naive-now + token="newtoken", last_access=datetime(1970, 1, 1, 0, 0, 0, tzinfo=UTC) ) config_entry.add_to_hass(hass) result = await config_entry.start_reauth_flow(hass) @@ -510,8 +509,7 @@ async def test_form_reauth_errors( ) mock_aiontfy.account.side_effect = exception mock_aiontfy.generate_token.return_value = AccountTokenResponse( - token="newtoken", - last_access=datetime.now(), # pylint: disable=home-assistant-enforce-naive-now + token="newtoken", last_access=datetime(1970, 1, 1, 0, 0, 0, tzinfo=UTC) ) config_entry.add_to_hass(hass) result = await config_entry.start_reauth_flow(hass) @@ -597,8 +595,7 @@ async def test_flow_reconfigure( }, ) mock_aiontfy.generate_token.return_value = AccountTokenResponse( - token="newtoken", - last_access=datetime.now(), # pylint: disable=home-assistant-enforce-naive-now + token="newtoken", last_access=datetime(1970, 1, 1, 0, 0, 0, tzinfo=UTC) ) config_entry.add_to_hass(hass) result = await config_entry.start_reconfigure_flow(hass) @@ -700,8 +697,7 @@ async def test_flow_reconfigure_errors( }, ) mock_aiontfy.generate_token.return_value = AccountTokenResponse( - token="newtoken", - last_access=datetime.now(), # pylint: disable=home-assistant-enforce-naive-now + token="newtoken", last_access=datetime(1970, 1, 1, 0, 0, 0, tzinfo=UTC) ) mock_aiontfy.account.side_effect = exception diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index 0644a9faa891..8da87f02e20f 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -173,6 +173,7 @@ async def test_thinking_content( ollama.CONF_THINK: True, }, ) + await hass.async_block_till_done() conversation_id = "conversation_id_1234" @@ -717,6 +718,7 @@ async def test_message_history_unlimited( subentry, data={**subentry.data, ollama.CONF_MAX_HISTORY: 0}, ) + await hass.async_block_till_done() for i in range(100): result = await conversation.async_converse( hass, @@ -894,6 +896,7 @@ async def test_reasoning_filter( ollama.CONF_THINK: think, }, ) + await hass.async_block_till_done() with patch( "ollama.AsyncClient.chat", diff --git a/tests/components/omie/conftest.py b/tests/components/omie/conftest.py index d13ececb629e..2e9a51293913 100644 --- a/tests/components/omie/conftest.py +++ b/tests/components/omie/conftest.py @@ -10,6 +10,7 @@ import pytest from homeassistant.components.omie.const import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from . import price_enc, spot_price_fetcher @@ -85,7 +86,7 @@ def mock_omie_results_jan15() -> OMIEResults: ], ) return OMIEResults( - updated_at=dt.datetime.now(), # pylint: disable=home-assistant-enforce-naive-now + updated_at=dt_util.now(), market_date=test_date, contents=spot_data, raw=json.dumps(spot_data), @@ -120,7 +121,7 @@ def mock_omie_results_oct15() -> OMIEResults: ], ) return OMIEResults( - updated_at=dt.datetime.now(), # pylint: disable=home-assistant-enforce-naive-now + updated_at=dt_util.now(), market_date=test_date, contents=spot_data, raw=json.dumps(spot_data), @@ -155,7 +156,7 @@ def mock_omie_results_oct26_dst() -> OMIEResults: ], ) return OMIEResults( - updated_at=dt.datetime.now(), # pylint: disable=home-assistant-enforce-naive-now + updated_at=dt_util.now(), market_date=test_date, contents=spot_data, raw=json.dumps(spot_data), @@ -190,7 +191,7 @@ def mock_omie_results_jan16() -> OMIEResults: ], ) return OMIEResults( - updated_at=dt.datetime.now(), # pylint: disable=home-assistant-enforce-naive-now + updated_at=dt_util.now(), market_date=test_date, contents=spot_data, raw=json.dumps(spot_data), diff --git a/tests/components/ouman_eh_800/test_init.py b/tests/components/ouman_eh_800/test_init.py index 10e82db932e9..3f874d0db895 100644 --- a/tests/components/ouman_eh_800/test_init.py +++ b/tests/components/ouman_eh_800/test_init.py @@ -8,12 +8,37 @@ from ouman_eh_800_api import ( ) import pytest +from homeassistant.components.ouman_eh_800.const import DOMAIN, OumanDevice from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry +@pytest.mark.usefixtures("mock_ouman_client") +async def test_sub_devices_link_to_main_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that the L1/L2 sub-devices link to the main device via via_device_id.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + entry_id = mock_config_entry.entry_id + main_device = device_registry.async_get_device(identifiers={(DOMAIN, entry_id)}) + assert main_device is not None + + for sub_device in (OumanDevice.L1, OumanDevice.L2): + device = device_registry.async_get_device( + identifiers={(DOMAIN, f"{entry_id}_{sub_device}")} + ) + assert device is not None + assert device.via_device_id == main_device.id + + @pytest.mark.usefixtures("mock_ouman_client") async def test_setup_unload_entry( hass: HomeAssistant, diff --git a/tests/components/philips_js/conftest.py b/tests/components/philips_js/conftest.py index 911753a8852c..8785cdaf1e4b 100644 --- a/tests/components/philips_js/conftest.py +++ b/tests/components/philips_js/conftest.py @@ -45,6 +45,7 @@ def mock_tv(): tv.notify_change_supported = False tv.pairing_type = None tv.powerstate = None + tv.screenstate = None tv.source_id = None tv.ambilight_current_configuration = None tv.ambilight_styles = {} diff --git a/tests/components/philips_js/test_media_player.py b/tests/components/philips_js/test_media_player.py new file mode 100644 index 000000000000..3a4425736347 --- /dev/null +++ b/tests/components/philips_js/test_media_player.py @@ -0,0 +1,41 @@ +"""Tests for the Philips TV media player.""" + +from haphilipsjs import PhilipsTV +import pytest + +from homeassistant.components.philips_js.const import TV_STATE_OFF, TV_STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from . import MOCK_ENTITY_ID + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("powerstate", "screenstate", "expected_state"), + [ + pytest.param(TV_STATE_ON, TV_STATE_OFF, STATE_ON, id="powerstate-on"), + pytest.param("Standby", TV_STATE_ON, STATE_OFF, id="powerstate-standby"), + pytest.param(None, TV_STATE_ON, STATE_ON, id="screenstate-on"), + pytest.param(None, TV_STATE_OFF, STATE_OFF, id="screenstate-off"), + ], +) +async def test_state( + hass: HomeAssistant, + mock_tv: PhilipsTV, + mock_config_entry: MockConfigEntry, + powerstate: str | None, + screenstate: str, + expected_state: str, +) -> None: + """Test the media player state.""" + mock_tv.json_feature_supported.return_value = False + mock_tv.powerstate = powerstate + mock_tv.screenstate = screenstate + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert (state := hass.states.get(MOCK_ENTITY_ID)) + assert state.state == expected_state diff --git a/tests/components/philips_js/test_switch.py b/tests/components/philips_js/test_switch.py new file mode 100644 index 000000000000..3967b5221433 --- /dev/null +++ b/tests/components/philips_js/test_switch.py @@ -0,0 +1,48 @@ +"""Tests for the Philips TV switches.""" + +from haphilipsjs import PhilipsTV +import pytest + +from homeassistant.components.philips_js.const import TV_STATE_OFF +from homeassistant.const import STATE_OFF +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("entity_id", "screenstate", "huelamp_power"), + [ + pytest.param( + "switch.philips_tv_screen_state", + TV_STATE_OFF, + None, + id="screen", + ), + pytest.param( + "switch.philips_tv_ambilight_hue", + None, + TV_STATE_OFF, + id="ambilight-hue", + ), + ], +) +async def test_available_without_powerstate( + hass: HomeAssistant, + mock_tv: PhilipsTV, + mock_config_entry: MockConfigEntry, + entity_id: str, + screenstate: str | None, + huelamp_power: str | None, +) -> None: + """Test switches are available when the power state endpoint is absent.""" + mock_tv.json_feature_supported.return_value = True + mock_tv.powerstate = None + mock_tv.screenstate = screenstate + mock_tv.huelamp_power = huelamp_power + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF diff --git a/tests/components/reolink/test_binary_sensor.py b/tests/components/reolink/test_binary_sensor.py index 415bd4bbfcdb..14f55a5918c2 100644 --- a/tests/components/reolink/test_binary_sensor.py +++ b/tests/components/reolink/test_binary_sensor.py @@ -196,6 +196,36 @@ async def test_dual_lens_sub_devices_nvr( assert lens_device.via_device_id == parent_device.id +async def test_dual_lens_sub_devices_nvr_multi_channel( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_host: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a lens sub-device on channel >= 1 links to its camera device on a NVR host.""" + reolink_host.model = TEST_DUO_MODEL + reolink_host.channels = [0, 1] + reolink_host.stream_channels = [0, 1] + # channel 1 camera device uses the "_ch{channel}" id (no UID support) + reolink_host.supported.side_effect = lambda ch, cap: ( + not (cap == "UID" and ch is not None) + ) + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BINARY_SENSOR]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + parent_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{TEST_UID}_ch1"), config_entry.entry_id + ) + assert parent_device is not None + lens_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{TEST_UID}_lens1"), config_entry.entry_id + ) + assert lens_device is not None + assert lens_device.via_device_id == parent_device.id + + async def test_smart_ai_sensor( hass: HomeAssistant, freezer: FrozenDateTimeFactory, diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 832656d1cc34..5bf70fae2c2e 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -339,6 +339,36 @@ async def test_removing_chime( assert sorted(device_models) == sorted(expected_models) +async def test_via_device_id_chain( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_chime: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the host -> camera -> chime devices are linked via via_device_id.""" + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + host_device = device_registry.async_get_device_by_identifier( + (DOMAIN, TEST_UID), config_entry.entry_id + ) + assert host_device is not None + + camera_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{TEST_UID}_{TEST_UID_CAM}"), config_entry.entry_id + ) + assert camera_device is not None + assert camera_device.via_device_id == host_device.id + + chime_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{TEST_UID}_chime{reolink_chime.dev_id}"), config_entry.entry_id + ) + assert chime_device is not None + assert chime_device.via_device_id == camera_device.id + + @pytest.mark.parametrize( ( "original_id", diff --git a/tests/components/reolink/test_time.py b/tests/components/reolink/test_time.py new file mode 100644 index 000000000000..6fa340126d7a --- /dev/null +++ b/tests/components/reolink/test_time.py @@ -0,0 +1,105 @@ +"""Test the Reolink time platform.""" + +from datetime import time +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from reolink_aio.enums import SpotlightModeEnum +from reolink_aio.exceptions import InvalidParameterError, ReolinkError + +from homeassistant.components.time import DOMAIN as TIME_DOMAIN, SERVICE_SET_VALUE +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TIME, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .conftest import TEST_CAM_NAME + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_floodlight_schedule( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_host: MagicMock, +) -> None: + """Test the floodlight schedule start and end time entities.""" + reolink_host.whiteled_schedule.return_value = { + "StartHour": 18, + "StartMin": 0, + "EndHour": 6, + "EndMin": 30, + } + reolink_host.whiteled_mode_list.return_value = [SpotlightModeEnum.schedule.name] + reolink_host.set_spotlight_lighting_schedule = AsyncMock() + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.TIME]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + start_id = f"{Platform.TIME}.{TEST_CAM_NAME}_floodlight_schedule_start" + end_id = f"{Platform.TIME}.{TEST_CAM_NAME}_floodlight_schedule_end" + + assert hass.states.get(start_id).state == "18:00:00" + assert hass.states.get(end_id).state == "06:30:00" + + # Setting the start time keeps the existing end time (6:30) + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: start_id, ATTR_TIME: time(20, 15)}, + blocking=True, + ) + reolink_host.set_spotlight_lighting_schedule.assert_called_with(0, 6, 30, 20, 15) + + # Setting the end time keeps the existing start time (18:00) + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: end_id, ATTR_TIME: time(7, 0)}, + blocking=True, + ) + reolink_host.set_spotlight_lighting_schedule.assert_called_with(0, 7, 0, 18, 0) + + reolink_host.set_spotlight_lighting_schedule.side_effect = ReolinkError( + "Test error" + ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: start_id, ATTR_TIME: time(20, 15)}, + blocking=True, + ) + + reolink_host.set_spotlight_lighting_schedule.side_effect = InvalidParameterError( + "Test error" + ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: start_id, ATTR_TIME: time(20, 15)}, + blocking=True, + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_floodlight_schedule_unknown( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_host: MagicMock, +) -> None: + """Test the floodlight schedule entities when no schedule is available.""" + reolink_host.whiteled_mode_list.return_value = [SpotlightModeEnum.schedule.name] + reolink_host.whiteled_schedule.return_value = None + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.TIME]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + start_id = f"{Platform.TIME}.{TEST_CAM_NAME}_floodlight_schedule_start" + assert hass.states.get(start_id).state == STATE_UNKNOWN diff --git a/tests/components/shelly/test_devices.py b/tests/components/shelly/test_devices.py index 827a3575bdba..ccd6479ca71c 100644 --- a/tests/components/shelly/test_devices.py +++ b/tests/components/shelly/test_devices.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry -from . import force_uptime_value, init_integration, snapshot_device_entities +from . import MOCK_MAC, force_uptime_value, init_integration, snapshot_device_entities from tests.common import async_load_json_object_fixture @@ -579,7 +579,7 @@ async def test_blu_trv_device_info( device_registry: DeviceRegistry, ) -> None: """Test BLU TRV device info.""" - await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + config_entry = await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) entry = entity_registry.async_get("climate.trv_name") assert entry @@ -590,6 +590,12 @@ async def test_blu_trv_device_info( assert device_entry.model_id == "SBTR-001AEU" assert device_entry.sw_version == "v1.2.10" + gateway_device_entry = device_registry.async_get_device_by_identifier( + (DOMAIN, MOCK_MAC), config_entry.entry_id + ) + assert gateway_device_entry + assert device_entry.via_device_id == gateway_device_entry.id + @pytest.mark.parametrize( "fixture", diff --git a/tests/components/solarlog/test_sensor.py b/tests/components/solarlog/test_sensor.py index 3ae9a34b759d..b34f0ce83219 100644 --- a/tests/components/solarlog/test_sensor.py +++ b/tests/components/solarlog/test_sensor.py @@ -12,6 +12,7 @@ from solarlog_cli.solarlog_exceptions import ( from solarlog_cli.solarlog_models import InverterData from syrupy.assertion import SnapshotAssertion +from homeassistant.components.solarlog.const import DOMAIN from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceRegistry @@ -78,6 +79,30 @@ async def test_add_remove_entities( assert hass.states.get("sensor.inverter_3_consumption_year").state == "0.454" +@pytest.mark.usefixtures("mock_solarlog_connector") +async def test_inverter_via_device_id( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: DeviceRegistry, + entity_registry: EntityRegistry, +) -> None: + """Test inverter devices are linked to the controller device via via_device_id.""" + await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) + + controller_device = device_registry.async_get_device_by_identifier( + (DOMAIN, mock_config_entry.entry_id), mock_config_entry.entry_id + ) + assert controller_device is not None + + entity = entity_registry.async_get("sensor.inverter_1_consumption_year") + assert entity is not None + assert entity.device_id is not None + inverter_device = device_registry.async_get(entity.device_id) + assert inverter_device is not None + + assert inverter_device.via_device_id == controller_device.id + + @pytest.mark.parametrize( "exception", [ diff --git a/tests/components/switchbot_cloud/test_init.py b/tests/components/switchbot_cloud/test_init.py index 6877a9ebc14f..8667bc51a61d 100644 --- a/tests/components/switchbot_cloud/test_init.py +++ b/tests/components/switchbot_cloud/test_init.py @@ -1,6 +1,6 @@ """Tests for the SwitchBot Cloud integration init.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest @@ -20,6 +20,7 @@ from homeassistant.components.switchbot_cloud.const import ( DEFAULT_SCAN_INTERVAL, DOMAIN, ) +from homeassistant.components.switchbot_cloud.coordinator import SwitchBotCoordinator from homeassistant.components.webhook import DOMAIN as WEBHOOK_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( @@ -744,3 +745,28 @@ async def test_remove_entry_with_cloud_unavailable( await hass.async_block_till_done() assert not hass.config_entries.async_entries("switchbot_cloud") + + +async def test_single_coordinator_for_multi_platform_device( + hass: HomeAssistant, mock_list_devices: AsyncMock, mock_get_status: AsyncMock +) -> None: + """Test that a multi-platform device creates only one coordinator.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="relay-switch-pm-id-1", + deviceName="relay-switch-pm-1", + deviceType="Relay Switch 1PM", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.return_value = {"switchStatus": 0} + + with patch( + "homeassistant.components.switchbot_cloud.SwitchBotCoordinator", + wraps=SwitchBotCoordinator, + ) as coordinator_cls: + entry = await configure_integration(hass) + + assert entry.state is ConfigEntryState.LOADED + assert coordinator_cls.call_count == 1 diff --git a/tests/components/synology_dsm/conftest.py b/tests/components/synology_dsm/conftest.py index 908a0119a77e..74e49c8e56ca 100644 --- a/tests/components/synology_dsm/conftest.py +++ b/tests/components/synology_dsm/conftest.py @@ -1,7 +1,7 @@ """Configure Synology DSM tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest @@ -9,6 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from .common import mock_dsm_hardware, mock_dsm_information +from .consts import HOST, MACS @pytest.fixture @@ -34,7 +35,9 @@ def fixture_dsm(): dsm.update = AsyncMock(return_value=True) dsm.information = mock_dsm_information() - dsm.network.update = AsyncMock(return_value=True) + dsm.network = Mock( + update=AsyncMock(return_value=True), macs=MACS, hostname=HOST + ) dsm.hardware = mock_dsm_hardware() dsm.surveillance_station.update = AsyncMock(return_value=True) dsm.upgrade.update = AsyncMock(return_value=True) diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index 3600667b4113..75e1dfda5bf3 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -92,7 +92,9 @@ def mock_dsm_with_filestation(): dsm.surveillance_station.update = AsyncMock(return_value=True) dsm.upgrade.update = AsyncMock(return_value=True) dsm.utilisation = Mock(cpu_user_load=1, update=AsyncMock(return_value=True)) - dsm.network = Mock(update=AsyncMock(return_value=True), macs=MACS) + dsm.network = Mock( + update=AsyncMock(return_value=True), macs=MACS, hostname=HOST + ) dsm.hardware = mock_dsm_hardware() dsm.storage = Mock( disks_ids=["sda", "sdb", "sdc"], @@ -146,7 +148,9 @@ def mock_dsm_without_filestation(): dsm.surveillance_station.update = AsyncMock(return_value=True) dsm.upgrade.update = AsyncMock(return_value=True) dsm.utilisation = Mock(cpu_user_load=1, update=AsyncMock(return_value=True)) - dsm.network = Mock(update=AsyncMock(return_value=True), macs=MACS) + dsm.network = Mock( + update=AsyncMock(return_value=True), macs=MACS, hostname=HOST + ) dsm.hardware = mock_dsm_hardware() dsm.information = mock_dsm_information() dsm.storage = Mock( diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index c9dec99fbcec..c29045c6cff5 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -67,7 +67,9 @@ def mock_controller_service(): dsm.surveillance_station.update = AsyncMock(return_value=True) dsm.upgrade.update = AsyncMock(return_value=True) dsm.utilisation = Mock(cpu_user_load=1, update=AsyncMock(return_value=True)) - dsm.network = Mock(update=AsyncMock(return_value=True), macs=MACS) + dsm.network = Mock( + update=AsyncMock(return_value=True), macs=MACS, hostname=HOST + ) dsm.hardware = mock_dsm_hardware() dsm.storage = Mock( disks_ids=["sda", "sdb", "sdc"], @@ -91,7 +93,9 @@ def mock_controller_service_2sa(): dsm.surveillance_station.update = AsyncMock(return_value=True) dsm.upgrade.update = AsyncMock(return_value=True) dsm.utilisation = Mock(cpu_user_load=1, update=AsyncMock(return_value=True)) - dsm.network = Mock(update=AsyncMock(return_value=True), macs=MACS) + dsm.network = Mock( + update=AsyncMock(return_value=True), macs=MACS, hostname=HOST + ) dsm.hardware = mock_dsm_hardware() dsm.storage = Mock( disks_ids=["sda", "sdb", "sdc"], @@ -113,7 +117,9 @@ def mock_controller_service_vdsm(): dsm.surveillance_station.update = AsyncMock(return_value=True) dsm.upgrade.update = AsyncMock(return_value=True) dsm.utilisation = Mock(cpu_user_load=1, update=AsyncMock(return_value=True)) - dsm.network = Mock(update=AsyncMock(return_value=True), macs=MACS) + dsm.network = Mock( + update=AsyncMock(return_value=True), macs=MACS, hostname=HOST + ) dsm.hardware = mock_dsm_hardware() dsm.storage = Mock( disks_ids=[], @@ -135,7 +141,9 @@ def mock_controller_service_with_filestation(): dsm.surveillance_station.update = AsyncMock(return_value=True) dsm.upgrade.update = AsyncMock(return_value=True) dsm.utilisation = Mock(cpu_user_load=1, update=AsyncMock(return_value=True)) - dsm.network = Mock(update=AsyncMock(return_value=True), macs=MACS) + dsm.network = Mock( + update=AsyncMock(return_value=True), macs=MACS, hostname=HOST + ) dsm.hardware = mock_dsm_hardware() dsm.storage = Mock( disks_ids=["sda", "sdb", "sdc"], diff --git a/tests/components/synology_dsm/test_media_source.py b/tests/components/synology_dsm/test_media_source.py index f16a2bac18fb..0f057b398bb5 100644 --- a/tests/components/synology_dsm/test_media_source.py +++ b/tests/components/synology_dsm/test_media_source.py @@ -2,7 +2,7 @@ from pathlib import Path import tempfile -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch from aiohttp import web import pytest @@ -41,7 +41,7 @@ def dsm_with_photos() -> MagicMock: dsm.login = AsyncMock(return_value=True) dsm.update = AsyncMock(return_value=True) dsm.information = mock_dsm_information() - dsm.network.update = AsyncMock(return_value=True) + dsm.network = Mock(update=AsyncMock(return_value=True), macs=MACS, hostname=HOST) dsm.hardware = mock_dsm_hardware() dsm.surveillance_station.update = AsyncMock(return_value=True) dsm.upgrade.update = AsyncMock(return_value=True) diff --git a/tests/components/synology_dsm/test_repairs.py b/tests/components/synology_dsm/test_repairs.py index 964f31a20ef2..f14aaba4ad34 100644 --- a/tests/components/synology_dsm/test_repairs.py +++ b/tests/components/synology_dsm/test_repairs.py @@ -41,7 +41,9 @@ def mock_dsm_with_filestation(): dsm.surveillance_station.update = AsyncMock(return_value=True) dsm.upgrade.update = AsyncMock(return_value=True) dsm.utilisation = Mock(cpu_user_load=1, update=AsyncMock(return_value=True)) - dsm.network = Mock(update=AsyncMock(return_value=True), macs=MACS) + dsm.network = Mock( + update=AsyncMock(return_value=True), macs=MACS, hostname=HOST + ) dsm.hardware = mock_dsm_hardware() dsm.storage = Mock( disks_ids=["sda", "sdb", "sdc"], diff --git a/tests/components/synology_dsm/test_sensor.py b/tests/components/synology_dsm/test_sensor.py index 8fc52ec8a46a..530b1f88190c 100644 --- a/tests/components/synology_dsm/test_sensor.py +++ b/tests/components/synology_dsm/test_sensor.py @@ -406,3 +406,26 @@ async def test_hub_device_info_mac_connections( ("mac", "00:11:32:xx:xx:59"), ("mac", "00:11:32:xx:xx:5a"), } + + +async def test_storage_device_via_device( + hass: HomeAssistant, + setup_dsm_with_usb: MagicMock, +) -> None: + """Test that storage/USB child devices link to the hub via via_device_id.""" + dev_reg = dr.async_get(hass) # pylint: disable=home-assistant-tests-registry-fixtures + entry_id = setup_dsm_with_usb.mock_entry.entry_id + hub_device = dev_reg.async_get_device_by_identifier((DOMAIN, SERIAL), entry_id) + assert hub_device is not None + + volume_device = dev_reg.async_get_device_by_identifier( + (DOMAIN, f"{SERIAL}_volume_1"), entry_id + ) + assert volume_device is not None + assert volume_device.via_device_id == hub_device.id + + usb_partition_device = dev_reg.async_get_device_by_identifier( + (DOMAIN, f"{SERIAL}_USB Disk 1 Partition 1"), entry_id + ) + assert usb_partition_device is not None + assert usb_partition_device.via_device_id == hub_device.id diff --git a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr index f96e11d555b6..70d4447cff51 100644 --- a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr @@ -808,6 +808,57 @@ 'state': 'off', }) # --- +# name: test_binary_sensor[binary_sensor.test_rear_defroster-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_rear_defroster', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Rear defroster', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear defroster', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_is_rear_defroster_on', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_is_rear_defroster_on', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_rear_defroster-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'heat', + : 'Test Rear defroster', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_defroster', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensor[binary_sensor.test_rear_driver_door-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1635,6 +1686,20 @@ 'state': 'off', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_rear_defroster-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'heat', + : 'Test Rear defroster', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_defroster', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_driver_door-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/teslemetry/test_binary_sensor.py b/tests/components/teslemetry/test_binary_sensor.py index bf7bea16d935..1f13871be3a7 100644 --- a/tests/components/teslemetry/test_binary_sensor.py +++ b/tests/components/teslemetry/test_binary_sensor.py @@ -86,6 +86,7 @@ async def test_binary_sensors_streaming( } }, Signal.DRIVER_SEAT_BELT: None, + Signal.REAR_DEFROST_ENABLED: True, }, "createdAt": "2024-10-04T10:45:17.537Z", } @@ -104,6 +105,7 @@ async def test_binary_sensors_streaming( assert hass.states.get("binary_sensor.test_front_driver_door").state == "off" assert hass.states.get("binary_sensor.test_front_passenger_door").state == "off" assert hass.states.get("binary_sensor.test_driver_seat_belt").state == "off" + assert hass.states.get("binary_sensor.test_rear_defroster").state == "on" async def test_binary_sensors_connectivity( diff --git a/tests/components/togrill/snapshots/test_number.ambr b/tests/components/togrill/snapshots/test_number.ambr index 0972818cbc35..4004cb711c86 100644 --- a/tests/components/togrill/snapshots/test_number.ambr +++ b/tests/components/togrill/snapshots/test_number.ambr @@ -246,6 +246,68 @@ 'state': 'unknown', }) # --- +# name: test_setup[no_data][number.probe_1_timer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 720, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.probe_1_timer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Timer', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:timer-outline', + 'original_name': 'Timer', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'timer', + 'unique_id': '00000000-0000-0000-0000-000000000001_timer_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[no_data][number.probe_1_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Probe 1 Timer', + : 'mdi:timer-outline', + : 720, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.probe_1_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_setup[no_data][number.probe_2_maximum_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -432,6 +494,68 @@ 'state': 'unknown', }) # --- +# name: test_setup[no_data][number.probe_2_timer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 720, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.probe_2_timer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Timer', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:timer-outline', + 'original_name': 'Timer', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'timer', + 'unique_id': '00000000-0000-0000-0000-000000000001_timer_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[no_data][number.probe_2_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Probe 2 Timer', + : 'mdi:timer-outline', + : 720, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.probe_2_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_setup[one_probe_with_target_alarm][number.pro_05_alarm_interval-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -679,6 +803,68 @@ 'state': '50.0', }) # --- +# name: test_setup[one_probe_with_target_alarm][number.probe_1_timer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 720, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.probe_1_timer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Timer', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:timer-outline', + 'original_name': 'Timer', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'timer', + 'unique_id': '00000000-0000-0000-0000-000000000001_timer_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[one_probe_with_target_alarm][number.probe_1_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Probe 1 Timer', + : 'mdi:timer-outline', + : 720, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.probe_1_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_setup[one_probe_with_target_alarm][number.probe_2_maximum_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -865,6 +1051,68 @@ 'state': 'unknown', }) # --- +# name: test_setup[one_probe_with_target_alarm][number.probe_2_timer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 720, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.probe_2_timer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Timer', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:timer-outline', + 'original_name': 'Timer', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'timer', + 'unique_id': '00000000-0000-0000-0000-000000000001_timer_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[one_probe_with_target_alarm][number.probe_2_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Probe 2 Timer', + : 'mdi:timer-outline', + : 720, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.probe_2_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_setup_with_ambient[ambient_with_range][number.pro_05_alarm_interval-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1236,6 +1484,68 @@ 'state': 'unknown', }) # --- +# name: test_setup_with_ambient[ambient_with_range][number.probe_1_timer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 720, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.probe_1_timer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Timer', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:timer-outline', + 'original_name': 'Timer', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'timer', + 'unique_id': '00000000-0000-0000-0000-000000000001_timer_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup_with_ambient[ambient_with_range][number.probe_1_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Probe 1 Timer', + : 'mdi:timer-outline', + : 720, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.probe_1_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_setup_with_ambient[ambient_with_range][number.probe_2_maximum_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1422,6 +1732,68 @@ 'state': 'unknown', }) # --- +# name: test_setup_with_ambient[ambient_with_range][number.probe_2_timer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 720, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.probe_2_timer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Timer', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:timer-outline', + 'original_name': 'Timer', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'timer', + 'unique_id': '00000000-0000-0000-0000-000000000001_timer_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup_with_ambient[ambient_with_range][number.probe_2_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Probe 2 Timer', + : 'mdi:timer-outline', + : 720, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.probe_2_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_setup_with_ambient[ambient_wrong_alarm_type][number.pro_05_alarm_interval-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1793,6 +2165,68 @@ 'state': 'unknown', }) # --- +# name: test_setup_with_ambient[ambient_wrong_alarm_type][number.probe_1_timer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 720, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.probe_1_timer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Timer', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:timer-outline', + 'original_name': 'Timer', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'timer', + 'unique_id': '00000000-0000-0000-0000-000000000001_timer_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup_with_ambient[ambient_wrong_alarm_type][number.probe_1_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Probe 1 Timer', + : 'mdi:timer-outline', + : 720, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.probe_1_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_setup_with_ambient[ambient_wrong_alarm_type][number.probe_2_maximum_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1979,6 +2413,68 @@ 'state': 'unknown', }) # --- +# name: test_setup_with_ambient[ambient_wrong_alarm_type][number.probe_2_timer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 720, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.probe_2_timer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Timer', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:timer-outline', + 'original_name': 'Timer', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'timer', + 'unique_id': '00000000-0000-0000-0000-000000000001_timer_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup_with_ambient[ambient_wrong_alarm_type][number.probe_2_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Probe 2 Timer', + : 'mdi:timer-outline', + : 720, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.probe_2_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_setup_with_ambient[no_data][number.pro_05_alarm_interval-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -2350,6 +2846,68 @@ 'state': 'unknown', }) # --- +# name: test_setup_with_ambient[no_data][number.probe_1_timer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 720, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.probe_1_timer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Timer', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:timer-outline', + 'original_name': 'Timer', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'timer', + 'unique_id': '00000000-0000-0000-0000-000000000001_timer_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup_with_ambient[no_data][number.probe_1_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Probe 1 Timer', + : 'mdi:timer-outline', + : 720, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.probe_1_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_setup_with_ambient[no_data][number.probe_2_maximum_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -2536,3 +3094,65 @@ 'state': 'unknown', }) # --- +# name: test_setup_with_ambient[no_data][number.probe_2_timer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 720, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.probe_2_timer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Timer', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:timer-outline', + 'original_name': 'Timer', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'timer', + 'unique_id': '00000000-0000-0000-0000-000000000001_timer_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup_with_ambient[no_data][number.probe_2_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Probe 2 Timer', + : 'mdi:timer-outline', + : 720, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.probe_2_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/togrill/test_number.py b/tests/components/togrill/test_number.py index f32720219bcf..1c6282a08092 100644 --- a/tests/components/togrill/test_number.py +++ b/tests/components/togrill/test_number.py @@ -1,5 +1,6 @@ """Test numbers for ToGrill integration.""" +from datetime import timedelta from unittest.mock import Mock from bleak.exc import BleakError @@ -9,6 +10,7 @@ from togrill_bluetooth.exceptions import BaseError from togrill_bluetooth.packets import ( PacketA0Notify, PacketA6Write, + PacketA7Write, PacketA8Notify, PacketA300Write, PacketA301Write, @@ -324,6 +326,32 @@ async def test_set_ambient_number( PacketA6Write(temperature_unit=None, alarm_interval=15), id="alarm_interval", ), + pytest.param( + [ + PacketA8Notify( + probe=1, + alarm_type=PacketA8Notify.AlarmType.TEMPERATURE_TARGET, + temperature_1=50.0, + ), + ], + "number.probe_1_timer", + 10.0, + PacketA7Write(probe=1, time=timedelta(minutes=10), unknown=1), + id="timer", + ), + pytest.param( + [ + PacketA8Notify( + probe=1, + alarm_type=PacketA8Notify.AlarmType.TEMPERATURE_TARGET, + temperature_1=50.0, + ), + ], + "number.probe_1_timer", + 0.0, + PacketA7Write(probe=1, time=timedelta(0), unknown=0), + id="timer_stop", + ), ], ) async def test_set_number( @@ -442,3 +470,28 @@ async def test_set_number_disconnected( }, blocking=True, ) + + +async def test_timer_readback( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_client: Mock, +) -> None: + """Test that a running timer is reported back in minutes.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, [Platform.NUMBER]) + + mock_client.mocked_notify( + PacketA8Notify( + probe=1, + alarm_type=None, + time=timedelta(minutes=30), + ) + ) + await hass.async_block_till_done() + + state = hass.states.get("number.probe_1_timer") + assert state is not None + assert float(state.state) == 30.0 diff --git a/tests/components/trane/test_init.py b/tests/components/trane/test_init.py index 91ab50731d98..7318c3fb6272 100644 --- a/tests/components/trane/test_init.py +++ b/tests/components/trane/test_init.py @@ -2,10 +2,15 @@ from unittest.mock import MagicMock +import pytest from steamloop import AuthenticationError, SteamloopConnectionError +from homeassistant.components.trane.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .conftest import MOCK_ENTRY_ID from tests.common import MockConfigEntry @@ -24,6 +29,23 @@ async def test_load_unload( assert entry.state is ConfigEntryState.NOT_LOADED +@pytest.mark.usefixtures("init_integration") +async def test_zone_device_via_device_id( + device_registry: dr.DeviceRegistry, +) -> None: + """Test the zone device links to the thermostat device via via_device_id.""" + thermostat_device = device_registry.async_get_device_by_identifier( + (DOMAIN, MOCK_ENTRY_ID), MOCK_ENTRY_ID + ) + assert thermostat_device is not None + + zone_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{MOCK_ENTRY_ID}_1"), MOCK_ENTRY_ID + ) + assert zone_device is not None + assert zone_device.via_device_id == thermostat_device.id + + async def test_setup_connection_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/uptime_kuma/snapshots/test_sensor.ambr b/tests/components/uptime_kuma/snapshots/test_sensor.ambr index bad07828cb9f..e1e38d069daf 100644 --- a/tests/components/uptime_kuma/snapshots/test_sensor.ambr +++ b/tests/components/uptime_kuma/snapshots/test_sensor.ambr @@ -69,6 +69,7 @@ 'dns', 'docker', 'system_service', + 'pm2', 'real_browser', 'group', 'push', @@ -78,6 +79,7 @@ 'json_query', 'kafka_producer', 'mqtt', + 'ntp', 'rabbit_mq', 'sip_options', 'smtp', @@ -139,6 +141,7 @@ 'dns', 'docker', 'system_service', + 'pm2', 'real_browser', 'group', 'push', @@ -148,6 +151,7 @@ 'json_query', 'kafka_producer', 'mqtt', + 'ntp', 'rabbit_mq', 'sip_options', 'smtp', @@ -770,6 +774,7 @@ 'dns', 'docker', 'system_service', + 'pm2', 'real_browser', 'group', 'push', @@ -779,6 +784,7 @@ 'json_query', 'kafka_producer', 'mqtt', + 'ntp', 'rabbit_mq', 'sip_options', 'smtp', @@ -840,6 +846,7 @@ 'dns', 'docker', 'system_service', + 'pm2', 'real_browser', 'group', 'push', @@ -849,6 +856,7 @@ 'json_query', 'kafka_producer', 'mqtt', + 'ntp', 'rabbit_mq', 'sip_options', 'smtp', @@ -1576,6 +1584,7 @@ 'dns', 'docker', 'system_service', + 'pm2', 'real_browser', 'group', 'push', @@ -1585,6 +1594,7 @@ 'json_query', 'kafka_producer', 'mqtt', + 'ntp', 'rabbit_mq', 'sip_options', 'smtp', @@ -1646,6 +1656,7 @@ 'dns', 'docker', 'system_service', + 'pm2', 'real_browser', 'group', 'push', @@ -1655,6 +1666,7 @@ 'json_query', 'kafka_producer', 'mqtt', + 'ntp', 'rabbit_mq', 'sip_options', 'smtp', diff --git a/tests/components/vizio/conftest.py b/tests/components/vizio/conftest.py index d783ed922e5f..56fd48c43f0c 100644 --- a/tests/components/vizio/conftest.py +++ b/tests/components/vizio/conftest.py @@ -238,6 +238,16 @@ def vizio_guess_device_type_fixture() -> Generator[None]: yield +@pytest.fixture(name="vizio_detect_tv") +def vizio_detect_tv_fixture() -> Generator[None]: + """Mock vizio device type probe to report a TV.""" + with patch( + "homeassistant.components.vizio.config_flow.async_is_tv", + return_value=True, + ): + yield + + @pytest.fixture(name="vizio_cant_connect") def vizio_cant_connect_fixture() -> Generator[None]: """Mock vizio device can't connect with valid auth.""" diff --git a/tests/components/vizio/test_config_flow.py b/tests/components/vizio/test_config_flow.py index 2201aeb5d6e9..6c147513bd33 100644 --- a/tests/components/vizio/test_config_flow.py +++ b/tests/components/vizio/test_config_flow.py @@ -45,7 +45,9 @@ from .const import ( from tests.common import MockConfigEntry -@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_setup") +@pytest.mark.usefixtures( + "vizio_connect", "vizio_bypass_setup", "vizio_guess_device_type" +) async def test_user_flow_minimum_fields(hass: HomeAssistant) -> None: """Test user config flow with minimum fields.""" # test form shows @@ -66,7 +68,7 @@ async def test_user_flow_minimum_fields(hass: HomeAssistant) -> None: assert result["data"][CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.SPEAKER -@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_setup") +@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_setup", "vizio_detect_tv") async def test_user_flow_all_fields(hass: HomeAssistant) -> None: """Test user config flow with all fields.""" # test form shows diff --git a/tests/components/watts/snapshots/test_diagnostics.ambr b/tests/components/watts/snapshots/test_diagnostics.ambr index 072c4181dc59..ffc330f17add 100644 --- a/tests/components/watts/snapshots/test_diagnostics.ambr +++ b/tests/components/watts/snapshots/test_diagnostics.ambr @@ -102,7 +102,7 @@ 'version': 1, }), 'hub_coordinator': dict({ - 'last_discovery': '2026-01-01T12:00:00', + 'last_discovery': '2026-01-01T12:00:00+00:00', 'last_exception': None, 'last_update_success': True, 'supported_devices': 3, diff --git a/tests/components/watts/test_init.py b/tests/components/watts/test_init.py index acca220ce125..2044e5cedd23 100644 --- a/tests/components/watts/test_init.py +++ b/tests/components/watts/test_init.py @@ -21,7 +21,7 @@ from homeassistant.components.climate import ( SERVICE_SET_TEMPERATURE, ) from homeassistant.components.watts.const import ( - DISCOVERY_INTERVAL_MINUTES, + DISCOVERY_INTERVAL_SECONDS, DOMAIN, FAST_POLLING_INTERVAL_SECONDS, OAUTH2_TOKEN, @@ -230,7 +230,7 @@ async def test_dynamic_device_creation( current_devices = list(mock_watts_client.discover_devices.return_value) mock_watts_client.discover_devices.return_value = [*current_devices, new_device] - freezer.tick(timedelta(minutes=DISCOVERY_INTERVAL_MINUTES)) + freezer.tick(timedelta(seconds=DISCOVERY_INTERVAL_SECONDS)) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -270,7 +270,7 @@ async def test_stale_device_removal( d for d in current_devices if d.device_id != "thermostat_456" ] - freezer.tick(timedelta(minutes=DISCOVERY_INTERVAL_MINUTES)) + freezer.tick(timedelta(seconds=DISCOVERY_INTERVAL_SECONDS)) async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index 68602b7f2a5a..9217b7e6f00b 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -62,6 +62,7 @@ from homeassistant.const import ( SERVICE_VOLUME_UP, STATE_OFF, STATE_UNAVAILABLE, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError @@ -911,23 +912,41 @@ async def test_reauth_reconnect( async def test_update_media_state(hass: HomeAssistant, client) -> None: """Test updating media state.""" + client.tv_state.media_state = [] await setup_webostv(hass) + # on but no media state, assumed state is set + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == MediaPlayerState.ON + assert state.attributes.get(EntityStateAttribute.ASSUMED_STATE) + + # playing state, assumed state is not set client.tv_state.media_state = [{"playState": "playing"}] await client.mock_state_update() - assert hass.states.get(ENTITY_ID).state == MediaPlayerState.PLAYING + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == MediaPlayerState.PLAYING + assert not state.attributes.get(EntityStateAttribute.ASSUMED_STATE) + # paused state, assumed state is not set client.tv_state.media_state = [{"playState": "paused"}] await client.mock_state_update() - assert hass.states.get(ENTITY_ID).state == MediaPlayerState.PAUSED + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == MediaPlayerState.PAUSED + assert not state.attributes.get(EntityStateAttribute.ASSUMED_STATE) + # unloaded state, assumed state is not set client.tv_state.media_state = [{"playState": "unloaded"}] await client.mock_state_update() - assert hass.states.get(ENTITY_ID).state == MediaPlayerState.IDLE + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == MediaPlayerState.IDLE + assert not state.attributes.get(EntityStateAttribute.ASSUMED_STATE) + # off state, assumed state is not set client.tv_state.is_on = False await client.mock_state_update() - assert hass.states.get(ENTITY_ID).state == STATE_OFF + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == MediaPlayerState.OFF + assert not state.attributes.get(EntityStateAttribute.ASSUMED_STATE) async def test_availability( diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index f87f7634c515..5aeb3f78bcd1 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -1247,6 +1247,17 @@ async def test_ping(websocket_client: MockHAClientWebSocket) -> None: assert msg["type"] == "pong" +async def test_slugify(websocket_client: MockHAClientWebSocket) -> None: + """Test slugify command.""" + await websocket_client.send_json_auto_id( + {"type": "slugify", "text": "Living room Thermostat Temperature"} + ) + + msg = await websocket_client.receive_json() + assert msg["success"] is True + assert msg["result"] == {"slug": "living_room_thermostat_temperature"} + + async def test_call_service_context_with_user( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, diff --git a/tests/components/wiim/test_entity.py b/tests/components/wiim/test_entity.py new file mode 100644 index 000000000000..f7bf17d085dc --- /dev/null +++ b/tests/components/wiim/test_entity.py @@ -0,0 +1,32 @@ +"""Tests for WiiM entities.""" + +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.wiim.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_wiim_controller") +async def test_device_info_uses_http_api_url( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_wiim_device: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the HTTP API URL is used when no presentation URL is available.""" + mock_wiim_device.presentation_url = None + + await setup_integration(hass, mock_config_entry) + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_wiim_device.udn)} + ) + assert device_entry is not None + assert device_entry.configuration_url == mock_wiim_device.http_api_url diff --git a/tests/components/wiim/test_init.py b/tests/components/wiim/test_init.py index 27e07bf9198e..3f8828557539 100644 --- a/tests/components/wiim/test_init.py +++ b/tests/components/wiim/test_init.py @@ -6,6 +6,7 @@ import pytest from wiim.exceptions import WiimDeviceException, WiimRequestException from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.core_config import async_process_ha_core_config @@ -31,6 +32,39 @@ async def test_load_unload_entry( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED +@pytest.mark.usefixtures("mock_wiim_controller") +async def test_shutdown_disconnects_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_wiim_device: AsyncMock, +) -> None: + """Test the device is disconnected when Home Assistant stops.""" + await setup_integration(hass, mock_config_entry) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + mock_wiim_device.disconnect.assert_awaited_once_with() + + +@pytest.mark.usefixtures("mock_wiim_controller") +async def test_unload_entry_fails_when_platform_cannot_unload( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the entry reports a failed unload when its platform cannot unload.""" + await setup_integration(hass, mock_config_entry) + + with patch.object( + hass.config_entries, + "async_unload_platforms", + return_value=False, + ): + assert not await hass.config_entries.async_unload(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.FAILED_UNLOAD + + @pytest.mark.parametrize( ("exc", "translation_key"), [ @@ -82,6 +116,26 @@ async def test_setup_raises_config_entry_not_ready_when_no_url( assert mock_config_entry.error_reason_translation_placeholders is None +@pytest.mark.usefixtures("mock_wiim_controller") +async def test_setup_retries_when_url_has_no_hostname( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a Home Assistant URL without a hostname causes setup to retry.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.wiim.util.get_url", + return_value="not-a-url", + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_config_entry.error_reason_translation_key == "missing_homeassistant_url" + assert mock_config_entry.error_reason_translation_placeholders is None + + async def test_setup_no_url_after_core_config( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/wiim/test_media_player.py b/tests/components/wiim/test_media_player.py index aada56366ef7..183d78c7e8ff 100644 --- a/tests/components/wiim/test_media_player.py +++ b/tests/components/wiim/test_media_player.py @@ -34,9 +34,12 @@ from homeassistant.components.media_player import ( DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_BROWSE_MEDIA, SERVICE_JOIN, + SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_SEEK, + SERVICE_MEDIA_STOP, SERVICE_PLAY_MEDIA, SERVICE_REPEAT_SET, SERVICE_SELECT_SOURCE, @@ -54,7 +57,7 @@ from homeassistant.components.media_player import ( ) import homeassistant.components.wiim as wiim_component from homeassistant.components.wiim.const import DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST +from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -190,6 +193,39 @@ async def test_state_machine_updates_from_device_callbacks( ) +async def test_general_update_handles_offline_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_wiim_device: MagicMock, + mock_wiim_controller: MagicMock, +) -> None: + """Test an offline update marks the media player unavailable.""" + await setup_integration(hass, mock_config_entry) + mock_wiim_device.available = False + + await fire_general_update(hass, mock_wiim_device) + + state = hass.states.get(MEDIA_PLAYER_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE + mock_wiim_controller.async_update_all_multiroom_status.assert_awaited_once_with() + + +@pytest.mark.usefixtures("mock_wiim_controller") +async def test_general_update_renews_http_subscriptions( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_wiim_device: MagicMock, +) -> None: + """Test an HTTP-capable device renews subscriptions on a general update.""" + await setup_integration(hass, mock_config_entry) + mock_wiim_device.supports_http_api = True + + await fire_general_update(hass, mock_wiim_device) + + mock_wiim_device.ensure_subscriptions.assert_awaited_once_with() + + async def test_state_machine_updates_from_transport_events( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -219,6 +255,52 @@ async def test_state_machine_updates_from_transport_events( assert state.state == MediaPlayerState.IDLE assert state.attributes.get(ATTR_MEDIA_TITLE) is None + mock_wiim_device.event_data = {"TransportState": "unknown"} + mock_wiim_device.av_transport_event_callback(MagicMock(), []) + await hass.async_block_till_done() + + state = hass.states.get(MEDIA_PLAYER_ENTITY_ID) + assert state is not None + assert state.state == MediaPlayerState.IDLE + + +@pytest.mark.parametrize( + ("service", "device_method"), + [ + pytest.param(SERVICE_MEDIA_STOP, "async_stop", id="stop"), + pytest.param(SERVICE_MEDIA_NEXT_TRACK, "async_next", id="next"), + pytest.param(SERVICE_MEDIA_PREVIOUS_TRACK, "async_previous", id="previous"), + ], +) +@pytest.mark.usefixtures("mock_wiim_controller") +async def test_transport_services_call_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_wiim_device: MagicMock, + *, + service: str, + device_method: str, +) -> None: + """Test transport services call the matching device command.""" + mock_wiim_device.async_get_transport_capabilities.return_value = ( + WiimTransportCapabilities( + can_next=True, + can_previous=True, + can_repeat=False, + can_shuffle=False, + ) + ) + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + service, + {ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID}, + blocking=True, + ) + + getattr(mock_wiim_device, device_method).assert_awaited_once_with() + @pytest.mark.parametrize( ( @@ -265,6 +347,7 @@ async def test_control_services_update_state_machine( mock_config_entry: MockConfigEntry, mock_wiim_device: MagicMock, mock_wiim_controller: MagicMock, + *, service: str, service_data: dict[str, object], device_method: str, @@ -697,6 +780,19 @@ async def test_play_media_services_call_device_commands( assert state.state == MediaPlayerState.PLAYING assert state.attributes[ATTR_MEDIA_TITLE] == "Preset 1" + mock_wiim_device.play_preset.reset_mock() + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: "wiim_library", + ATTR_MEDIA_CONTENT_ID: "2", + }, + blocking=True, + ) + mock_wiim_device.play_preset.assert_awaited_once_with(2) + await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, @@ -744,6 +840,7 @@ async def test_play_media_validation_error_uses_translation( mock_config_entry: MockConfigEntry, mock_wiim_device: MagicMock, mock_wiim_controller: MagicMock, + *, media_type: MediaType | str, media_id: str, translation_key: str, @@ -975,6 +1072,7 @@ async def test_browse_media_error_uses_translation( mock_config_entry: MockConfigEntry, mock_wiim_device: MagicMock, mock_wiim_controller: MagicMock, + *, media_content_type: MediaType, media_content_id: str, translation_key: str, diff --git a/tests/components/xiaomi_miio/test_entity.py b/tests/components/xiaomi_miio/test_entity.py new file mode 100644 index 000000000000..086d50af1809 --- /dev/null +++ b/tests/components/xiaomi_miio/test_entity.py @@ -0,0 +1,44 @@ +"""The tests for the xiaomi_miio entity base classes.""" + +from unittest.mock import Mock + +from homeassistant.components.xiaomi_miio.const import DOMAIN +from homeassistant.components.xiaomi_miio.coordinator import GatewayDeviceCoordinator +from homeassistant.components.xiaomi_miio.entity import XiaomiGatewayDevice +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import TEST_MAC + +from tests.common import MockConfigEntry + + +async def test_gateway_sub_device_via_device_id( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test a gateway sub device links to the gateway device via via_device_id.""" + config_entry = MockConfigEntry(domain=DOMAIN, unique_id=TEST_MAC) + config_entry.add_to_hass(hass) + + gateway_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, TEST_MAC)}, + manufacturer="Xiaomi", + name="Test Gateway", + ) + + sub_device = Mock( + sid="158d0001d7c95a", + model="lumi.sensor_ht", + firmware_version="1.2", + zigbee_model="lumi.sensor_ht.v1", + ) + sub_device.name = "Sub Device" + + coordinator = GatewayDeviceCoordinator(hass, config_entry, sub_device) + entity = XiaomiGatewayDevice(coordinator) + entity.hass = hass + + device_info = entity.device_info + + assert device_info["via_device_id"] == gateway_device.id diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 5aad66c39740..d5a3b4996575 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -467,12 +467,12 @@ async def test_new_entity_on_value_added( assert hass.states.get("sensor.multisensor_6_ultraviolet_10") is not None -@pytest.mark.usefixtures("integration") async def test_on_node_added_ready( hass: HomeAssistant, device_registry: dr.DeviceRegistry, multisensor_6_state: NodeDataType, client: MagicMock, + integration: MockConfigEntry, ) -> None: """Test we handle a node added event with a ready node.""" node = Node(client, deepcopy(multisensor_6_state)) @@ -493,9 +493,21 @@ async def test_on_node_added_ready( assert state # entity and device added assert state.state != STATE_UNAVAILABLE - assert device_registry.async_get_device( - identifiers={(DOMAIN, air_temperature_device_id)} + device = device_registry.async_get_device_by_identifier( + (DOMAIN, air_temperature_device_id), integration.entry_id ) + assert device + + controller_node = client.driver.controller.own_node + assert controller_node + controller_device_id = ( + f"{client.driver.controller.home_id}-{controller_node.node_id}" + ) + controller_device = device_registry.async_get_device_by_identifier( + (DOMAIN, controller_device_id), integration.entry_id + ) + assert controller_device + assert device.via_device_id == controller_device.id async def test_check_pre_provisioned_device_update_device(