From f183e224d31dc3c5039e074ed4cb936375534e3f Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Wed, 5 Aug 2026 09:44:41 +0300 Subject: [PATCH 01/15] Update LG webOS TV common-modules to done (#178205) --- homeassistant/components/webostv/quality_scale.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/webostv/quality_scale.yaml b/homeassistant/components/webostv/quality_scale.yaml index 18f46e4adbffa5..028ec68e96ed98 100644 --- a/homeassistant/components/webostv/quality_scale.yaml +++ b/homeassistant/components/webostv/quality_scale.yaml @@ -3,9 +3,7 @@ rules: action-setup: done appropriate-polling: done brands: done - common-modules: - status: exempt - comment: The integration does not use common patterns. + common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done From 7eeb4ec3ca58d68ec488d0885254e28ff23955ad Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 5 Aug 2026 08:46:33 +0200 Subject: [PATCH 02/15] Remove unneeded test in Portainer (#178185) --- tests/components/portainer/test_services.py | 31 ++------------------- 1 file changed, 2 insertions(+), 29 deletions(-) diff --git a/tests/components/portainer/test_services.py b/tests/components/portainer/test_services.py index db72f383cc2ed4..deeff7cdc98d33 100644 --- a/tests/components/portainer/test_services.py +++ b/tests/components/portainer/test_services.py @@ -23,14 +23,14 @@ _async_get_device, ) from homeassistant.const import ATTR_DEVICE_ID -from homeassistant.core import Context, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.device_registry import DeviceRegistry from . import setup_integration from .conftest import TEST_CONTAINER_ID, TEST_CONTAINER_NAME, TEST_ENTRY -from tests.common import MockConfigEntry, MockUser +from tests.common import MockConfigEntry TEST_ENDPOINT_ID = 1 TEST_DEVICE_IDENTIFIER = f"{TEST_ENTRY}_{TEST_ENDPOINT_ID}" @@ -157,33 +157,6 @@ async def test_service_recreate_container( ) -async def test_service_recreate_container_non_admin_rejected( - hass: HomeAssistant, - device_registry: DeviceRegistry, - mock_portainer_client: AsyncMock, - mock_config_entry: MockConfigEntry, - hass_read_only_user: MockUser, -) -> None: - """Test recreate container service requires admin access.""" - - await setup_integration(hass, mock_config_entry) - container = device_registry.async_get_device( - identifiers={(DOMAIN, TEST_CONTAINER_DEVICE_IDENTIFIER)} - ) - assert container is not None - - await hass.services.async_call( - DOMAIN, - SERVICE_RECREATE_CONTAINER, - {ATTR_CONTAINER_DEVICE_ID: container.id, ATTR_PULL_IMAGE: True}, - blocking=False, - context=Context(user_id=hass_read_only_user.id), - ) - await hass.async_block_till_done() - - mock_portainer_client.container_recreate.assert_not_called() - - @pytest.mark.parametrize( ("exception", "translation_key"), [ From 67fd9c8baf39da096b8390d0a80c8615acb69db9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Aug 2026 10:02:53 +0200 Subject: [PATCH 03/15] Fix via_device race in duco (#178170) --- homeassistant/components/duco/__init__.py | 17 ++++++++++-- homeassistant/components/duco/entity.py | 12 +++++++-- tests/components/duco/test_init.py | 33 ++++++++++++++++++++++- 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/duco/__init__.py b/homeassistant/components/duco/__init__.py index 09c79893ad5f3d..55e8b8ccd62054 100644 --- a/homeassistant/components/duco/__init__.py +++ b/homeassistant/components/duco/__init__.py @@ -1,15 +1,16 @@ """The Duco integration.""" import re +from typing import TYPE_CHECKING from duco_connectivity import DucoClient from homeassistant.const import CONF_HOST 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_get_clientsession -from .const import PLATFORMS +from .const import BOX_NODE_ID, DOMAIN, PLATFORMS from .coordinator import DucoConfigEntry, DucoCoordinator _REMOVED_SENSOR_RE = re.compile(r"_\d+_(box_)?temperature$") @@ -37,6 +38,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool entry.runtime_data = coordinator + # Pre-register the box before forwarding platforms so sub-node entities can + # resolve it as their via_device parent regardless of entity setup order. + mac = entry.unique_id + if TYPE_CHECKING: + assert mac is not None + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, f"{mac}_{BOX_NODE_ID}")}, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/duco/entity.py b/homeassistant/components/duco/entity.py index b14332791f8885..b66627724d3510 100644 --- a/homeassistant/components/duco/entity.py +++ b/homeassistant/components/duco/entity.py @@ -4,7 +4,7 @@ from duco_connectivity.models import Node, NodeType -from homeassistant.const import ATTR_VIA_DEVICE +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 @@ -38,7 +38,15 @@ def __init__(self, coordinator: DucoCoordinator, node: Node) -> None: "serial_number": coordinator.board_info.serial_board_box, } if node.general.node_type == NodeType.BOX - else {ATTR_VIA_DEVICE: (DOMAIN, f"{mac}_1")} + # The box is pre-registered in async_setup_entry, so it is always + # resolvable here even if a sub-node entity is added first. + else { + "via_device_id": dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, f"{mac}_1"), + config_entry_id=coordinator.config_entry.entry_id, + ) + } ) self._attr_device_info = device_info diff --git a/tests/components/duco/test_init.py b/tests/components/duco/test_init.py index 3cf9aee5b3c928..fdabfd21de2411 100644 --- a/tests/components/duco/test_init.py +++ b/tests/components/duco/test_init.py @@ -20,12 +20,13 @@ from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.duco.const import SCAN_INTERVAL +from homeassistant.components.duco.const import BOX_NODE_ID, DOMAIN, SCAN_INTERVAL from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from . import setup_platform_integration from .conftest import ( TEST_HOST, TEST_MAC, @@ -137,6 +138,36 @@ async def test_setup_entry_success( assert init_integration.state is ConfigEntryState.LOADED +async def test_device_via_device_links( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, + mock_nodes: list[Node], + device_registry: dr.DeviceRegistry, +) -> None: + """Test a sub-node added before the box still links to it via via_device_id.""" + # Force the child-before-parent race: return a sub-node ahead of the box node + # and set up only the sensor platform (the fan platform would otherwise create + # the box first). This only resolves because the box is pre-registered in + # async_setup_entry before the platforms build their entities. + box_node = next(node for node in mock_nodes if node.node_id == BOX_NODE_ID) + child_node = next(node for node in mock_nodes if node.node_id != BOX_NODE_ID) + mock_duco_client.async_get_nodes.return_value = [child_node, box_node] + + await setup_platform_integration(hass, mock_config_entry, [Platform.SENSOR]) + + box_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{TEST_MAC}_{BOX_NODE_ID}"), mock_config_entry.entry_id + ) + assert box_device is not None + + child_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{TEST_MAC}_{child_node.node_id}"), mock_config_entry.entry_id + ) + assert child_device is not None + assert child_device.via_device_id == box_device.id + + @pytest.mark.parametrize( "exception", [ From 7c508bea51450afe490b05fd7a1e1a825fb08931 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Aug 2026 10:03:17 +0200 Subject: [PATCH 04/15] Handle intended via_device missing in tellduslive (#178178) --- .../components/tellduslive/entity.py | 11 ++++--- tests/components/tellduslive/test_init.py | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tellduslive/entity.py b/homeassistant/components/tellduslive/entity.py index fb108e8d16a50a..d319edee6ef7af 100644 --- a/homeassistant/components/tellduslive/entity.py +++ b/homeassistant/components/tellduslive/entity.py @@ -121,10 +121,11 @@ def device_info(self) -> DeviceInfo: config_entry = self.platform.config_entry if TYPE_CHECKING: assert config_entry - # The hub is registered in async_new_client before entities are added. - device_info["via_device_id"] = dr.async_get_device_id_by_identifier( - self.hass, - (DOMAIN, client), - config_entry_id=config_entry.entry_id, + # The hub is not registered when fetching the client list failed while + # device requests succeeded, so link only when the hub device exists. + hub_device = dr.async_get(self.hass).async_get_device_by_identifier( + (DOMAIN, client), config_entry.entry_id ) + if hub_device is not None: + device_info["via_device_id"] = hub_device.id return device_info diff --git a/tests/components/tellduslive/test_init.py b/tests/components/tellduslive/test_init.py index c504147c25e040..ef24e4028a9ff7 100644 --- a/tests/components/tellduslive/test_init.py +++ b/tests/components/tellduslive/test_init.py @@ -40,3 +40,35 @@ async def test_device_via_device_links( assert child_device.via_device_id == hub_device.id assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + + +async def test_device_added_without_hub( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_tellduslive: MagicMock, +) -> None: + """Test a device is still added, unlinked, when its hub is not registered.""" + # A failed clients/list request yields an empty hub list, so no hub is registered. + mock_tellduslive.get_clients.return_value = [] + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.data[NEW_CLIENT_TASK] + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert ( + device_registry.async_get_device_by_identifier( + (DOMAIN, HUB_ID), mock_config_entry.entry_id + ) + is None + ) + + child_device = device_registry.async_get_device_by_identifier( + (DOMAIN, DEVICE_ID), mock_config_entry.entry_id + ) + assert child_device is not None + assert child_device.via_device_id is None + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) From d5750e374c137ecacd3ecc5c958fd28b09b89bdf Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Aug 2026 10:03:38 +0200 Subject: [PATCH 05/15] Improve via_device handling in smartthings (#178179) --- .../components/smartthings/__init__.py | 19 +++++--- tests/components/smartthings/test_init.py | 45 +++++++++++++++++++ 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 76f6f060ba283b..8923d3ed9043e1 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -498,12 +498,8 @@ def create_devices( rooms: dict[str, str], ) -> None: """Create devices in the device registry.""" - # Devices are sorted so a parent is always created before its children, - # allowing children to reference the parent's registered device id. created_devices: dict[str, dr.DeviceEntry] = {} - for device in sorted( - devices.values(), key=lambda d: d.device.parent_device_id or "" - ): + for device in devices.values(): kwargs: dict[str, Any] = {} if device.device.hub is not None: kwargs = { @@ -522,8 +518,6 @@ def create_devices( format_zigbee_address(device.device.hub.hub_eui), ) ) - if device.device.parent_device_id and device.device.parent_device_id in devices: - kwargs["via_device_id"] = created_devices[device.device.parent_device_id].id if (ocf := device.device.ocf) is not None: kwargs.update( { @@ -609,6 +603,17 @@ def create_devices( **kwargs, ) + # Link child devices to their parent in a second pass, so registration is + # robust to any ordering of parents and children in the device list, + # including nested hierarchies where a parent is itself a child device. + for device in devices.values(): + parent_device_id = device.device.parent_device_id + if parent_device_id and parent_device_id in devices: + device_registry.async_update_device( + created_devices[device.device.device_id].id, + via_device_id=created_devices[parent_device_id].id, + ) + KEEP_CAPABILITY_QUIRK: dict[ Capability | str, Callable[[dict[Attribute | str, Status]], bool] diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index f0cad77fcabbbd..2d25411d879268 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -1,5 +1,6 @@ """Tests for the SmartThings component init module.""" +from dataclasses import replace from unittest.mock import AsyncMock, MagicMock, patch from pysmartthings import ( @@ -414,6 +415,50 @@ async def test_hub_via_device( assert child_device.via_device_id == hub_device.id +async def test_via_device_nested_hierarchy( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + mock_smartthings: AsyncMock, +) -> None: + """Test a nested device hierarchy is linked regardless of device order.""" + grandparent_id = "11111111-1111-1111-1111-111111111111" + parent_id = "00000000-0000-0000-0000-000000000000" + child_id = "22222222-2222-2222-2222-222222222222" + + base_device = DeviceResponse.from_json( + await async_load_fixture(hass, "devices/virtual_valve.json", DOMAIN) + ).items[0] + # Children precede their parents to exercise ordering-independent linking. + mock_smartthings.get_devices.return_value = [ + replace(base_device, device_id=child_id, parent_device_id=parent_id), + replace(base_device, device_id=parent_id, parent_device_id=grandparent_id), + replace(base_device, device_id=grandparent_id, parent_device_id=None), + ] + mock_smartthings.get_device_status.return_value = DeviceStatus.from_json( + await async_load_fixture(hass, "device_status/virtual_valve.json", DOMAIN) + ).components + + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + grandparent_device = device_registry.async_get_device_by_identifier( + (DOMAIN, grandparent_id), mock_config_entry.entry_id + ) + parent_device = device_registry.async_get_device_by_identifier( + (DOMAIN, parent_id), mock_config_entry.entry_id + ) + child_device = device_registry.async_get_device_by_identifier( + (DOMAIN, child_id), mock_config_entry.entry_id + ) + assert grandparent_device is not None + assert parent_device is not None + assert child_device is not None + assert grandparent_device.via_device_id is None + assert parent_device.via_device_id == grandparent_device.id + assert child_device.via_device_id == parent_device.id + + @pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) async def test_deleted_device_runtime( hass: HomeAssistant, From 444d4fed9a60fc618a18e3e5e387c3e8a9680c0e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Aug 2026 10:04:05 +0200 Subject: [PATCH 06/15] Fix via_device in hive linking to itself (#178190) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/hive/__init__.py | 7 +++- homeassistant/components/hive/entity.py | 14 ++++--- tests/components/hive/test_init.py | 48 +++++++++++++++++++++++ 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index 2ffce1f45d0bd1..7bc513a101677f 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -50,7 +50,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HiveConfigEntry) -> bool connections.add((dr.CONNECTION_NETWORK_MAC, mac)) device_registry = dr.async_get(hass) - device_registry.async_get_or_create( + hub_device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, hub_data["device_id"])}, connections=connections, @@ -59,6 +59,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: HiveConfigEntry) -> bool sw_version=hub_data["deviceData"]["version"], manufacturer=hub_data["deviceData"]["manufacturer"], ) + if hub_device.via_device_id is not None: + # Older versions linked the hub's own diagnostic sensor to the hub itself; + # clear the stale self-reference since async_get_or_create leaves + # via_device_id untouched when it's not passed. + device_registry.async_update_device(hub_device.id, via_device_id=None) await hass.config_entries.async_forward_entry_setups( entry, diff --git a/homeassistant/components/hive/entity.py b/homeassistant/components/hive/entity.py index 1b0c85cc43320d..381fae43f80efd 100644 --- a/homeassistant/components/hive/entity.py +++ b/homeassistant/components/hive/entity.py @@ -31,18 +31,22 @@ def __init__( self.device = hive_device self._attr_name = self.device["haName"] self._attr_unique_id = f"{self.device['hiveID']}-{self.device['hiveType']}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.device["device_id"])}, + device_id = self.device["device_id"] + device_info = DeviceInfo( + identifiers={(DOMAIN, device_id)}, model=self.device["deviceData"]["model"], manufacturer=self.device["deviceData"]["manufacturer"], name=self.device["device_name"], sw_version=self.device["deviceData"]["version"], - via_device_id=dr.async_get_device_id_by_identifier( + ) + # Hive reports the hub itself as its parent. + if self.device["parentDevice"] != device_id: + device_info["via_device_id"] = dr.async_get_device_id_by_identifier( hass, (DOMAIN, self.device["parentDevice"]), config_entry_id=entry.entry_id, - ), - ) + ) + self._attr_device_info = device_info self.attributes: dict[str, Any] = {} @override diff --git a/tests/components/hive/test_init.py b/tests/components/hive/test_init.py index 2e505af81b3fa1..6467c661858f8c 100644 --- a/tests/components/hive/test_init.py +++ b/tests/components/hive/test_init.py @@ -50,6 +50,25 @@ "status": {"state": True}, } +# The hub's own diagnostic sensor reports the hub as its own parent +# (parentDevice == device_id), which would link the hub device to itself. +_HUB_BINARY_SENSOR = { + "device_id": "hive-hub-id", + "hiveID": "hive-hub-id", + "hiveName": "Hive Hub Status", + "haName": "Hive Hub Status", + "device_name": "Hive Hub", + "hiveType": "Connectivity", + "parentDevice": "hive-hub-id", + "deviceData": { + "model": "Hub", + "version": "1.2.3", + "manufacturer": "Hive", + "online": True, + }, + "status": {"state": True}, +} + def _make_mock_hive( hub_extra: dict, extra_devices: dict[str, list[dict[str, Any]]] | None = None @@ -143,3 +162,32 @@ async def test_child_device_links_to_hub_via_device_id( assert hub_device is not None assert child_device is not None assert child_device.via_device_id == hub_device.id + + +async def test_hub_diagnostic_sensor_not_linked_to_itself( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """The hub's own diagnostic sensor must not link the hub device to itself.""" + entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA) + entry.add_to_hass(hass) + + mock_hive = _make_mock_hive( + {"macAddress": "00:1C:2B:1C:2E:68"}, + {"binary_sensor": [_HUB_BINARY_SENSOR], "sensor": []}, + ) + mock_hive.session.updateData = AsyncMock() + mock_hive.sensor.getSensor = AsyncMock(side_effect=lambda device: device) + + with patch( + "homeassistant.components.hive.Hive", + return_value=mock_hive, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + hub_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "hive-hub-id"), entry.entry_id + ) + assert hub_device is not None + assert hub_device.via_device_id is None From 9256528a6b13b4279a540ccac422f1170a0f8aaf Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Aug 2026 10:04:19 +0200 Subject: [PATCH 07/15] Fix via_device in squeezebox linking to itself (#178191) --- homeassistant/components/squeezebox/media_player.py | 7 ++++++- tests/components/squeezebox/snapshots/test_init.ambr | 2 +- tests/components/squeezebox/test_init.py | 2 ++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index bf28dc055520e6..5ad10f7a1ee9f8 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -134,6 +134,7 @@ async def _player_discovered( manufacturer = player.creator model_id = player.model_type sw_version = "" + via_device_id = server_device.id if server_device else None # So we nicely merge with a server and a player # linked by a MAC server is not all info lost if ( @@ -151,6 +152,10 @@ async def _player_discovered( else SERVER_MANUFACTURER ) model_id = SERVER_MODEL_ID + "/" + model_id if model_id else SERVER_MODEL_ID + # The player shares the server's device (same MAC), so it resolves to + # the server device itself; don't link it to itself. None also clears + # the link for devices from before this was fixed. + via_device_id = None device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, @@ -162,7 +167,7 @@ async def _player_discovered( model_id=model_id, hw_version=str(player.firmware) if player.firmware is not None else None, sw_version=sw_version, - via_device_id=server_device.id if server_device else None, + via_device_id=via_device_id, ) _LOGGER.debug("Creating / Updating player device %s", device) async_add_entities([SqueezeBoxMediaPlayerEntity(coordinator)]) diff --git a/tests/components/squeezebox/snapshots/test_init.ambr b/tests/components/squeezebox/snapshots/test_init.ambr index c4e33fea3244ce..c44c46ff7de3e1 100644 --- a/tests/components/squeezebox/snapshots/test_init.ambr +++ b/tests/components/squeezebox/snapshots/test_init.ambr @@ -68,6 +68,6 @@ 'name_by_user': None, 'serial_number': None, 'sw_version': '', - 'via_device_id': , + 'via_device_id': None, }) # --- diff --git a/tests/components/squeezebox/test_init.py b/tests/components/squeezebox/test_init.py index d43e92582b1d70..abe197bab17c11 100644 --- a/tests/components/squeezebox/test_init.py +++ b/tests/components/squeezebox/test_init.py @@ -151,6 +151,8 @@ async def test_device_registry_server_merged( """Test squeezebox device registered in the device registry.""" reg_device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_MAC[2])}) assert reg_device is not None + # The player shares the server's device, so it must not be linked to itself. + assert reg_device.via_device_id is None assert reg_device == snapshot From 70a899698c2f427e7b14c00a23342151214bd6be Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 5 Aug 2026 10:04:40 +0200 Subject: [PATCH 08/15] Allow Alexa Devices setup to continue when additional feature APIs fail (#177402) --- .../components/alexa_devices/__init__.py | 28 +++++- .../components/alexa_devices/coordinator.py | 4 +- .../alexa_devices/test_coordinator.py | 14 +-- tests/components/alexa_devices/test_init.py | 87 +++++++++++++++++++ tests/components/alexa_devices/test_todo.py | 6 +- 5 files changed, 125 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/alexa_devices/__init__.py b/homeassistant/components/alexa_devices/__init__.py index 6db44b51c02638..f5ba46e534ac09 100644 --- a/homeassistant/components/alexa_devices/__init__.py +++ b/homeassistant/components/alexa_devices/__init__.py @@ -1,7 +1,10 @@ """Alexa Devices integration.""" +from collections.abc import Awaitable, Callable + from homeassistant.const import CONF_COUNTRY, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_validation as cv, httpx_client from homeassistant.helpers.typing import ConfigType from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2 @@ -31,6 +34,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +async def _async_initial_sync(sync_call: Callable[[], Awaitable[None]]) -> None: + """Run an initial best-effort sync call. + + These syncs are not required for setup to succeed: a failing Amazon API + call must not prevent the other syncs from running or abort setup. + """ + try: + await sync_call() + except ConfigEntryNotReady as err: + LOGGER.warning( + "Initial sync failed for %s: %s. Data may be missing or incomplete until updates are pushed by Amazon", + sync_call.__name__, + err, + ) + + async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool: """Set up Alexa Devices platform.""" @@ -39,9 +58,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo await coordinator.async_config_entry_first_refresh() - await coordinator.sync_todo_list_items() - await coordinator.sync_history_state() - await coordinator.sync_media_state() + for sync_call in ( + coordinator.sync_todo_list_items, + coordinator.sync_history_state, + coordinator.sync_media_state, + ): + await _async_initial_sync(sync_call) async def _on_http2_reauth_required() -> None: entry.async_start_reauth(hass) diff --git a/homeassistant/components/alexa_devices/coordinator.py b/homeassistant/components/alexa_devices/coordinator.py index 4ff28af4d057a7..e7fe1cddb216a8 100644 --- a/homeassistant/components/alexa_devices/coordinator.py +++ b/homeassistant/components/alexa_devices/coordinator.py @@ -299,7 +299,9 @@ async def sync_todo_list_items(self) -> None: async def todo_event_handler(self, list_event: AmazonListEvent) -> None: """Handle changes on To-Do lists.""" if list_event.type == AmazonListEventType.DELETED: - self._todo_list_items[list_event.list_id].pop(list_event.item_id, None) + self._todo_list_items.get(list_event.list_id, {}).pop( + list_event.item_id, None + ) elif ( list_event.type in (AmazonListEventType.UPDATED, AmazonListEventType.CREATED) diff --git a/tests/components/alexa_devices/test_coordinator.py b/tests/components/alexa_devices/test_coordinator.py index cc81b994c8bf07..07c50e63f8ce5d 100644 --- a/tests/components/alexa_devices/test_coordinator.py +++ b/tests/components/alexa_devices/test_coordinator.py @@ -143,12 +143,12 @@ async def test_async_update_data_errors( ), pytest.param( CannotConnect, - ConfigEntryState.SETUP_RETRY, + ConfigEntryState.LOADED, id="cannot_connect", ), pytest.param( CannotRetrieveData, - ConfigEntryState.SETUP_RETRY, + ConfigEntryState.LOADED, id="cannot_retrieve_data", ), ], @@ -160,7 +160,7 @@ async def test_sync_history_state_error( side_effect: type[Exception], expected_state: ConfigEntryState, ) -> None: - """Test sync_history_state error handling.""" + """Test sync_history_state error handling does not block setup.""" mock_amazon_devices_client.sync_history_state.side_effect = side_effect mock_config_entry.add_to_hass(hass) @@ -180,22 +180,22 @@ async def test_sync_history_state_error( ), pytest.param( CannotConnect, - ConfigEntryState.SETUP_RETRY, + ConfigEntryState.LOADED, id="cannot_connect", ), pytest.param( TimeoutError, - ConfigEntryState.SETUP_RETRY, + ConfigEntryState.LOADED, id="timeout_error", ), pytest.param( CannotRetrieveData, - ConfigEntryState.SETUP_RETRY, + ConfigEntryState.LOADED, id="cannot_retrieve_data", ), pytest.param( ValueError, - ConfigEntryState.SETUP_RETRY, + ConfigEntryState.LOADED, id="value_error", ), ], diff --git a/tests/components/alexa_devices/test_init.py b/tests/components/alexa_devices/test_init.py index e798b3ceeb0198..2e793a18cd7b64 100644 --- a/tests/components/alexa_devices/test_init.py +++ b/tests/components/alexa_devices/test_init.py @@ -1,8 +1,11 @@ """Tests for the Alexa Devices integration.""" import asyncio +from collections.abc import Callable from unittest.mock import AsyncMock, patch +from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData +from aioamazondevices.structures import AmazonListInfo, AmazonListType import pytest from syrupy.assertion import SnapshotAssertion @@ -27,6 +30,24 @@ from tests.common import MockConfigEntry +def _fail_todo_list_items(client: AsyncMock, error: Exception) -> None: + """Make the todo list items sync fail.""" + client.todo_lists = [ + AmazonListInfo(id="shopping_list_id", name=None, list_type=AmazonListType.SHOP) + ] + client.get_todo_list_items.side_effect = error + + +def _fail_history_state(client: AsyncMock, error: Exception) -> None: + """Make the history state sync fail.""" + client.sync_history_state.side_effect = error + + +def _fail_media_state(client: AsyncMock, error: Exception) -> None: + """Make the media state sync fail.""" + client.sync_media_state.side_effect = error + + async def test_device_info( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -224,3 +245,69 @@ async def test_http2_stop_processing_called_on_shutdown( await hass.async_block_till_done() mock_amazon_devices_client.stop_http2_processing.assert_awaited_once() + + +@pytest.mark.parametrize( + ("configure_failure", "invoked_method"), + [ + (_fail_todo_list_items, "sync_todo_list_items"), + (_fail_history_state, "sync_history_state"), + (_fail_media_state, "sync_media_state"), + ], +) +@pytest.mark.parametrize( + "error", + [ + pytest.param( + CannotConnect("429 - Too Many Requests"), + id="http_429_too_many_requests", + ), + pytest.param( + CannotRetrieveData("503 - Service Unavailable"), + id="http_503_service_unavailable", + ), + ], +) +async def test_initial_sync_amazon_api_failure_does_not_block_setup( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + configure_failure: Callable[[AsyncMock, Exception], None], + invoked_method: str, + error: Exception, +) -> None: + """Test a failing initial sync call is logged but does not block setup.""" + configure_failure(mock_amazon_devices_client, error) + + await setup_integration(hass, mock_config_entry) + + assert f"Initial sync failed for {invoked_method}:" in caplog.text + assert str(error) in caplog.text + assert ( + "Data may be missing or incomplete until updates are pushed by Amazon" + in caplog.text + ) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_initial_sync_failure_does_not_prevent_other_syncs( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a failing initial sync call does not stop the remaining sync calls.""" + mock_amazon_devices_client.todo_lists = [ + AmazonListInfo(id="shopping_list_id", name=None, list_type=AmazonListType.SHOP) + ] + mock_amazon_devices_client.get_todo_list_items.return_value = {} + mock_amazon_devices_client.sync_history_state.side_effect = CannotConnect( + "429 - Too Many Requests" + ) + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_amazon_devices_client.get_todo_list_items.assert_awaited_once() + mock_amazon_devices_client.sync_media_state.assert_awaited_once() diff --git a/tests/components/alexa_devices/test_todo.py b/tests/components/alexa_devices/test_todo.py index ac4e9abb36a913..c751975cd765d3 100644 --- a/tests/components/alexa_devices/test_todo.py +++ b/tests/components/alexa_devices/test_todo.py @@ -430,12 +430,12 @@ async def test_todo_event_handler( ), pytest.param( CannotConnect, - ConfigEntryState.SETUP_RETRY, + ConfigEntryState.LOADED, id="cannot_connect", ), pytest.param( CannotRetrieveData, - ConfigEntryState.SETUP_RETRY, + ConfigEntryState.LOADED, id="cannot_retrieve_data", ), ], @@ -447,7 +447,7 @@ async def test_sync_todo_list_items_error( side_effect: type[Exception], expected_state: ConfigEntryState, ) -> None: - """Test setup fails when syncing todo list items raises an error.""" + """Test syncing todo list items handles errors without blocking setup.""" mock_amazon_devices_client.get_todo_list_items.side_effect = side_effect mock_amazon_devices_client.todo_lists = [ AmazonListInfo(id="shopping_list_id", name=None, list_type=AmazonListType.SHOP) From a107789a5b65413f6b3616565c8d18d4c42f2990 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Aug 2026 10:15:07 +0200 Subject: [PATCH 09/15] Fix via_device race in hydrawise (#178074) --- .../components/hydrawise/__init__.py | 42 +++++++++++++++++-- homeassistant/components/hydrawise/entity.py | 14 ++++++- tests/components/hydrawise/test_device.py | 37 ++++++++++++++++ tests/components/hydrawise/test_init.py | 38 +++++++++++++++++ 4 files changed, 126 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index 03cb40fecce9e8..1fced69ba70437 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -1,12 +1,15 @@ """Support for Hydrawise cloud.""" -from pydrawise import auth, hybrid +from collections.abc import Iterable + +from pydrawise import Controller, auth, hybrid from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr -from .const import APP_ID +from .const import APP_ID, DOMAIN, MANUFACTURER from .coordinator import ( HydrawiseConfigEntry, HydrawiseMainDataUpdateCoordinator, @@ -46,6 +49,39 @@ async def async_setup_entry( water_use_coordinator = HydrawiseWaterUseDataUpdateCoordinator( hass, config_entry, hydrawise, main_coordinator ) + + device_registry = dr.async_get(hass) + + @callback + def _async_register_controller_devices(controllers: Iterable[Controller]) -> None: + """Register controller devices so children can resolve via_device_id. + + Runs as the first new-controller callback so via_device parents are + registered before the new-zone callbacks construct zone entities that + resolve their via_device_id. Registration must not run before + _add_remove_zones computes the previous controllers, or newly discovered + controllers would be treated as already-known and their controller-level + entities would never be added. + """ + for controller in controllers: + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, str(controller.id))}, + manufacturer=MANUFACTURER, + model=controller.hardware.model.description, + name=controller.name, + # Explicitly clear any via_device_id: older versions linked the + # controller device to itself via its rain sensor entity. + via_device_id=None, + ) + + # Register the controllers known at setup before the platforms construct + # their entities. + _async_register_controller_devices(main_coordinator.data.controllers.values()) + main_coordinator.new_controllers_callbacks.append( + _async_register_controller_devices + ) + # async_track_zones is registered first on water_use_coordinator, # so the water-use coordinator's data is in sync before # callbacks below construct entities for newly added zones. diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py index 53ddaa3d0b7fd3..e171a77ba6d8bd 100644 --- a/homeassistant/components/hydrawise/entity.py +++ b/homeassistant/components/hydrawise/entity.py @@ -5,6 +5,7 @@ from pydrawise.schema import Controller, Sensor, Zone 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 @@ -46,8 +47,17 @@ def __init__( ), manufacturer=MANUFACTURER, ) - if zone_id is not None or sensor_id is not None: - self._attr_device_info["via_device"] = (DOMAIN, str(controller.id)) + if zone_id is not None: + # Only zones get their own device; sensor entities share the + # controller device, so linking them to the controller would create + # a self-referential via_device. + self._attr_device_info["via_device_id"] = ( + dr.async_get_device_id_by_identifier( + self.coordinator.hass, + (DOMAIN, str(controller.id)), + config_entry_id=self.coordinator.config_entry.entry_id, + ) + ) self._update_attrs() @property diff --git a/tests/components/hydrawise/test_device.py b/tests/components/hydrawise/test_device.py index 9d98f2a7b442de..3d68209febc4de 100644 --- a/tests/components/hydrawise/test_device.py +++ b/tests/components/hydrawise/test_device.py @@ -2,6 +2,8 @@ from unittest.mock import Mock +import pytest + from homeassistant.components.hydrawise.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -38,3 +40,38 @@ def test_controller_in_device_registry( assert device is not None assert device.name == "Home Controller" assert device.manufacturer == "Hydrawise" + + +@pytest.mark.usefixtures("mock_pydrawise") +def test_zone_via_device_links_to_controller( + device_registry: dr.DeviceRegistry, + mock_added_config_entry: ConfigEntry, +) -> None: + """Test that a zone device links to its controller via via_device_id.""" + controller = device_registry.async_get_device_by_identifier( + (DOMAIN, "52496"), mock_added_config_entry.entry_id + ) + assert controller is not None + + zone = device_registry.async_get_device_by_identifier( + (DOMAIN, "5965394"), mock_added_config_entry.entry_id + ) + assert zone is not None + assert zone.via_device_id == controller.id + + +@pytest.mark.usefixtures("mock_pydrawise") +def test_controller_has_no_self_via_device( + device_registry: dr.DeviceRegistry, + mock_added_config_entry: ConfigEntry, +) -> None: + """Test the controller device does not link to itself via via_device_id. + + Sensor entities share the controller device, so they must not set + via_device_id, which would point the controller device at itself. + """ + controller = device_registry.async_get_device_by_identifier( + (DOMAIN, "52496"), mock_added_config_entry.entry_id + ) + assert controller is not None + assert controller.via_device_id is None diff --git a/tests/components/hydrawise/test_init.py b/tests/components/hydrawise/test_init.py index bd8fba3b77bb15..4d3f28c4b7cdae 100644 --- a/tests/components/hydrawise/test_init.py +++ b/tests/components/hydrawise/test_init.py @@ -94,6 +94,12 @@ async def test_auto_add_devices( identifiers={(DOMAIN, str(controller2.id))} ) assert new_controller_device is not None + + # The new controller's own entities must also be added, not just its device. + # Registering the controller device must not make _add_remove_zones treat the + # controller as already-known and skip the new-controller callbacks. + assert hass.states.get("binary_sensor.home_controller_2_connectivity") is not None + for zone in zones2: new_zone_device = device_registry.async_get_device( identifiers={(DOMAIN, str(zone.id))} @@ -112,6 +118,38 @@ async def test_auto_add_devices( assert hass.states.get("sensor.zone_two_2_daily_active_watering_time") is not None +async def test_setup_clears_self_referential_via_device( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_pydrawise: AsyncMock, +) -> None: + """Test setup clears a self-referential via_device left by older versions. + + Older versions linked the controller device to itself via its rain sensor + entity, which persists in the device registry across upgrades. + """ + mock_config_entry.add_to_hass(hass) + controller = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "52496")}, + name="Home Controller", + ) + controller = device_registry.async_update_device( + controller.id, via_device_id=controller.id + ) + assert controller.via_device_id == controller.id + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + controller = device_registry.async_get_device_by_identifier( + (DOMAIN, "52496"), mock_config_entry.entry_id + ) + assert controller is not None + assert controller.via_device_id is None + + async def test_auto_remove_devices( hass: HomeAssistant, device_registry: DeviceRegistry, From 935764fb6cec6e6be08e95bdcff838758df86317 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 5 Aug 2026 03:27:24 -0500 Subject: [PATCH 10/15] Fix target matching in Assist sentence parser debugger (#178202) Co-authored-by: Claude Opus 5 --- .../components/conversation/default_agent.py | 37 +++-- tests/components/conversation/test_http.py | 153 +++++++++++++++++- 2 files changed, 176 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 26deb9383689ad..3e7a4cc8e90a8f 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -409,10 +409,13 @@ async def async_debug_recognize( } if successful_match: + satellite_area, _ = self._get_satellite_area_and_device( + user_input.satellite_id, user_input.device_id + ) result_dict["targets"] = { state.entity_id: {"matched": is_matched} for state, is_matched in _get_debug_targets( - self.hass, intent_result + self.hass, intent_result, satellite_area ) } @@ -1676,12 +1679,14 @@ def _collect_list_references(expression: Expression, list_names: set[str]) -> No def _get_debug_targets( hass: HomeAssistant, result: RecognizeResult, + satellite_area: ar.AreaEntry | None = None, ) -> Iterable[tuple[State, bool]]: """Yield state/is_matched pairs for a hassil recognition.""" entities = result.entities name: str | None = None area_name: str | None = None + floor_name: str | None = None domains: set[str] | None = None device_classes: set[str] | None = None state_names: set[str] | None = None @@ -1692,6 +1697,9 @@ def _get_debug_targets( if "area" in entities: area_name = str(entities["area"].value) + if "floor" in entities: + floor_name = str(entities["floor"].value) + if "domain" in entities: domains = set(cv.ensure_list(entities["domain"].value)) @@ -1702,24 +1710,27 @@ def _get_debug_targets( # HassGetState only state_names = set(cv.ensure_list(entities["state"].value)) - if ( - (name is None) - and (area_name is None) - and (not domains) - and (not device_classes) - and (not state_names) - ): - # Avoid "matching" all entities when there is no filter - return - - states = intent.async_match_states( - hass, + constraints = intent.MatchTargetsConstraints( name=name, area_name=area_name, + floor_name=floor_name, domains=domains, device_classes=device_classes, + assistant=DOMAIN, ) + if not (constraints.has_constraints or state_names): + # Avoid "matching" all entities when there is no filter + return + + # Mirror the preferences used when the intent is actually handled so that + # duplicate names are deduplicated the same way. + preferences = intent.MatchTargetsPreferences( + area_id=satellite_area.id if satellite_area is not None else None + ) + + states = intent.async_match_targets(hass, constraints, preferences).states + for state in states: # For queries, a target is "matched" based on its state is_matched = (state_names is None) or (state.state in state_names) diff --git a/tests/components/conversation/test_http.py b/tests/components/conversation/test_http.py index 66964c03eb929a..f024ba0d220254 100644 --- a/tests/components/conversation/test_http.py +++ b/tests/components/conversation/test_http.py @@ -9,6 +9,7 @@ import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components import conversation from homeassistant.components.conversation import ( AssistantContent, ConversationInput, @@ -17,13 +18,16 @@ ) from homeassistant.components.conversation.const import HOME_ASSISTANT_AGENT from homeassistant.components.conversation.models import ConversationResult +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import ( area_registry as ar, chat_session, + device_registry as dr, entity_registry as er, + floor_registry as fr, intent, ) from homeassistant.setup import async_setup_component @@ -31,7 +35,12 @@ from . import MockAgent -from tests.common import MockUser, async_fire_time_changed, async_mock_service +from tests.common import ( + MockConfigEntry, + MockUser, + async_fire_time_changed, + async_mock_service, +) from tests.typing import ClientSessionGenerator, WebSocketGenerator AGENT_ID_OPTIONS = [ @@ -454,6 +463,148 @@ async def async_recognize_intent(self, user_input, *args, **kwargs): assert msg["result"]["results"] == [None] +async def test_ws_hass_agent_debug_floor( + hass: HomeAssistant, + init_components, + hass_ws_client: WebSocketGenerator, + area_registry: ar.AreaRegistry, + floor_registry: fr.FloorRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that debug targets are restricted to the matched floor.""" + first_floor = floor_registry.async_create("first floor") + floor_registry.async_create("ground floor") + + bedroom_area = area_registry.async_create("bedroom", floor_id=first_floor.floor_id) + bedroom_light = entity_registry.async_get_or_create( + "light", "demo", "bedroom", original_name="bedroom light" + ) + entity_registry.async_update_entity( + bedroom_light.entity_id, area_id=bedroom_area.id + ) + hass.states.async_set(bedroom_light.entity_id, "on") + + # Not assigned to a floor + garage_area = area_registry.async_create("garage") + garage_light = entity_registry.async_get_or_create( + "light", "demo", "garage", original_name="garage light" + ) + entity_registry.async_update_entity(garage_light.entity_id, area_id=garage_area.id) + hass.states.async_set(garage_light.entity_id, "on") + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "conversation/agent/homeassistant/debug", + "sentences": [ + "turn off the lights on the first floor", + "turn off the lights on the ground floor", + ], + } + ) + msg = await client.receive_json() + + assert msg["success"] + results = msg["result"]["results"] + assert results[0]["match"] + assert results[0]["targets"] == {bedroom_light.entity_id: {"matched": True}} + + # No areas are assigned to the ground floor + assert results[1]["match"] + assert results[1]["targets"] == {} + + +async def test_ws_hass_agent_debug_unexposed_entity( + hass: HomeAssistant, + init_components, + hass_ws_client: WebSocketGenerator, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that debug targets only include entities exposed to Assist.""" + kitchen_area = area_registry.async_create("kitchen") + + exposed_light = entity_registry.async_get_or_create( + "light", "demo", "exposed", original_name="exposed light" + ) + hidden_light = entity_registry.async_get_or_create( + "light", "demo", "hidden", original_name="hidden light" + ) + for entity_entry in (exposed_light, hidden_light): + entity_registry.async_update_entity( + entity_entry.entity_id, area_id=kitchen_area.id + ) + hass.states.async_set(entity_entry.entity_id, "on") + + async_expose_entity(hass, conversation.DOMAIN, hidden_light.entity_id, False) + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "conversation/agent/homeassistant/debug", + "sentences": ["turn off the lights in the kitchen"], + } + ) + msg = await client.receive_json() + + assert msg["success"] + results = msg["result"]["results"] + assert results[0]["match"] + assert results[0]["targets"] == {exposed_light.entity_id: {"matched": True}} + + +async def test_ws_hass_agent_debug_preferred_area( + hass: HomeAssistant, + init_components, + hass_ws_client: WebSocketGenerator, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that debug targets use the requesting device's area to disambiguate.""" + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) + + bedroom_area = area_registry.async_create("bedroom") + office_area = area_registry.async_create("office") + + # Duplicate names in two areas + bedroom_light = entity_registry.async_get_or_create( + "light", "demo", "bedroom", original_name="overhead light" + ) + entity_registry.async_update_entity( + bedroom_light.entity_id, area_id=bedroom_area.id + ) + hass.states.async_set(bedroom_light.entity_id, "on") + + office_light = entity_registry.async_get_or_create( + "light", "demo", "office", original_name="overhead light" + ) + entity_registry.async_update_entity(office_light.entity_id, area_id=office_area.id) + hass.states.async_set(office_light.entity_id, "on") + + voice_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("demo", "voice-satellite")}, + ) + device_registry.async_update_device(voice_device.id, area_id=office_area.id) + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "conversation/agent/homeassistant/debug", + "sentences": ["turn off the overhead light"], + "device_id": voice_device.id, + } + ) + msg = await client.receive_json() + + assert msg["success"] + results = msg["result"]["results"] + assert results[0]["match"] + assert results[0]["targets"] == {office_light.entity_id: {"matched": True}} + + async def test_ws_hass_agent_debug_out_of_range( hass: HomeAssistant, init_components, From 1b72fba9842f9e066ad331b3522bca83fe3bf985 Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Wed, 5 Aug 2026 11:01:44 +0200 Subject: [PATCH 11/15] Use dt_util.naive_now() in upnp tests (#178198) --- tests/components/upnp/conftest.py | 4 ++-- tests/components/upnp/test_binary_sensor.py | 4 ++-- tests/components/upnp/test_sensor.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/components/upnp/conftest.py b/tests/components/upnp/conftest.py index c9f2fdc119113a..b4525bf8dfd60a 100644 --- a/tests/components/upnp/conftest.py +++ b/tests/components/upnp/conftest.py @@ -2,7 +2,6 @@ from collections.abc import Callable, Coroutine, Generator import copy -from datetime import datetime import socket from typing import Any from unittest.mock import AsyncMock, MagicMock, create_autospec, patch @@ -32,6 +31,7 @@ ATTR_UPNP_UDN, SsdpServiceInfo, ) +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry @@ -93,7 +93,7 @@ def mock_igd_device(mock_async_create_device) -> IgdDevice: mock_igd_device.device = mock_upnp_device mock_igd_device.async_get_traffic_and_status_data.return_value = IgdState( - timestamp=datetime.now(), # pylint: disable=home-assistant-enforce-naive-now + timestamp=dt_util.naive_now(), bytes_received=0, bytes_sent=0, packets_received=0, diff --git a/tests/components/upnp/test_binary_sensor.py b/tests/components/upnp/test_binary_sensor.py index 537d8c2753c081..b9461462e9488a 100644 --- a/tests/components/upnp/test_binary_sensor.py +++ b/tests/components/upnp/test_binary_sensor.py @@ -1,6 +1,6 @@ """Tests for UPnP/IGD binary_sensor.""" -from datetime import datetime, timedelta +from datetime import timedelta from async_upnp_client.profiles.igd import IgdDevice, IgdState @@ -22,7 +22,7 @@ async def test_upnp_binary_sensors( # Second poll. mock_igd_device: IgdDevice = mock_config_entry.igd_device mock_igd_device.async_get_traffic_and_status_data.return_value = IgdState( - timestamp=datetime.now(), # pylint: disable=home-assistant-enforce-naive-now + timestamp=dt_util.naive_now(), bytes_received=0, bytes_sent=0, packets_received=0, diff --git a/tests/components/upnp/test_sensor.py b/tests/components/upnp/test_sensor.py index 8c8a8ee24355bc..4023851eed4464 100644 --- a/tests/components/upnp/test_sensor.py +++ b/tests/components/upnp/test_sensor.py @@ -1,6 +1,6 @@ """Tests for UPnP/IGD sensor.""" -from datetime import datetime, timedelta +from datetime import timedelta from async_upnp_client.profiles.igd import IgdDevice, IgdState import pytest @@ -53,7 +53,7 @@ async def test_upnp_sensors( # Second poll. mock_igd_device: IgdDevice = mock_config_entry.igd_device mock_igd_device.async_get_traffic_and_status_data.return_value = IgdState( - timestamp=datetime.now(), # pylint: disable=home-assistant-enforce-naive-now + timestamp=dt_util.naive_now(), bytes_received=10240, bytes_sent=20480, packets_received=30, From f5216975c7b6473e43840554d9f6fff2ca59b2e1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:06:57 +0200 Subject: [PATCH 12/15] Update uv to 0.12.0 (#178210) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2d7f11f1ad1bc2..fee6d345e625fe 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -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.32 +uv==0.12.0 voluptuous-openapi==0.4.1 voluptuous-serialize==2.7.0 voluptuous==0.15.2 diff --git a/pyproject.toml b/pyproject.toml index f74df5070bcc96..84114591fef043 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.32", + "uv==0.12.0", "voluptuous==0.15.2", "voluptuous-serialize==2.7.0", "voluptuous-openapi==0.4.1", diff --git a/requirements.txt b/requirements.txt index 8515365c9d995e..d81d491da2a6be 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.32 +uv==0.12.0 voluptuous-openapi==0.4.1 voluptuous-serialize==2.7.0 voluptuous==0.15.2 From 5bcb00efb81758fdfda1d71984a1a88742c00339 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 5 Aug 2026 11:11:19 +0200 Subject: [PATCH 13/15] Require admin for Z-Wave lock credential services (#177300) --- homeassistant/components/zwave_js/services.py | 6 ++ homeassistant/helpers/service.py | 28 ++++++--- .../zwave_js/test_credential_services.py | 62 ++++++++++++++++++- tests/helpers/test_service.py | 52 +++++++++++++++- 4 files changed, 136 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 80aba468d7ad83..e2016bff5a06c0 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -83,6 +83,7 @@ def _async_register_credential_services(hass: HomeAssistant) -> None: hass, const.DOMAIN, "set_user", + admin_only=True, entity_domain=LOCK_DOMAIN, schema={ vol.Optional(const.ATTR_USER_ID): uint16_id, @@ -106,6 +107,7 @@ def _async_register_credential_services(hass: HomeAssistant) -> None: hass, const.DOMAIN, "delete_user", + admin_only=True, entity_domain=LOCK_DOMAIN, schema={vol.Required(const.ATTR_USER_ID): uint16_id}, func="async_delete_user", @@ -115,6 +117,7 @@ def _async_register_credential_services(hass: HomeAssistant) -> None: hass, const.DOMAIN, "delete_all_users", + admin_only=True, entity_domain=LOCK_DOMAIN, schema={}, func="async_delete_all_users", @@ -144,6 +147,7 @@ def _async_register_credential_services(hass: HomeAssistant) -> None: hass, const.DOMAIN, "set_credential", + admin_only=True, entity_domain=LOCK_DOMAIN, schema={ vol.Required(const.ATTR_USER_ID): uint16_id, @@ -161,6 +165,7 @@ def _async_register_credential_services(hass: HomeAssistant) -> None: hass, const.DOMAIN, "delete_credential", + admin_only=True, entity_domain=LOCK_DOMAIN, schema={ vol.Required(const.ATTR_USER_ID): uint16_id, @@ -176,6 +181,7 @@ def _async_register_credential_services(hass: HomeAssistant) -> None: hass, const.DOMAIN, "delete_all_credentials", + admin_only=True, entity_domain=LOCK_DOMAIN, schema={vol.Required(const.ATTR_USER_ID): uint16_id}, func="async_delete_all_credentials", diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index c9c56e4f09ab45..4dd09cca57da61 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -1256,6 +1256,7 @@ def async_register_platform_entity_service( service_domain: str, service_name: str, *, + admin_only: bool = False, description_placeholders: Mapping[str, str] | None = None, entity_device_classes: Iterable[str | None] | None = None, entity_domain: str, @@ -1269,18 +1270,29 @@ def async_register_platform_entity_service( service_func: str | HassJob[..., Any] service_func = func if isinstance(func, str) else HassJob(func) + entity_handler = partial( + entity_service_call, + hass, + partial(_get_platform_entities, hass, entity_domain, service_domain), + service_func, + entity_device_classes=entity_device_classes, + required_features=required_features, + ) + + service_handler = ( + partial( + _async_admin_handler, + hass, + HassJob(entity_handler, f"admin service {service_domain}.{service_name}"), + ) + if admin_only + else entity_handler + ) hass.services.async_register( service_domain, service_name, - partial( - entity_service_call, - hass, - partial(_get_platform_entities, hass, entity_domain, service_domain), - service_func, - entity_device_classes=entity_device_classes, - required_features=required_features, - ), + service_handler, schema, supports_response, job_type=HassJobType.Coroutinefunction, diff --git a/tests/components/zwave_js/test_credential_services.py b/tests/components/zwave_js/test_credential_services.py index 61742b66403eae..1efc844085570a 100644 --- a/tests/components/zwave_js/test_credential_services.py +++ b/tests/components/zwave_js/test_credential_services.py @@ -24,11 +24,11 @@ from homeassistant.components.zwave_js.const import DOMAIN from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.const import ATTR_ENTITY_ID -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, MockUser def _mock_access_control( @@ -1794,3 +1794,59 @@ async def test_service_access_control_not_supported( # The guard runs before anything else, so no capability query is issued. api.is_supported.assert_called_once_with() api.get_user_capabilities_cached.assert_not_called() + + +@pytest.mark.parametrize( + ("service", "service_data", "returns_response"), + [ + pytest.param("set_user", {}, True, id="set_user"), + pytest.param("delete_user", {"user_id": 1}, False, id="delete_user"), + pytest.param("delete_all_users", {}, False, id="delete_all_users"), + pytest.param( + "set_credential", + {"user_id": 1, "credential_type": "pin_code", "credential_data": "1234"}, + True, + id="set_credential", + ), + pytest.param( + "delete_credential", + {"user_id": 1, "credential_type": "pin_code", "credential_slot": 1}, + False, + id="delete_credential", + ), + pytest.param( + "delete_all_credentials", {"user_id": 1}, False, id="delete_all_credentials" + ), + ], +) +async def test_service_requires_admin( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, + hass_read_only_user: MockUser, + service: str, + service_data: dict[str, int | str], + returns_response: bool, +) -> None: + """Every mutating user/credential service rejects non-admin users.""" + # Grant control of all entities, so the call is only rejected for not being admin + hass_read_only_user.mock_policy({"entities": {"all": {"control": True}}}) + api = _mock_access_control(lock_schlage_be469) + entity_id = _lock_entity_id( + entity_registry, device_registry, client, lock_schlage_be469 + ) + + with pytest.raises(Unauthorized): + await hass.services.async_call( + DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, **service_data}, + blocking=True, + return_response=returns_response, + context=Context(user_id=hass_read_only_user.id), + ) + + api.is_supported.assert_not_called() diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index e9e459a4d60aa2..7505798e9dc1d0 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -1911,7 +1911,7 @@ async def test_register_admin_service( hass: HomeAssistant, hass_read_only_user: MockUser, hass_admin_user: MockUser ) -> None: """Test the register admin service.""" - calls = [] + calls: list[ServiceCall] = [] async def mock_service(call): calls.append(call) @@ -2876,6 +2876,56 @@ async def generate_response( } +async def test_register_platform_entity_service_admin_only( + hass: HomeAssistant, + hass_admin_user: MockUser, + hass_read_only_user: MockUser, +) -> None: + """Test an admin-only platform entity service.""" + # Grant control of all entities, so the call is only rejected for not being admin + hass_read_only_user.mock_policy({"entities": {"all": {"control": True}}}) + calls: list[MockEntity] = [] + + @callback + def handle_service(entity: MockEntity, *_: Any) -> None: + calls.append(entity) + + service.async_register_platform_entity_service( + hass, + "mock_platform", + "hello", + admin_only=True, + entity_domain="mock_integration", + schema={}, + func=handle_service, + ) + + entity_platform = MockEntityPlatform( + hass, domain="mock_integration", platform_name="mock_platform", platform=None + ) + entity = MockEntity(entity_id="mock_integration.entity") + await entity_platform.async_add_entities([entity]) + + with pytest.raises(exceptions.Unauthorized): + await hass.services.async_call( + "mock_platform", + "hello", + {"entity_id": entity.entity_id}, + blocking=True, + context=Context(user_id=hass_read_only_user.id), + ) + assert calls == [] + + await hass.services.async_call( + "mock_platform", + "hello", + {"entity_id": entity.entity_id}, + blocking=True, + context=Context(user_id=hass_admin_user.id), + ) + assert calls == [entity] + + async def test_register_platform_entity_service_response_data_multiple_matches( hass: HomeAssistant, ) -> None: From 7abc24da31cd62028878a1d8fbb05e8aead6d39c Mon Sep 17 00:00:00 2001 From: Willem Vooijs Date: Wed, 5 Aug 2026 05:15:42 -0400 Subject: [PATCH 14/15] Add Ridder HortiMaX Pro (HortOS) integration (#177247) --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/hortimax/__init__.py | 44 + .../components/hortimax/config_flow.py | 76 ++ homeassistant/components/hortimax/const.py | 163 +++ .../components/hortimax/coordinator.py | 139 +++ homeassistant/components/hortimax/entity.py | 51 + .../components/hortimax/manifest.json | 11 + .../components/hortimax/quality_scale.yaml | 106 ++ homeassistant/components/hortimax/sensor.py | 205 ++++ .../components/hortimax/strings.json | 36 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + tests/components/hortimax/__init__.py | 12 + tests/components/hortimax/conftest.py | 77 ++ .../hortimax/fixtures/readouts.json | 348 ++++++ .../hortimax/snapshots/test_sensor.ambr | 1014 +++++++++++++++++ tests/components/hortimax/test_config_flow.py | 141 +++ tests/components/hortimax/test_init.py | 155 +++ tests/components/hortimax/test_sensor.py | 327 ++++++ 22 files changed, 2928 insertions(+) create mode 100644 homeassistant/components/hortimax/__init__.py create mode 100644 homeassistant/components/hortimax/config_flow.py create mode 100644 homeassistant/components/hortimax/const.py create mode 100644 homeassistant/components/hortimax/coordinator.py create mode 100644 homeassistant/components/hortimax/entity.py create mode 100644 homeassistant/components/hortimax/manifest.json create mode 100644 homeassistant/components/hortimax/quality_scale.yaml create mode 100644 homeassistant/components/hortimax/sensor.py create mode 100644 homeassistant/components/hortimax/strings.json create mode 100644 tests/components/hortimax/__init__.py create mode 100644 tests/components/hortimax/conftest.py create mode 100644 tests/components/hortimax/fixtures/readouts.json create mode 100644 tests/components/hortimax/snapshots/test_sensor.ambr create mode 100644 tests/components/hortimax/test_config_flow.py create mode 100644 tests/components/hortimax/test_init.py create mode 100644 tests/components/hortimax/test_sensor.py diff --git a/.strict-typing b/.strict-typing index 4a7e5c1e8dee2a..1603e7bb749cd2 100644 --- a/.strict-typing +++ b/.strict-typing @@ -284,6 +284,7 @@ homeassistant.components.homekit_controller.storage homeassistant.components.homekit_controller.utils homeassistant.components.homewizard.* homeassistant.components.homeworks.* +homeassistant.components.hortimax.* homeassistant.components.hr_energy_qube.* homeassistant.components.http.* homeassistant.components.huawei_lte.* diff --git a/CODEOWNERS b/CODEOWNERS index 6399a23dcb90a0..103b577f52afba 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -797,6 +797,8 @@ CLAUDE.md @home-assistant/core /tests/components/honeywell/ @mkmer /homeassistant/components/honeywell_string_lights/ @balloob /tests/components/honeywell_string_lights/ @balloob +/homeassistant/components/hortimax/ @wildekek +/tests/components/hortimax/ @wildekek /homeassistant/components/hr_energy_qube/ @MattieGit /tests/components/hr_energy_qube/ @MattieGit /homeassistant/components/html5/ @alexyao2015 @tr4nt0r diff --git a/homeassistant/components/hortimax/__init__.py b/homeassistant/components/hortimax/__init__.py new file mode 100644 index 00000000000000..dd4660c82c78f8 --- /dev/null +++ b/homeassistant/components/hortimax/__init__.py @@ -0,0 +1,44 @@ +"""The Ridder HortiMaX Pro (HortOS) integration.""" + +from aiohortos import HortosClient + +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, MANUFACTURER +from .coordinator import HortimaxConfigEntry, HortimaxCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: HortimaxConfigEntry) -> bool: + """Set up Ridder HortiMaX Pro from a config entry.""" + client = HortosClient( + entry.data[CONF_API_KEY], session=async_get_clientsession(hass) + ) + coordinator = HortimaxCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + # Registered up front so the sensor platform's source devices can point at + # them through via_device. + device_registry = dr.async_get(hass) + for device in coordinator.devices: + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, device.name)}, + manufacturer=MANUFACTURER, + name=device.label or device.name, + model="HortiMaX Pro", + serial_number=device.name, + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: HortimaxConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/hortimax/config_flow.py b/homeassistant/components/hortimax/config_flow.py new file mode 100644 index 00000000000000..6ec55721e6f835 --- /dev/null +++ b/homeassistant/components/hortimax/config_flow.py @@ -0,0 +1,76 @@ +"""Config flow for the Ridder HortiMaX Pro (HortOS) integration.""" + +from typing import Any, override + +from aiohortos import HortosAuthenticationError, HortosClient, HortosError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN, LOGGER + +USER_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD, autocomplete="api_key") + ), + } +) + + +class HortimaxConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Ridder HortiMaX Pro.""" + + async def _async_validate(self, api_key: str, errors: dict[str, str]) -> str | None: + """Authenticate and list controllers, returning the organisation id.""" + client = HortosClient(api_key, session=async_get_clientsession(self.hass)) + try: + tokens = await client.authenticate() + devices = await client.get_device_names() + except HortosAuthenticationError: + errors["base"] = "invalid_auth" + except HortosError: + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected error validating the HortOS API") + errors["base"] = "unknown" + else: + if not devices: + errors["base"] = "no_devices" + elif tokens.organisation is None or tokens.organisation.id is None: + # Every API key is issued under an organisation, so this only + # happens if the API changes shape. + LOGGER.error("HortOS reported no organisation for this API key") + errors["base"] = "unknown" + else: + return tokens.organisation.id + return None + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + organisation_id = await self._async_validate( + user_input[CONF_API_KEY], errors + ) + if not errors: + await self.async_set_unique_id(organisation_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title="Ridder HortiMaX Pro", data=user_input + ) + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema(USER_SCHEMA, user_input), + errors=errors, + ) diff --git a/homeassistant/components/hortimax/const.py b/homeassistant/components/hortimax/const.py new file mode 100644 index 00000000000000..2ef5e3cf775f27 --- /dev/null +++ b/homeassistant/components/hortimax/const.py @@ -0,0 +1,163 @@ +"""Constants for the Ridder HortiMaX Pro (HortOS) integration.""" + +import logging +from typing import Final + +from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription +from homeassistant.const import ( + DEGREE, + LIGHT_LUX, + PERCENTAGE, + UnitOfConductivity, + UnitOfEnergy, + UnitOfIrradiance, + UnitOfMass, + UnitOfPower, + UnitOfPressure, + UnitOfRatio, + UnitOfSpeed, + UnitOfTemperature, + UnitOfTime, + UnitOfVolume, + UnitOfVolumeFlowRate, +) + +DOMAIN: Final = "hortimax" +LOGGER: Final = logging.getLogger(__package__) +MANUFACTURER: Final = "Ridder" + +# Changed readouts are published about once a minute; unchanged ones keep a +# stale timestamp for up to five. One request per controller, limit 100/15s. +SCAN_INTERVAL: Final = 60 # seconds + +# Dimensionless readouts are mostly status/override codes, so they get neither +# a unit nor statistics. +DIMENSIONLESS_UNITS: Final = {"Scalar", "None"} + +# Seconds since local midnight (SunriseToday = 19145 -> 05:19), rendered as a +# timestamp. Keyed by the lowercased subject from `readout_subject()`. +TIME_OF_DAY_READOUTS: Final[frozenset[str]] = frozenset({"sunrisetoday", "sunsettoday"}) +SECONDS_PER_DAY: Final = 24 * 60 * 60 + +# Icons for readouts that have no device class, and so no automatic icon. +READOUT_ICONS: Final[dict[str, str]] = { + # A g/kg mixing ratio, not a relative humidity. + "absolutehumidity": "mdi:water-opacity", + "radiationsum": "mdi:sun-wireless", + # A g/kg moisture shortfall, not a pressure, so VPD does not apply. + "humiditydeficit": "mdi:water-minus", +} + +# An enumeration member id rather than a bearing; aiohortos owns the table. +# This only decides which readout gets the wind direction device class. +WIND_DIRECTION_SUBJECT: Final = "cardinalwinddirection" + +# HortOS unit identifiers to Home Assistant units. Unknown identifiers fall +# back to the raw string, without a device class. +UNIT_MAP: Final[dict[str, str]] = { + # Observed on a live installation + "Percent": PERCENTAGE, + "DegreeCelsius": UnitOfTemperature.CELSIUS, + "Second": UnitOfTime.SECONDS, + "Minute": UnitOfTime.MINUTES, + "Gram/Kilogram": "g/kg", + "Joule/SquareCentimeter": "J/cm²", + "Liter/Minute": UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + "Liter/SquareMeter": "l/m²", + "KilowattHour": UnitOfEnergy.KILO_WATT_HOUR, + "Watt/SquareMeter": UnitOfIrradiance.WATTS_PER_SQUARE_METER, + "Meter/Second": UnitOfSpeed.METERS_PER_SECOND, + "CubicMeter": UnitOfVolume.CUBIC_METERS, + # Plausible variants, not yet observed + "DegreesCelsius": UnitOfTemperature.CELSIUS, + "DegreeFahrenheit": UnitOfTemperature.FAHRENHEIT, + "DegreesFahrenheit": UnitOfTemperature.FAHRENHEIT, + "Kelvin": UnitOfTemperature.KELVIN, + "Percentage": PERCENTAGE, + "PartsPerMillion": UnitOfRatio.PARTS_PER_MILLION, + "Joule/SquareMeter": "J/m²", + "Kilometer/Hour": UnitOfSpeed.KILOMETERS_PER_HOUR, + "Degrees": DEGREE, + "Degree": DEGREE, + "Liter": UnitOfVolume.LITERS, + "Milliliter": UnitOfVolume.MILLILITERS, + "Milliliter/SquareMeter": "ml/m²", + "CubicMeter/Hour": UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + "Kilogram": UnitOfMass.KILOGRAMS, + "Gram": UnitOfMass.GRAMS, + "Hour": UnitOfTime.HOURS, + "MilliSiemens/Centimeter": UnitOfConductivity.MILLISIEMENS_PER_CM, + "MicroSiemens/Centimeter": UnitOfConductivity.MICROSIEMENS_PER_CM, + "Ph": "pH", + "PH": "pH", + "Bar": UnitOfPressure.BAR, + "MilliBar": UnitOfPressure.MBAR, + "HectoPascal": UnitOfPressure.HPA, + "Pascal": UnitOfPressure.PA, + "Gram/CubicMeter": "g/m³", + "Micromol/SquareMeter/Second": "µmol/m²/s", + "Mol/SquareMeter/Day": "mol/m²/d", + "Lux": LIGHT_LUX, + "Watt": UnitOfPower.WATT, + "Kilowatt": UnitOfPower.KILO_WATT, +} + +# Everything that follows from the unit alone. Device classes that also need +# the readout identifier or its source (humidity, CO2, wind, gas) are set in +# sensor.py. Precision is display only, and needs a default because the API +# emits float32-converted doubles (90.15303039550781 %). +UNIT_DESCRIPTIONS: Final[dict[str, SensorEntityDescription]] = { + unit: SensorEntityDescription( + key=unit, + native_unit_of_measurement=unit, + device_class=device_class, + suggested_display_precision=precision, + ) + for unit, device_class, precision in ( + (UnitOfTemperature.CELSIUS, SensorDeviceClass.TEMPERATURE, 1), + (UnitOfTemperature.FAHRENHEIT, SensorDeviceClass.TEMPERATURE, 1), + (UnitOfTemperature.KELVIN, SensorDeviceClass.TEMPERATURE, 1), + (PERCENTAGE, None, 1), + ("g/kg", None, 1), + ("g/m³", None, 1), + ("J/cm²", None, 1), + ("J/m²", None, 0), + (UnitOfSpeed.METERS_PER_SECOND, None, 1), + (UnitOfSpeed.KILOMETERS_PER_HOUR, None, 1), + (UnitOfVolumeFlowRate.LITERS_PER_MINUTE, SensorDeviceClass.VOLUME_FLOW_RATE, 1), + ( + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + SensorDeviceClass.VOLUME_FLOW_RATE, + 1, + ), + ("l/m²", None, 1), + ("ml/m²", None, 0), + (UnitOfEnergy.KILO_WATT_HOUR, SensorDeviceClass.ENERGY, 2), + (UnitOfVolume.CUBIC_METERS, None, 2), + (UnitOfVolume.LITERS, None, 1), + (UnitOfVolume.MILLILITERS, None, 0), + (UnitOfTime.SECONDS, SensorDeviceClass.DURATION, 0), + (UnitOfTime.MINUTES, SensorDeviceClass.DURATION, 0), + (UnitOfTime.HOURS, SensorDeviceClass.DURATION, 1), + (UnitOfIrradiance.WATTS_PER_SQUARE_METER, SensorDeviceClass.IRRADIANCE, 0), + (UnitOfRatio.PARTS_PER_MILLION, None, 0), + (LIGHT_LUX, SensorDeviceClass.ILLUMINANCE, 0), + (UnitOfConductivity.MILLISIEMENS_PER_CM, SensorDeviceClass.CONDUCTIVITY, 2), + (UnitOfConductivity.MICROSIEMENS_PER_CM, SensorDeviceClass.CONDUCTIVITY, 0), + ("pH", None, 1), + (UnitOfPressure.BAR, SensorDeviceClass.PRESSURE, 2), + (UnitOfPressure.MBAR, SensorDeviceClass.PRESSURE, 0), + (UnitOfPressure.HPA, SensorDeviceClass.PRESSURE, 0), + (UnitOfPressure.PA, SensorDeviceClass.PRESSURE, 0), + ("µmol/m²/s", None, 0), + ("mol/m²/d", None, 1), + (DEGREE, None, 0), + (UnitOfPower.WATT, SensorDeviceClass.POWER, 0), + (UnitOfPower.KILO_WATT, SensorDeviceClass.POWER, 2), + (UnitOfMass.KILOGRAMS, SensorDeviceClass.WEIGHT, 1), + (UnitOfMass.GRAMS, SensorDeviceClass.WEIGHT, 0), + ) +} + +# pH has no device class on purpose: SensorDeviceClass.PH accepts no unit, and +# keeping the "pH" unit is worth more than the class. diff --git a/homeassistant/components/hortimax/coordinator.py b/homeassistant/components/hortimax/coordinator.py new file mode 100644 index 00000000000000..c86217a556f6e1 --- /dev/null +++ b/homeassistant/components/hortimax/coordinator.py @@ -0,0 +1,139 @@ +"""Data update coordinator for the Ridder HortiMaX Pro (HortOS) integration.""" + +from dataclasses import dataclass, field +from datetime import timedelta +from typing import override + +from aiohortos import ( + Device, + HortosAuthenticationError, + HortosClient, + HortosError, + Readout, + disambiguate_source_names, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER, SCAN_INTERVAL + +type HortimaxConfigEntry = ConfigEntry[HortimaxCoordinator] + + +def source_key(source_type: str, source_name: str) -> str: + """Return a stable key for a source within a controller.""" + return f"{source_type}::{source_name}" + + +def readout_key(source_type: str, source_name: str, identifier: str) -> str: + """Return a stable key for a readout within a controller.""" + return f"{source_type}::{source_name}::{identifier}" + + +@dataclass +class HortimaxDeviceData: + """All data for one greenhouse controller.""" + + device: Device + readouts: dict[str, Readout] = field(default_factory=dict) + #: Source key -> de-duplicated display name. + source_names: dict[str, str] = field(default_factory=dict) + + +class HortimaxCoordinator(DataUpdateCoordinator[dict[str, HortimaxDeviceData]]): + """Poll the latest readout values of every controller.""" + + config_entry: HortimaxConfigEntry + devices: list[Device] + + def __init__( + self, + hass: HomeAssistant, + config_entry: HortimaxConfigEntry, + client: HortosClient, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + logger=LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=timedelta(seconds=SCAN_INTERVAL), + ) + self.client = client + + @override + async def _async_setup(self) -> None: + """Discover the available controllers once.""" + try: + self.devices = await self.client.get_devices() + except HortosAuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="invalid_auth" + ) from err + except HortosError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": str(err)}, + ) from err + + # The config flow refuses a key with no controllers, so an entry that + # suddenly has none is a change on the HortOS side. Retrying beats + # loading an integration with nothing in it. + if not self.devices: + raise UpdateFailed(translation_domain=DOMAIN, translation_key="no_devices") + + @override + async def _async_update_data(self) -> dict[str, HortimaxDeviceData]: + """Fetch the latest value of every readout of every controller.""" + data: dict[str, HortimaxDeviceData] = {} + try: + for device in self.devices: + device_data = HortimaxDeviceData(device=device) + sources: dict[str, tuple[str, str, str]] = {} + for readout in await self.client.get_latest_readouts(device.name): + source = readout.source + key = readout_key(source.type, source.name, readout.identifier) + device_data.readouts[key] = readout + sources[source_key(source.type, source.name)] = ( + source.display_name, + source.type, + source.name, + ) + device_data.source_names = disambiguate_source_names(sources) + data[device.name] = device_data + self._rename_changed_sources(device.name, device_data) + except HortosAuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="invalid_auth" + ) from err + except HortosError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": str(err)}, + ) from err + return data + + @callback + def _rename_changed_sources( + self, device_id: str, device_data: HortimaxDeviceData + ) -> None: + """Follow a source that was renamed, or that now collides with another. + + Entities set the device name when they are first added, so a rename in + HortiMaX Pro would otherwise not show until the entry is reloaded. A + name the user set themselves takes precedence and is left alone. + """ + registry = dr.async_get(self.hass) + for key, name in device_data.source_names.items(): + device = registry.async_get_device( + identifiers={(DOMAIN, f"{device_id}::{key}")} + ) + if device is not None and device.name != name: + registry.async_update_device(device.id, name=name) diff --git a/homeassistant/components/hortimax/entity.py b/homeassistant/components/hortimax/entity.py new file mode 100644 index 00000000000000..ce2f1d14293201 --- /dev/null +++ b/homeassistant/components/hortimax/entity.py @@ -0,0 +1,51 @@ +"""Base entity for the Ridder HortiMaX Pro (HortOS) integration.""" + +from aiohortos import Readout + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import HortimaxCoordinator, source_key + + +class HortimaxEntity(CoordinatorEntity[HortimaxCoordinator]): + """An entity backed by one readout of a HortOS source. + + Sources (a weather station, a ventilation group, ...) each become their own + device, linked to their controller through ``via_device``. + """ + + _attr_has_entity_name = True + + def __init__( + self, coordinator: HortimaxCoordinator, device_id: str, key: str + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._device_id = device_id + self._key = key + + readout = coordinator.data[device_id].readouts[key] + source = readout.source + self._attr_unique_id = f"{device_id}::{key}" + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, f"{device_id}::{source_key(source.type, source.name)}") + }, + name=coordinator.data[device_id].source_names.get( + source_key(source.type, source.name), source.display_name + ), + model=source.type, + manufacturer=MANUFACTURER, + via_device=(DOMAIN, device_id), + ) + + @property + def readout(self) -> Readout | None: + """Return the current readout, or None once the controller drops it. + + A reachable controller that stops reporting one readout leaves the + entity unknown, not unavailable. + """ + return self.coordinator.data[self._device_id].readouts.get(self._key) diff --git a/homeassistant/components/hortimax/manifest.json b/homeassistant/components/hortimax/manifest.json new file mode 100644 index 00000000000000..3a71032e64b60b --- /dev/null +++ b/homeassistant/components/hortimax/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "hortimax", + "name": "Ridder HortiMaX Pro", + "codeowners": ["@wildekek"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/hortimax", + "integration_type": "hub", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["aiohortos==0.3.1"] +} diff --git a/homeassistant/components/hortimax/quality_scale.yaml b/homeassistant/components/hortimax/quality_scale.yaml new file mode 100644 index 00000000000000..cf83fdc79e7250 --- /dev/null +++ b/homeassistant/components/hortimax/quality_scale.yaml @@ -0,0 +1,106 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not provide additional actions. + docs-conditions: + status: exempt + comment: This integration does not provide additional conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not provide additional triggers. + entity-event-setup: + status: exempt + comment: | + The entities of this integration do not subscribe to events; they read + from the coordinator. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: This integration does not provide additional actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration does not provide an options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery: + status: exempt + comment: | + The cloud API has no presence on the local network. The on-premise + deployment runs on a Ridder Connect Box, which is configured with a + static IP and advertises no mDNS service, so it turns up in neither + mDNS nor DHCP discovery. + discovery-update-info: + status: exempt + comment: | + Nothing is discovered, so there is no discovery information to keep up + to date. + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: + status: todo + comment: | + Sources that appear on a known controller do become devices during + normal updates, but the controller list itself is only read once + during setup, so a newly added controller needs a reload. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: + status: todo + comment: | + Entity names are derived at runtime from the readout identifier. The + identifiers are Ridder's rather than the grower's, since custom sensors + are not exposed through the API, so a translation key per readout subject + is possible. The derived name stays as the fallback for readouts a later + firmware adds. + exception-translations: done + icon-translations: + status: todo + comment: | + Icons are chosen per readout subject at runtime, on the same fixed set of + subjects as entity-translations, so they can move to icons.json with it. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No known use cases for repair issues or flows yet. + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/hortimax/sensor.py b/homeassistant/components/hortimax/sensor.py new file mode 100644 index 00000000000000..808255d831da16 --- /dev/null +++ b/homeassistant/components/hortimax/sensor.py @@ -0,0 +1,205 @@ +"""Sensor platform: one sensor per HortOS readout.""" + +from dataclasses import replace +from datetime import datetime, timedelta +from math import isfinite +from typing import override + +from aiohortos import ( + Readout, + ReadoutValueType, + decode_cardinal_wind_direction, + readout_display_name, + readout_subject, +) + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + DEGREE, + EntityCategory, + UnitOfRatio, + UnitOfSpeed, + UnitOfVolume, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util + +from .const import ( + DIMENSIONLESS_UNITS, + READOUT_ICONS, + SECONDS_PER_DAY, + TIME_OF_DAY_READOUTS, + UNIT_DESCRIPTIONS, + UNIT_MAP, + WIND_DIRECTION_SUBJECT, +) +from .coordinator import HortimaxConfigEntry, HortimaxCoordinator +from .entity import HortimaxEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HortimaxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up one sensor per readout, adding new ones as they appear.""" + coordinator = entry.runtime_data + known: set[tuple[str, str]] = set() + + @callback + def _add_new_entities() -> None: + new_entities: list[HortimaxReadoutSensor] = [] + for device_id, device_data in coordinator.data.items(): + for key in device_data.readouts: + if (device_id, key) in known: + continue + known.add((device_id, key)) + new_entities.append(HortimaxReadoutSensor(coordinator, device_id, key)) + if new_entities: + async_add_entities(new_entities) + + _add_new_entities() + entry.async_on_unload(coordinator.async_add_listener(_add_new_entities)) + + +def _describe(readout: Readout) -> SensorEntityDescription: + """Derive the description of the sensor for a readout. + + A device class is only assigned to a mapped unit: Home Assistant rejects + values whose unit does not match the class. Dimensionless readouts are + status codes, so they get no state class and integer display. + """ + key = readout.identifier + if readout.value_type is not ReadoutValueType.DOUBLE: + return SensorEntityDescription(key=key) + + subject = readout_subject(readout.identifier) + if subject in TIME_OF_DAY_READOUTS: + return SensorEntityDescription( + key=key, device_class=SensorDeviceClass.TIMESTAMP + ) + # MEASUREMENT_ANGLE gives statistics the circular mean. + if subject == WIND_DIRECTION_SUBJECT: + return SensorEntityDescription( + key=key, + native_unit_of_measurement=DEGREE, + device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + ) + + raw_unit = readout.unit + if not raw_unit or raw_unit in DIMENSIONLESS_UNITS: + return SensorEntityDescription(key=key, suggested_display_precision=0) + + unit = UNIT_MAP.get(raw_unit) + if unit is None: + # Truthful, but rules out a device class and any basis for a precision. + return SensorEntityDescription( + key=key, + native_unit_of_measurement=raw_unit, + state_class=SensorStateClass.MEASUREMENT, + ) + + description = UNIT_DESCRIPTIONS[unit] + identifier = readout.identifier.lower() + device_class = description.device_class + if device_class is None: + if unit == "%" and "relativehumidity" in identifier: + device_class = SensorDeviceClass.HUMIDITY + elif unit == UnitOfRatio.PARTS_PER_MILLION and ( + "co2" in identifier or "carbondioxide" in identifier + ): + # ppm is a generic concentration; only claim CO2 when the + # readout says so, since growers define their own. + device_class = SensorDeviceClass.CO2 + elif unit == UnitOfSpeed.METERS_PER_SECOND: + device_class = ( + SensorDeviceClass.WIND_SPEED + if "wind" in identifier + else SensorDeviceClass.SPEED + ) + elif unit == UnitOfVolume.CUBIC_METERS and readout.source.type == "GasMeter": + device_class = SensorDeviceClass.GAS + + state_class: SensorStateClass | None + if device_class in (SensorDeviceClass.ENERGY, SensorDeviceClass.GAS): + # Core rejects MEASUREMENT for these. Daily counters reset at midnight, + # which TOTAL_INCREASING handles and the energy dashboard needs; any + # other meter readout has unknown cumulative semantics. + state_class = ( + SensorStateClass.TOTAL_INCREASING + if "consumptiontoday" in identifier + else None + ) + else: + state_class = SensorStateClass.MEASUREMENT + + return replace( + description, key=key, device_class=device_class, state_class=state_class + ) + + +class HortimaxReadoutSensor(HortimaxEntity, SensorEntity): + """A single readout (measurement) from a HortOS source.""" + + def __init__( + self, coordinator: HortimaxCoordinator, device_id: str, key: str + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, device_id, key) + readout = coordinator.data[device_id].readouts[key] + self._attr_name = readout_display_name(readout.identifier) + self._attr_icon = READOUT_ICONS.get(readout_subject(readout.identifier)) + # Settings are diagnostics, so measurements stand out on the device. + if readout.identifier.lower().endswith("-actualsetting"): + self._attr_entity_category = EntityCategory.DIAGNOSTIC + + self.entity_description = _describe(readout) + + # A controller reports hundreds of unclassifiable status codes, so + # those start disabled. A state class means a real measurement, which + # stays enabled even without a device class for its unit. + if ( + self.entity_description.device_class is None + and self._attr_icon is None + and self.entity_description.state_class is None + ): + self._attr_entity_registry_enabled_default = False + + @property + @override + def native_value(self) -> float | str | datetime | None: + """Return the value of the readout.""" + if (readout := self.readout) is None or readout.value is None: + return None + if readout.value_type is not ReadoutValueType.DOUBLE: + return str(readout.value) + try: + number = float(readout.value) + except TypeError, ValueError: + return None + # float() and the JSON parser both accept NaN and Infinity, which + # numeric sensors reject and timedelta() cannot convert. + if not isfinite(number): + return None + subject = readout_subject(readout.identifier) + if subject in TIME_OF_DAY_READOUTS: + # Seconds since midnight at the controller. HortOS reports no + # timezone, so this assumes it shares Home Assistant's. Anything + # outside the day is not a time of day, and would either land on + # another date or overflow timedelta(). + if not 0 <= number < SECONDS_PER_DAY: + return None + return dt_util.start_of_local_day() + timedelta(seconds=number) + if subject == WIND_DIRECTION_SUBJECT: + # Returns None for ids outside the known enumeration block. + return decode_cardinal_wind_direction(number) + return number diff --git a/homeassistant/components/hortimax/strings.json b/homeassistant/components/hortimax/strings.json new file mode 100644 index 00000000000000..1b4357b5e70c42 --- /dev/null +++ b/homeassistant/components/hortimax/strings.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "no_devices": "Authentication succeeded, but no controllers are available for this API key", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "The API key that gives access to the controllers of your organization." + }, + "description": "Enter the API key for the Ridder HortOS Automation API. You can request one from your Ridder account manager.", + "title": "Connect to Ridder HortOS" + } + } + }, + "exceptions": { + "cannot_connect": { + "message": "Error talking to the HortOS API: {error}" + }, + "invalid_auth": { + "message": "The HortOS API rejected the API key" + }, + "no_devices": { + "message": "The HortOS API reported no controllers for this API key" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6dc4879c1433a2..023861ceac56a3 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -335,6 +335,7 @@ "homeworks", "honeywell", "honeywell_string_lights", + "hortimax", "hr_energy_qube", "html5", "huawei_lte", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 43dc497fdc2b93..7e7719466d3419 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3058,6 +3058,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "hortimax": { + "name": "Ridder HortiMaX Pro", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "hp_ilo": { "name": "HP Integrated Lights-Out (ILO)", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 0e446c8b3f261f..34a6993a73c2a6 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2597,6 +2597,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.hortimax.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.hr_energy_qube.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 6d5bd50b41da77..f3815c30364290 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -296,6 +296,9 @@ aiohomeconnect==0.38.0 # homeassistant.components.homekit_controller aiohomekit==4.0.0 +# homeassistant.components.hortimax +aiohortos==0.3.1 + # homeassistant.components.mcp_server aiohttp_sse==2.2.0 diff --git a/tests/components/hortimax/__init__.py b/tests/components/hortimax/__init__.py new file mode 100644 index 00000000000000..b7491a7336e046 --- /dev/null +++ b/tests/components/hortimax/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the Ridder HortiMaX Pro (HortOS) integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the integration from a mock config entry.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/hortimax/conftest.py b/tests/components/hortimax/conftest.py new file mode 100644 index 00000000000000..aae20152f2ab3d --- /dev/null +++ b/tests/components/hortimax/conftest.py @@ -0,0 +1,77 @@ +"""Fixtures for the Ridder HortiMaX Pro (HortOS) tests.""" + +from collections.abc import Generator +from datetime import UTC, datetime, timedelta +import json +from unittest.mock import AsyncMock, patch + +from aiohortos import Device, Organisation, Readout, TokenPair +import pytest + +from homeassistant.components.hortimax.const import DOMAIN +from homeassistant.const import CONF_API_KEY + +from tests.common import MockConfigEntry, load_fixture + +API_KEY = "test-api-key" +DEVICE = "HOR00000000.000" +DEVICE_LABEL = "Greenhouse Multima" +ORGANISATION_ID = "9006" + + +def load_readouts() -> list[Readout]: + """Return the fixture readouts, parsed the way the library parses them.""" + return [ + readout + for raw in json.loads(load_fixture("readouts.json", DOMAIN)) + if (readout := Readout.from_api(raw)) is not None + ] + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.hortimax.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Ridder HortiMaX Pro", + data={CONF_API_KEY: API_KEY}, + unique_id=ORGANISATION_ID, + ) + + +@pytest.fixture +def mock_hortos_client() -> Generator[AsyncMock]: + """Return a mocked HortOS client, shared by both of its import sites.""" + now = datetime(2026, 6, 12, 8, 0, tzinfo=UTC) + with ( + patch( + "homeassistant.components.hortimax.HortosClient", autospec=True + ) as mock_client, + patch( + "homeassistant.components.hortimax.config_flow.HortosClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.authenticate.return_value = TokenPair( + token="token", + expires_at=now + timedelta(minutes=15), + refresh_token="refresh-token", + refresh_expires_at=now + timedelta(days=7), + organisation=Organisation(id=ORGANISATION_ID, name="Test organisation"), + ) + client.get_device_names.return_value = [DEVICE] + client.get_devices.return_value = [ + Device(name=DEVICE, label=DEVICE_LABEL, public_id="public-id") + ] + client.get_latest_readouts.return_value = load_readouts() + yield client diff --git a/tests/components/hortimax/fixtures/readouts.json b/tests/components/hortimax/fixtures/readouts.json new file mode 100644 index 00000000000000..eeaf376ac6b49e --- /dev/null +++ b/tests/components/hortimax/fixtures/readouts.json @@ -0,0 +1,348 @@ +[ + { + "name": "Outside temperature (Weerstation )", + "readoutIdentifier": "OutsideTemperature-Measured", + "readoutValueType": "Double", + "unitIdentifier": "DegreeCelsius", + "device": "HOR00000000.000", + "source": { + "sourceName": "Weather station 001", + "sourceType": "WeatherStation", + "userDefinedName": "Weerstation ", + "sourceGroups": ["Weather"] + }, + "values": [ + { + "timestampUTC": "2026-06-12T07:55:00Z", + "value": 17.9 + }, + { + "timestampUTC": "2026-06-12T08:00:00Z", + "value": 18.203125 + } + ] + }, + { + "name": "Relative humidity (Weerstation )", + "readoutIdentifier": "RelativeHumidity-Measured", + "readoutValueType": "Double", + "unitIdentifier": "Percent", + "device": "HOR00000000.000", + "source": { + "sourceName": "Weather station 001", + "sourceType": "WeatherStation", + "userDefinedName": "Weerstation ", + "sourceGroups": ["Weather"] + }, + "values": [ + { + "timestampUTC": "2026-06-12T08:00:00Z", + "value": 90.15303039550781 + } + ] + }, + { + "name": "Wind speed (Weerstation )", + "readoutIdentifier": "WindSpeed-Measured", + "readoutValueType": "Double", + "unitIdentifier": "Meter/Second", + "device": "HOR00000000.000", + "source": { + "sourceName": "Weather station 001", + "sourceType": "WeatherStation", + "userDefinedName": "Weerstation ", + "sourceGroups": ["Weather"] + }, + "values": [ + { + "timestampUTC": "2026-06-12T08:00:00Z", + "value": 3.5 + } + ] + }, + { + "name": "Cardinal wind direction (Weerstation )", + "readoutIdentifier": "CardinalWindDirection-Measured", + "readoutValueType": "Double", + "unitIdentifier": "Scalar", + "device": "HOR00000000.000", + "source": { + "sourceName": "Weather station 001", + "sourceType": "WeatherStation", + "userDefinedName": "Weerstation ", + "sourceGroups": ["Weather"] + }, + "values": [ + { + "timestampUTC": "2026-06-12T08:00:00Z", + "value": 8783 + } + ] + }, + { + "name": "Sunrise today (Weerstation )", + "readoutIdentifier": "SunriseToday-Measured", + "readoutValueType": "Double", + "unitIdentifier": "Second", + "device": "HOR00000000.000", + "source": { + "sourceName": "Weather station 001", + "sourceType": "WeatherStation", + "userDefinedName": "Weerstation ", + "sourceGroups": ["Weather"] + }, + "values": [ + { + "timestampUTC": "2026-06-12T08:00:00Z", + "value": 19145 + } + ] + }, + { + "name": "Absolute humidity (Weerstation )", + "readoutIdentifier": "AbsoluteHumidity-Measured", + "readoutValueType": "Double", + "unitIdentifier": "Gram/Kilogram", + "device": "HOR00000000.000", + "source": { + "sourceName": "Weather station 001", + "sourceType": "WeatherStation", + "userDefinedName": "Weerstation ", + "sourceGroups": ["Weather"] + }, + "values": [ + { + "timestampUTC": "2026-06-12T08:00:00Z", + "value": 9.4 + } + ] + }, + { + "name": "Weather status (Weerstation )", + "readoutIdentifier": "WeatherStatus-Measured", + "readoutValueType": "Double", + "unitIdentifier": "Scalar", + "device": "HOR00000000.000", + "source": { + "sourceName": "Weather station 001", + "sourceType": "WeatherStation", + "userDefinedName": "Weerstation ", + "sourceGroups": ["Weather"] + }, + "values": [ + { + "timestampUTC": "2026-06-12T08:00:00Z", + "value": 8789 + } + ] + }, + { + "name": "Gas consumption today", + "readoutIdentifier": "GasConsumptionToday-Measured", + "readoutValueType": "Double", + "unitIdentifier": "CubicMeter", + "device": "HOR00000000.000", + "source": { + "sourceName": "Gas meter 001", + "sourceType": "GasMeter", + "userDefinedName": null, + "sourceGroups": ["Energy"] + }, + "values": [ + { + "timestampUTC": "2026-06-12T08:00:00Z", + "value": 42.5 + } + ] + }, + { + "name": "Gas consumption total", + "readoutIdentifier": "GasConsumptionTotal-Measured", + "readoutValueType": "Double", + "unitIdentifier": "CubicMeter", + "device": "HOR00000000.000", + "source": { + "sourceName": "Gas meter 001", + "sourceType": "GasMeter", + "userDefinedName": null, + "sourceGroups": ["Energy"] + }, + "values": [ + { + "timestampUTC": "2026-06-12T08:00:00Z", + "value": 128341.0 + } + ] + }, + { + "name": "Energy consumption today", + "readoutIdentifier": "EnergyConsumptionToday-Measured", + "readoutValueType": "Double", + "unitIdentifier": "KilowattHour", + "device": "HOR00000000.000", + "source": { + "sourceName": "Electricity meter 001", + "sourceType": "ElectricityMeter", + "userDefinedName": null, + "sourceGroups": ["Energy"] + }, + "values": [ + { + "timestampUTC": "2026-06-12T08:00:00Z", + "value": 128.25 + } + ] + }, + { + "name": "Screen position (OV1 Tropen)", + "readoutIdentifier": "ScreenPosition-Measured", + "readoutValueType": "Double", + "unitIdentifier": "Percent", + "device": "HOR00000000.000", + "source": { + "sourceName": "Screen 001", + "sourceType": "Screen", + "userDefinedName": "OV1 Tropen", + "sourceGroups": ["Climate"] + }, + "values": [ + { + "timestampUTC": "2026-06-12T08:00:00Z", + "value": 62.5 + } + ] + }, + { + "name": "Screen status (OV1 Tropen)", + "readoutIdentifier": "ScreenStatus-Measured", + "readoutValueType": "String", + "unitIdentifier": null, + "device": "HOR00000000.000", + "source": { + "sourceName": "Screen 001", + "sourceType": "Screen", + "userDefinedName": "OV1 Tropen", + "sourceGroups": ["Climate"] + }, + "values": [ + { + "timestampUTC": "2026-06-12T08:00:00Z", + "value": "Closing" + } + ] + }, + { + "name": "Vent position leeward side (OV1 Tropen)", + "readoutIdentifier": "VentPositionLeewardSide-Measured", + "readoutValueType": "Double", + "unitIdentifier": "Percent", + "device": "HOR00000000.000", + "source": { + "sourceName": "Ventilation group 001", + "sourceType": "VentilationGroup", + "userDefinedName": "OV1 Tropen", + "sourceGroups": ["Climate"] + }, + "values": [ + { + "timestampUTC": "2026-06-12T08:00:00Z", + "value": 12.0 + } + ] + }, + { + "name": "Maximum pipe temperature (OV1 Tropen)", + "readoutIdentifier": "MaximumPipeTemperature-ActualSetting", + "readoutValueType": "Double", + "unitIdentifier": "DegreeCelsius", + "device": "HOR00000000.000", + "source": { + "sourceName": "Ventilation group 001", + "sourceType": "VentilationGroup", + "userDefinedName": "OV1 Tropen", + "sourceGroups": ["Climate"] + }, + "values": [ + { + "timestampUTC": "2026-06-12T08:00:00Z", + "value": 35.0 + } + ] + }, + { + "name": "Irrigation volume", + "readoutIdentifier": "IrrigationVolume-Measuered", + "readoutValueType": "Double", + "unitIdentifier": "Liter/SquareMeter", + "device": "HOR00000000.000", + "source": { + "sourceName": "Valve group 003", + "sourceType": "IrrigationValveGroup", + "userDefinedName": null, + "sourceGroups": ["Irrigation"] + }, + "values": [ + { + "timestampUTC": "2026-06-12T08:00:00Z", + "value": 1.75 + } + ] + }, + { + "name": "Substrate conductivity", + "readoutIdentifier": "SubstrateConductivity-Measured", + "readoutValueType": "Double", + "unitIdentifier": "Furlong/Fortnight", + "device": "HOR00000000.000", + "source": { + "sourceName": "Valve group 003", + "sourceType": "IrrigationValveGroup", + "userDefinedName": null, + "sourceGroups": ["Irrigation"] + }, + "values": [ + { + "timestampUTC": "2026-06-12T08:00:00Z", + "value": 2.4 + } + ] + }, + { + "name": "CO2 level", + "readoutIdentifier": "CO2Level-Measured", + "readoutValueType": "Double", + "unitIdentifier": "PartsPerMillion", + "device": "HOR00000000.000", + "source": { + "sourceName": "Block 001", + "sourceType": "Block", + "userDefinedName": "Kas 1", + "sourceGroups": ["Block 001"] + }, + "values": [ + { + "timestampUTC": "2026-06-12T08:00:00Z", + "value": 812.0 + } + ] + }, + { + "name": "Nutrient concentration", + "readoutIdentifier": "NutrientConcentration-Measured", + "readoutValueType": "Double", + "unitIdentifier": "PartsPerMillion", + "device": "HOR00000000.000", + "source": { + "sourceName": "Block 001", + "sourceType": "Block", + "userDefinedName": "Kas 1", + "sourceGroups": ["Block 001"] + }, + "values": [ + { + "timestampUTC": "2026-06-12T08:00:00Z", + "value": 145.0 + } + ] + } +] diff --git a/tests/components/hortimax/snapshots/test_sensor.ambr b/tests/components/hortimax/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..430df5046b1f36 --- /dev/null +++ b/tests/components/hortimax/snapshots/test_sensor.ambr @@ -0,0 +1,1014 @@ +# serializer version: 1 +# name: test_all_entities[sensor.electricity_meter_001_energy_consumption_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.electricity_meter_001_energy_consumption_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy consumption today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy consumption today', + 'platform': 'hortimax', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HOR00000000.000::ElectricityMeter::Electricity meter 001::EnergyConsumptionToday-Measured', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.electricity_meter_001_energy_consumption_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Electricity meter 001 Energy consumption today', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.electricity_meter_001_energy_consumption_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '128.25', + }) +# --- +# name: test_all_entities[sensor.gas_meter_001_gas_consumption_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gas_meter_001_gas_consumption_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Gas consumption today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Gas consumption today', + 'platform': 'hortimax', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HOR00000000.000::GasMeter::Gas meter 001::GasConsumptionToday-Measured', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.gas_meter_001_gas_consumption_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'gas', + : 'Gas meter 001 Gas consumption today', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.gas_meter_001_gas_consumption_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42.5', + }) +# --- +# name: test_all_entities[sensor.gas_meter_001_gas_consumption_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gas_meter_001_gas_consumption_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Gas consumption total', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Gas consumption total', + 'platform': 'hortimax', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HOR00000000.000::GasMeter::Gas meter 001::GasConsumptionTotal-Measured', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.gas_meter_001_gas_consumption_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'gas', + : 'Gas meter 001 Gas consumption total', + : , + }), + 'context': , + 'entity_id': 'sensor.gas_meter_001_gas_consumption_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '128341.0', + }) +# --- +# name: test_all_entities[sensor.kas_1_co2_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kas_1_co2_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'CO2 level', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CO2 level', + 'platform': 'hortimax', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HOR00000000.000::Block::Block 001::CO2Level-Measured', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.kas_1_co2_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'carbon_dioxide', + : 'Kas 1 CO2 level', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.kas_1_co2_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '812.0', + }) +# --- +# name: test_all_entities[sensor.kas_1_nutrient_concentration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kas_1_nutrient_concentration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Nutrient concentration', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Nutrient concentration', + 'platform': 'hortimax', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HOR00000000.000::Block::Block 001::NutrientConcentration-Measured', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.kas_1_nutrient_concentration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Kas 1 Nutrient concentration', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.kas_1_nutrient_concentration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '145.0', + }) +# --- +# name: test_all_entities[sensor.ov1_tropen_screen_screen_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ov1_tropen_screen_screen_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Screen position', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Screen position', + 'platform': 'hortimax', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HOR00000000.000::Screen::Screen 001::ScreenPosition-Measured', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.ov1_tropen_screen_screen_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'OV1 Tropen screen Screen position', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.ov1_tropen_screen_screen_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '62.5', + }) +# --- +# name: test_all_entities[sensor.ov1_tropen_screen_screen_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ov1_tropen_screen_screen_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Screen status', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Screen status', + 'platform': 'hortimax', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HOR00000000.000::Screen::Screen 001::ScreenStatus-Measured', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.ov1_tropen_screen_screen_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'OV1 Tropen screen Screen status', + }), + 'context': , + 'entity_id': 'sensor.ov1_tropen_screen_screen_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Closing', + }) +# --- +# name: test_all_entities[sensor.ov1_tropen_ventilation_group_maximum_pipe_temperature_actual_setting-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ov1_tropen_ventilation_group_maximum_pipe_temperature_actual_setting', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Maximum pipe temperature (actual setting)', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maximum pipe temperature (actual setting)', + 'platform': 'hortimax', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HOR00000000.000::VentilationGroup::Ventilation group 001::MaximumPipeTemperature-ActualSetting', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.ov1_tropen_ventilation_group_maximum_pipe_temperature_actual_setting-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'OV1 Tropen ventilation group Maximum pipe temperature (actual setting)', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.ov1_tropen_ventilation_group_maximum_pipe_temperature_actual_setting', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35.0', + }) +# --- +# name: test_all_entities[sensor.ov1_tropen_ventilation_group_vent_position_leeward_side-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ov1_tropen_ventilation_group_vent_position_leeward_side', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Vent position leeward side', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Vent position leeward side', + 'platform': 'hortimax', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HOR00000000.000::VentilationGroup::Ventilation group 001::VentPositionLeewardSide-Measured', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.ov1_tropen_ventilation_group_vent_position_leeward_side-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'OV1 Tropen ventilation group Vent position leeward side', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.ov1_tropen_ventilation_group_vent_position_leeward_side', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.0', + }) +# --- +# name: test_all_entities[sensor.valve_group_003_irrigation_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.valve_group_003_irrigation_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Irrigation volume', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Irrigation volume', + 'platform': 'hortimax', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HOR00000000.000::IrrigationValveGroup::Valve group 003::IrrigationVolume-Measuered', + 'unit_of_measurement': 'l/m²', + }) +# --- +# name: test_all_entities[sensor.valve_group_003_irrigation_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Valve group 003 Irrigation volume', + : , + : 'l/m²', + }), + 'context': , + 'entity_id': 'sensor.valve_group_003_irrigation_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.75', + }) +# --- +# name: test_all_entities[sensor.valve_group_003_substrate_conductivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.valve_group_003_substrate_conductivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Substrate conductivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Substrate conductivity', + 'platform': 'hortimax', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HOR00000000.000::IrrigationValveGroup::Valve group 003::SubstrateConductivity-Measured', + 'unit_of_measurement': 'Furlong/Fortnight', + }) +# --- +# name: test_all_entities[sensor.valve_group_003_substrate_conductivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Valve group 003 Substrate conductivity', + : , + : 'Furlong/Fortnight', + }), + 'context': , + 'entity_id': 'sensor.valve_group_003_substrate_conductivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.4', + }) +# --- +# name: test_all_entities[sensor.weerstation_absolute_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.weerstation_absolute_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Absolute humidity', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': 'mdi:water-opacity', + 'original_name': 'Absolute humidity', + 'platform': 'hortimax', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HOR00000000.000::WeatherStation::Weather station 001::AbsoluteHumidity-Measured', + 'unit_of_measurement': 'g/kg', + }) +# --- +# name: test_all_entities[sensor.weerstation_absolute_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Weerstation Absolute humidity', + : 'mdi:water-opacity', + : , + : 'g/kg', + }), + 'context': , + 'entity_id': 'sensor.weerstation_absolute_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.4', + }) +# --- +# name: test_all_entities[sensor.weerstation_cardinal_wind_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.weerstation_cardinal_wind_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cardinal wind direction', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cardinal wind direction', + 'platform': 'hortimax', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HOR00000000.000::WeatherStation::Weather station 001::CardinalWindDirection-Measured', + 'unit_of_measurement': '°', + }) +# --- +# name: test_all_entities[sensor.weerstation_cardinal_wind_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'wind_direction', + : 'Weerstation Cardinal wind direction', + : , + : '°', + }), + 'context': , + 'entity_id': 'sensor.weerstation_cardinal_wind_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '247.5', + }) +# --- +# name: test_all_entities[sensor.weerstation_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.weerstation_outside_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Outside temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outside temperature', + 'platform': 'hortimax', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HOR00000000.000::WeatherStation::Weather station 001::OutsideTemperature-Measured', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.weerstation_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Weerstation Outside temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.weerstation_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.203125', + }) +# --- +# name: test_all_entities[sensor.weerstation_relative_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.weerstation_relative_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Relative humidity', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Relative humidity', + 'platform': 'hortimax', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HOR00000000.000::WeatherStation::Weather station 001::RelativeHumidity-Measured', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.weerstation_relative_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'humidity', + : 'Weerstation Relative humidity', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.weerstation_relative_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90.1530303955078', + }) +# --- +# name: test_all_entities[sensor.weerstation_sunrise_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.weerstation_sunrise_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sunrise today', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sunrise today', + 'platform': 'hortimax', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HOR00000000.000::WeatherStation::Weather station 001::SunriseToday-Measured', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.weerstation_sunrise_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'Weerstation Sunrise today', + }), + 'context': , + 'entity_id': 'sensor.weerstation_sunrise_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-06-12T12:19:05+00:00', + }) +# --- +# name: test_all_entities[sensor.weerstation_weather_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.weerstation_weather_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Weather status', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Weather status', + 'platform': 'hortimax', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HOR00000000.000::WeatherStation::Weather station 001::WeatherStatus-Measured', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.weerstation_weather_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Weerstation Weather status', + }), + 'context': , + 'entity_id': 'sensor.weerstation_weather_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8789.0', + }) +# --- +# name: test_all_entities[sensor.weerstation_wind_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.weerstation_wind_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wind speed', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed', + 'platform': 'hortimax', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HOR00000000.000::WeatherStation::Weather station 001::WindSpeed-Measured', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.weerstation_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'wind_speed', + : 'Weerstation Wind speed', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.weerstation_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.6', + }) +# --- diff --git a/tests/components/hortimax/test_config_flow.py b/tests/components/hortimax/test_config_flow.py new file mode 100644 index 00000000000000..5654e5e35d9f65 --- /dev/null +++ b/tests/components/hortimax/test_config_flow.py @@ -0,0 +1,141 @@ +"""Test the Ridder HortiMaX Pro (HortOS) config flow.""" + +from unittest.mock import AsyncMock + +from aiohortos import HortosAuthenticationError, HortosConnectionError, Organisation +import pytest + +from homeassistant.components.hortimax.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import API_KEY, ORGANISATION_ID + +from tests.common import MockConfigEntry + +USER_INPUT = {CONF_API_KEY: API_KEY} + + +@pytest.mark.usefixtures("mock_hortos_client", "mock_setup_entry") +async def test_full_flow(hass: HomeAssistant) -> None: + """Test the happy path creates an entry keyed on the organisation.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Ridder HortiMaX Pro" + assert result["data"] == USER_INPUT + assert result["result"].unique_id == ORGANISATION_ID + + +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (HortosAuthenticationError("nope"), "invalid_auth"), + (HortosConnectionError("boom"), "cannot_connect"), + (RuntimeError("surprise"), "unknown"), + ], +) +async def test_errors_recover( + hass: HomeAssistant, + mock_hortos_client: AsyncMock, + side_effect: Exception, + error: str, +) -> None: + """Test every error is shown and the flow can still be completed.""" + mock_hortos_client.authenticate.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_hortos_client.authenticate.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_no_devices_recovers( + hass: HomeAssistant, mock_hortos_client: AsyncMock +) -> None: + """Test an API key without controllers is rejected.""" + mock_hortos_client.get_device_names.return_value = [] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "no_devices"} + + mock_hortos_client.get_device_names.return_value = ["HOR00000000.000"] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_missing_organisation_is_an_error( + hass: HomeAssistant, mock_hortos_client: AsyncMock +) -> None: + """Test an entry is never created without the id it is keyed on.""" + tokens = mock_hortos_client.authenticate.return_value + mock_hortos_client.authenticate.return_value = type(tokens)( + token=tokens.token, + expires_at=tokens.expires_at, + refresh_token=tokens.refresh_token, + refresh_expires_at=tokens.refresh_expires_at, + organisation=Organisation(id=None), + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + +@pytest.mark.usefixtures("mock_hortos_client", "mock_setup_entry") +async def test_duplicate_organisation_aborts( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the same organisation cannot be configured twice.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/hortimax/test_init.py b/tests/components/hortimax/test_init.py new file mode 100644 index 00000000000000..8678f266bfcf21 --- /dev/null +++ b/tests/components/hortimax/test_init.py @@ -0,0 +1,155 @@ +"""Test setting up and tearing down the Ridder HortiMaX Pro integration.""" + +from dataclasses import replace +from datetime import timedelta +from unittest.mock import AsyncMock + +from aiohortos import HortosAuthenticationError, HortosConnectionError +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.hortimax.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 +from .conftest import DEVICE, DEVICE_LABEL, load_readouts + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.usefixtures("mock_hortos_client") +async def test_load_and_unload( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the entry loads and unloads cleanly.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_connection_error_retries( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hortos_client: AsyncMock, +) -> None: + """Test an unreachable API leaves the entry in a retry state.""" + mock_hortos_client.get_devices.side_effect = HortosConnectionError("boom") + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_no_controllers_retries( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hortos_client: AsyncMock, +) -> None: + """Test an entry whose controllers have gone retries instead of loading empty.""" + mock_hortos_client.get_devices.return_value = [] + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert not hass.states.async_entity_ids("sensor") + + +async def test_auth_error_sets_error_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hortos_client: AsyncMock, +) -> None: + """Test a rejected API key leaves the entry in an error state.""" + mock_hortos_client.get_devices.side_effect = HortosAuthenticationError("nope") + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_readout_auth_error_sets_error_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hortos_client: AsyncMock, +) -> None: + """Test a key rejected while reading values also errors the entry.""" + mock_hortos_client.get_latest_readouts.side_effect = HortosAuthenticationError( + "nope" + ) + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +@pytest.mark.usefixtures("mock_hortos_client") +async def test_renamed_source_follows( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hortos_client: AsyncMock, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test renaming a source in HortiMaX Pro renames its device.""" + await setup_integration(hass, mock_config_entry) + identifiers = {(DOMAIN, f"{DEVICE}::WeatherStation::Weather station 001")} + assert ( + device_registry.async_get_device(identifiers=identifiers).name == "Weerstation" + ) + + readouts = load_readouts() + mock_hortos_client.get_latest_readouts.return_value = [ + replace(readout, source=replace(readout.source, user_defined_name="Weerhuisje")) + for readout in readouts + if readout.source.type == "WeatherStation" + ] + [readout for readout in readouts if readout.source.type != "WeatherStation"] + freezer.tick(timedelta(minutes=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + device_registry.async_get_device(identifiers=identifiers).name == "Weerhuisje" + ) + + +@pytest.mark.usefixtures("mock_hortos_client") +async def test_devices( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the controller and its sources become linked devices.""" + await setup_integration(hass, mock_config_entry) + + controller = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE)}) + assert controller is not None + assert controller.name == DEVICE_LABEL + assert controller.manufacturer == "Ridder" + assert controller.via_device_id is None + + weather_station = device_registry.async_get_device( + identifiers={(DOMAIN, f"{DEVICE}::WeatherStation::Weather station 001")} + ) + assert weather_station is not None + # The user-defined name wins, with its trailing whitespace stripped. + assert weather_station.name == "Weerstation" + assert weather_station.model == "WeatherStation" + assert weather_station.via_device_id == controller.id + + # Two sources share the user-defined name 'OV1 Tropen', so both get their + # source type appended. + screen = device_registry.async_get_device( + identifiers={(DOMAIN, f"{DEVICE}::Screen::Screen 001")} + ) + ventilation = device_registry.async_get_device( + identifiers={(DOMAIN, f"{DEVICE}::VentilationGroup::Ventilation group 001")} + ) + assert screen is not None + assert ventilation is not None + assert screen.name == "OV1 Tropen screen" + assert ventilation.name == "OV1 Tropen ventilation group" diff --git a/tests/components/hortimax/test_sensor.py b/tests/components/hortimax/test_sensor.py new file mode 100644 index 00000000000000..23f9354b8ba37b --- /dev/null +++ b/tests/components/hortimax/test_sensor.py @@ -0,0 +1,327 @@ +"""Test the Ridder HortiMaX Pro sensor platform.""" + +from datetime import UTC, datetime, timedelta +from math import inf, nan +from unittest.mock import AsyncMock + +from aiohortos import HortosConnectionError, Readout, Source +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .conftest import load_readouts + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +def _sunrise_readout(value: float, sampled_at: datetime | None = None) -> Readout: + """Build a SunriseToday readout, whose value is seconds since midnight.""" + return Readout( + identifier="SunriseToday-Measured", + name="Sunrise today", + unit="Second", + source=Source(name="Weather station 001", type="WeatherStation"), + value=value, + timestamp=sampled_at, + ) + + +@pytest.mark.freeze_time("2026-06-12 12:00:00+00:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_hortos_client") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all sensors of a controller.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("mock_hortos_client") +async def test_unclassified_readouts_are_disabled( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test readouts with no device class, icon or state class are disabled.""" + await setup_integration(hass, mock_config_entry) + + # An enumeration code nobody has decoded yet: no unit, no device class. + weather_status = entity_registry.async_get("sensor.weerstation_weather_status") + assert weather_status is not None + assert weather_status.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + # A classified readout right next to it stays enabled. + temperature = entity_registry.async_get("sensor.weerstation_outside_temperature") + assert temperature is not None + assert temperature.disabled_by is None + + # A measurement Home Assistant has no device class for is still a + # measurement, so it stays enabled on the strength of its state class. + screen = entity_registry.async_get("sensor.ov1_tropen_screen_screen_position") + assert screen is not None + assert screen.disabled_by is None + + conductivity = entity_registry.async_get( + "sensor.valve_group_003_substrate_conductivity" + ) + assert conductivity is not None + assert conductivity.disabled_by is None + + +@pytest.mark.usefixtures("mock_hortos_client") +async def test_new_readouts_are_added( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hortos_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a readout that only shows up later still becomes a sensor.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("sensor.weerstation_2_outside_temperature") is None + + mock_hortos_client.get_latest_readouts.return_value = [ + *load_readouts(), + Readout( + identifier="OutsideTemperature-Measured", + name="Outside temperature (Weerstation 2)", + unit="DegreeCelsius", + source=Source( + name="Weather station 002", + type="WeatherStation", + user_defined_name="Weerstation 2", + ), + value=19.5, + ), + ] + + freezer.tick(timedelta(minutes=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.weerstation_2_outside_temperature") + assert state is not None + assert state.state == "19.5" + + +@pytest.mark.usefixtures("mock_hortos_client") +async def test_update_failure_makes_entities_unavailable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hortos_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test entities go unavailable when a poll fails.""" + await setup_integration(hass, mock_config_entry) + assert ( + hass.states.get("sensor.weerstation_outside_temperature").state == "18.203125" + ) + + mock_hortos_client.get_latest_readouts.side_effect = HortosConnectionError("boom") + freezer.tick(timedelta(minutes=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("sensor.weerstation_outside_temperature").state + == STATE_UNAVAILABLE + ) + + +@pytest.mark.usefixtures("mock_hortos_client") +async def test_disappearing_readout_becomes_unknown( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hortos_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a readout the controller stops reporting is unknown, not unavailable.""" + await setup_integration(hass, mock_config_entry) + + mock_hortos_client.get_latest_readouts.return_value = [ + readout + for readout in load_readouts() + if readout.identifier != "OutsideTemperature-Measured" + ] + freezer.tick(timedelta(minutes=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("sensor.weerstation_outside_temperature").state == STATE_UNKNOWN + ) + + +@pytest.mark.usefixtures("mock_hortos_client") +@pytest.mark.parametrize( + ("code", "expected"), + [ + (8772, "0.0"), # north, the anchor of the block + (8783, "247.5"), # WSW, cross-checked against the official app + (8787, "337.5"), # NNW, the last code of the block + (8771, "unknown"), # just below the block + (8788, "unknown"), # just above it + (0, "unknown"), + # Fractional values are not member ids; rounding them into the block + # would report 8771.6 as due north. + (8771.6, "unknown"), + (8787.4, "unknown"), + # The API sends doubles as strings for some readouts, which every + # other sensor accepts. + ("8783", "247.5"), + ], +) +async def test_wind_direction_codes( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hortos_client: AsyncMock, + code: float | str, + expected: str, +) -> None: + """Test only the documented enum block becomes a bearing.""" + mock_hortos_client.get_latest_readouts.return_value = [ + Readout( + identifier="CardinalWindDirection-Measured", + name="Cardinal wind direction", + unit="Scalar", + source=Source(name="Weather station 001", type="WeatherStation"), + value=code, + ) + ] + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.weather_station_001_cardinal_wind_direction") + assert state is not None + assert state.state == expected + + +@pytest.mark.usefixtures("mock_hortos_client") +@pytest.mark.parametrize( + "value", + [ + pytest.param("n/a", id="text"), + pytest.param(nan, id="nan"), + pytest.param(inf, id="infinity"), + pytest.param("NaN", id="nan_as_text"), + ], +) +async def test_unusable_double_is_unknown( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hortos_client: AsyncMock, + value: float | str, +) -> None: + """Test a numeric readout without a usable value is reported as unknown.""" + mock_hortos_client.get_latest_readouts.return_value = [ + Readout( + identifier="OutsideTemperature-Measured", + name="Outside temperature", + unit="DegreeCelsius", + source=Source(name="Weather station 001", type="WeatherStation"), + value=value, + ) + ] + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.weather_station_001_outside_temperature") + assert state is not None + assert state.state == "unknown" + + +@pytest.mark.usefixtures("mock_hortos_client") +@pytest.mark.parametrize( + "value", + [ + pytest.param(nan, id="nan"), + pytest.param(inf, id="infinity"), + pytest.param(-1.0, id="before_midnight"), + pytest.param(86400.0, id="end_of_day"), + pytest.param(1e20, id="beyond_timedelta"), + ], +) +async def test_time_of_day_outside_the_day_is_unknown( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hortos_client: AsyncMock, + value: float, +) -> None: + """Test a time-of-day readout that is not a time of day never reaches timedelta().""" + mock_hortos_client.get_latest_readouts.return_value = [_sunrise_readout(value)] + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.weather_station_001_sunrise_today") + assert state is not None + assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("mock_hortos_client") +@pytest.mark.parametrize( + ("now", "expected"), + [ + pytest.param( + "2026-06-18 21:59:00+00:00", + "2026-06-18T03:19:05+00:00", + id="before_midnight", + ), + pytest.param( + "2026-06-18 22:01:00+00:00", + "2026-06-19T03:19:05+00:00", + id="after_midnight", + ), + ], +) +async def test_time_of_day_follows_the_local_day( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hortos_client: AsyncMock, + freezer: FrozenDateTimeFactory, + now: str, + expected: str, +) -> None: + """Test the timestamp tracks the current local day, not when it was sampled. + + SunriseToday describes the controller's current day, so an unchanged value + read either side of local midnight belongs to whichever day it is now. The + readout is sampled well before midnight in both cases. + """ + await hass.config.async_set_time_zone("Europe/Amsterdam") + freezer.move_to(now) + mock_hortos_client.get_latest_readouts.return_value = [ + _sunrise_readout(19145.0, sampled_at=datetime(2026, 6, 18, 12, 0, tzinfo=UTC)) + ] + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.weather_station_001_sunrise_today") + assert state is not None + assert state.state == expected + + +@pytest.mark.usefixtures("mock_hortos_client") +async def test_readout_without_a_value( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hortos_client: AsyncMock, +) -> None: + """Test a readout the API reports as null is unknown, not unavailable.""" + mock_hortos_client.get_latest_readouts.return_value = [ + Readout( + identifier="OutsideTemperature-Measured", + name="Outside temperature", + unit="DegreeCelsius", + source=Source(name="Weather station 001", type="WeatherStation"), + value=None, + ) + ] + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.weather_station_001_outside_temperature") + assert state is not None + assert state.state == "unknown" From 1b6e35fddc931bd42a57bccdbc98b21c2093af27 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:20:41 +0200 Subject: [PATCH 15/15] Bump docker/login-action from 4.5.2 to 4.6.0 (#178214) Signed-off-by: dependabot[bot] --- .github/workflows/builder.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 5e3f0b125635a6..81696c4a2d7a58 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@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 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@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.repository_owner }}