From f5dc15ef47aa33afd7018ce4eb7b59c5704304ad Mon Sep 17 00:00:00 2001 From: Ronald van der Meer Date: Fri, 25 Sep 2026 16:03:18 +0200 Subject: [PATCH] Add node identify switches to Duco (#182173) --- homeassistant/components/duco/const.py | 1 + homeassistant/components/duco/coordinator.py | 10 +- homeassistant/components/duco/strings.json | 8 + homeassistant/components/duco/switch.py | 113 +++++++++ .../duco/snapshots/test_switch.ambr | 51 ++++ tests/components/duco/test_switch.py | 220 ++++++++++++++++++ 6 files changed, 401 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/duco/switch.py create mode 100644 tests/components/duco/snapshots/test_switch.ambr create mode 100644 tests/components/duco/test_switch.py diff --git a/homeassistant/components/duco/const.py b/homeassistant/components/duco/const.py index e00cb52b44e1..ac3479257b7c 100644 --- a/homeassistant/components/duco/const.py +++ b/homeassistant/components/duco/const.py @@ -13,6 +13,7 @@ PLATFORMS = [ Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SWITCH, ] SCAN_INTERVAL = timedelta(seconds=10) BOX_NODE_ID = 1 diff --git a/homeassistant/components/duco/coordinator.py b/homeassistant/components/duco/coordinator.py index d458fe274c24..087b8322cbde 100644 --- a/homeassistant/components/duco/coordinator.py +++ b/homeassistant/components/duco/coordinator.py @@ -89,6 +89,12 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]): await self.client.async_set_ventilation_state(node_id, state) await self._async_refresh_node(node_id) + async def async_set_node_identify(self, node_id: int, identify: bool) -> None: + """Set and refresh a node's identify state.""" + async with self._request_lock: + await self.client.async_set_node_identify(node_id, identify) + await self._async_refresh_node(node_id) + async def _async_refresh_node(self, node_id: int) -> None: """Refresh one node while holding the request lock.""" try: @@ -213,9 +219,9 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]): ) _LOGGER.warning( "Could not fetch Duco node actions; %s", - "keeping previous select discovery data" + "keeping previous action discovery data" if previous_data is not None - else "starting with empty select discovery data", + else "starting with empty action discovery data", exc_info=err, ) diff --git a/homeassistant/components/duco/strings.json b/homeassistant/components/duco/strings.json index f0da02e8cd5a..4253d6feffe9 100644 --- a/homeassistant/components/duco/strings.json +++ b/homeassistant/components/duco/strings.json @@ -139,6 +139,11 @@ "man3x3": "MAN3x3" } } + }, + "switch": { + "identify": { + "name": "Identify" + } } }, "exceptions": { @@ -154,6 +159,9 @@ "failed_to_set_bypass_supply_temperature_target": { "message": "Failed to set bypass supply target temperature." }, + "failed_to_set_identify": { + "message": "Failed to set node identification." + }, "failed_to_set_state": { "message": "Failed to set ventilation state." }, diff --git a/homeassistant/components/duco/switch.py b/homeassistant/components/duco/switch.py new file mode 100644 index 000000000000..2368fd7477b8 --- /dev/null +++ b/homeassistant/components/duco/switch.py @@ -0,0 +1,113 @@ +"""Switch platform for the Duco integration.""" + +import logging +from typing import Any, override + +from duco_connectivity import ( + ActionValueType, + DucoError, + DucoRateLimitError, + KnownActionName, + Node, + NodeListActionItemList, +) + +from homeassistant.components.switch import SwitchEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import DucoConfigEntry, DucoCoordinator +from .entity import DucoEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 1 + + +def _discover_identify_nodes(node_actions: NodeListActionItemList) -> set[int]: + """Return node IDs advertising a Boolean identify action.""" + return { + node_action.node_id + for node_action in node_actions.nodes + if any( + action.action.known_value is KnownActionName.SET_IDENTIFY + and action.val_type is ActionValueType.BOOLEAN + for action in node_action.actions + ) + } + + +async def async_setup_entry( + hass: HomeAssistant, + entry: DucoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Duco switch entities.""" + coordinator = entry.runtime_data + known_nodes: set[int] = set() + + @callback + def _async_add_new_entities() -> None: + """Add identify switches for newly discovered controllable nodes.""" + identify_nodes = _discover_identify_nodes(coordinator.data.node_actions) + new_nodes = [ + node + for node in coordinator.data.nodes.values() + if node.node_id in identify_nodes and node.node_id not in known_nodes + ] + new_entities = [DucoIdentifySwitch(coordinator, node) for node in new_nodes] + known_nodes.update(node.node_id for node in new_nodes) + if new_entities: + async_add_entities(new_entities) + + entry.async_on_unload(coordinator.async_add_listener(_async_add_new_entities)) + _async_add_new_entities() + + +class DucoIdentifySwitch(DucoEntity, SwitchEntity): + """Switch entity for a node's identify state.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "identify" + + def __init__(self, coordinator: DucoCoordinator, node: Node) -> None: + """Initialize the switch entity.""" + super().__init__(coordinator, node) + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}_{node.node_id}_identify" + ) + + @property + @override + def is_on(self) -> bool: + """Return whether node identification is active.""" + return bool(self._node.general.identify) + + @override + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on node identification.""" + await self._async_set_identify(True) + + @override + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off node identification.""" + await self._async_set_identify(False) + + async def _async_set_identify(self, identify: bool) -> None: + """Set node identification.""" + try: + await self.coordinator.async_set_node_identify(self._node_id, identify) + except DucoRateLimitError as err: + _LOGGER.warning("Duco write rate limit exceeded for node %s", self._node_id) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="rate_limit_exceeded", + ) from err + except DucoError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="failed_to_set_identify", + ) from err diff --git a/tests/components/duco/snapshots/test_switch.ambr b/tests/components/duco/snapshots/test_switch.ambr new file mode 100644 index 000000000000..8359564c099c --- /dev/null +++ b/tests/components/duco/snapshots/test_switch.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_identify_switch_entity_state[switch.living_identify-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.living_identify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Identify', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Identify', + 'platform': 'duco', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'identify', + 'unique_id': 'aa:bb:cc:dd:ee:ff_1_identify', + 'unit_of_measurement': None, + }) +# --- +# name: test_identify_switch_entity_state[switch.living_identify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Living Identify', + }), + 'context': , + 'entity_id': 'switch.living_identify', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/duco/test_switch.py b/tests/components/duco/test_switch.py new file mode 100644 index 000000000000..1707e4280f77 --- /dev/null +++ b/tests/components/duco/test_switch.py @@ -0,0 +1,220 @@ +"""Tests for the Duco switch platform.""" + +from dataclasses import replace +from unittest.mock import AsyncMock + +from duco_connectivity import ( + ActionItem, + ActionValueType, + DucoError, + DucoRateLimitError, + KnownActionName, + Node, + NodeListActionItemList, +) +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.duco.const import SCAN_INTERVAL +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import async_fire_coordinator_update, setup_platform_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +_IDENTIFY_ENTITY = "switch.living_identify" + + +def _identify_action( + val_type: ActionValueType = ActionValueType.BOOLEAN, +) -> ActionItem: + """Build identify action metadata for switch tests.""" + return ActionItem( + action=KnownActionName.SET_IDENTIFY, + val_type=val_type, + ) + + +async def _async_set_identify(hass: HomeAssistant, service: str) -> None: + """Set the identify switch state.""" + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: _IDENTIFY_ENTITY}, + blocking=True, + ) + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, + mock_node_actions: NodeListActionItemList, +) -> None: + """Set up only the switch platform with identify capability.""" + mock_node_actions.nodes[0].actions.append(_identify_action()) + await setup_platform_integration(hass, mock_config_entry, [Platform.SWITCH]) + + +@pytest.mark.usefixtures("init_integration") +async def test_identify_switch_entity_state( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the identify switch entity registry metadata and state.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_identify_switch_discovery_and_state(hass: HomeAssistant) -> None: + """Test identify switches require a Boolean identify action.""" + assert hass.states.is_state(_IDENTIFY_ENTITY, STATE_OFF) + assert hass.states.get("switch.office_co2_identify") is None + + +async def test_non_boolean_identify_action_is_ignored( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, + mock_node_actions: NodeListActionItemList, +) -> None: + """Test identify actions with another value type are ignored.""" + mock_node_actions.nodes[0].actions.append(_identify_action(ActionValueType.INTEGER)) + + await setup_platform_integration(hass, mock_config_entry, [Platform.SWITCH]) + + assert hass.states.get(_IDENTIFY_ENTITY) is None + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("service", "identify", "expected_state"), + [ + pytest.param(SERVICE_TURN_ON, True, STATE_ON, id="on"), + pytest.param(SERVICE_TURN_OFF, False, STATE_OFF, id="off"), + ], +) +async def test_set_identify( + hass: HomeAssistant, + mock_duco_client: AsyncMock, + mock_nodes: list[Node], + service: str, + identify: bool, + expected_state: str, +) -> None: + """Test identify writes use the typed helper and update state.""" + box_node = mock_nodes[0] + mock_duco_client.async_get_node_info.side_effect = None + mock_duco_client.async_get_node_info.return_value = replace( + box_node, + general=replace(box_node.general, identify=int(identify)), + ) + + await _async_set_identify(hass, service) + + mock_duco_client.async_set_node_identify.assert_awaited_once_with(1, identify) + mock_duco_client.async_get_node_info.assert_awaited_once_with(1) + assert mock_duco_client.async_get_nodes.await_count == 1 + assert hass.states.is_state(_IDENTIFY_ENTITY, expected_state) + + +@pytest.mark.parametrize( + ("exception", "match"), + [ + pytest.param( + DucoError("Unexpected error"), "Failed to set node identification" + ), + pytest.param(DucoRateLimitError(), "daily write limit"), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_identify_write_error( + hass: HomeAssistant, + mock_duco_client: AsyncMock, + exception: Exception, + match: str, +) -> None: + """Test identify write failures report user-facing errors.""" + mock_duco_client.async_set_node_identify.side_effect = exception + + with pytest.raises(HomeAssistantError, match=match): + await _async_set_identify(hass, SERVICE_TURN_ON) + + +async def test_identify_switch_is_added_when_action_discovery_succeeds_later( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, + mock_node_actions: NodeListActionItemList, + freezer: FrozenDateTimeFactory, +) -> None: + """Test identify switches are added after action discovery recovers.""" + mock_node_actions.nodes[0].actions.append(_identify_action()) + mock_duco_client.async_get_node_actions.side_effect = [ + DucoError("Connection refused"), + mock_node_actions, + ] + await setup_platform_integration(hass, mock_config_entry, [Platform.SWITCH]) + + assert hass.states.get(_IDENTIFY_ENTITY) is None + + await async_fire_coordinator_update(hass, freezer) + + assert hass.states.get(_IDENTIFY_ENTITY) is not None + + +@pytest.mark.usefixtures("init_integration") +async def test_identify_switch_becomes_unavailable_when_node_is_missing( + hass: HomeAssistant, + mock_duco_client: AsyncMock, + mock_nodes: list[Node], + freezer: FrozenDateTimeFactory, +) -> None: + """Test an identify switch becomes unavailable when its node disappears.""" + mock_duco_client.async_get_nodes.return_value = mock_nodes[1:] + + await async_fire_coordinator_update(hass, freezer) + + assert hass.states.is_state(_IDENTIFY_ENTITY, STATE_UNAVAILABLE) + + +@pytest.mark.usefixtures("init_integration") +async def test_targeted_identify_readback_keeps_coordinator_poll_schedule( + hass: HomeAssistant, + mock_duco_client: AsyncMock, + mock_nodes: list[Node], + freezer: FrozenDateTimeFactory, +) -> None: + """Test an identify readback does not postpone the full coordinator poll.""" + box_node = mock_nodes[0] + mock_duco_client.async_get_node_info.return_value = replace( + box_node, general=replace(box_node.general, identify=1) + ) + mock_duco_client.async_get_node_info.side_effect = None + + freezer.tick(SCAN_INTERVAL / 2) + await _async_set_identify(hass, SERVICE_TURN_ON) + + freezer.tick(SCAN_INTERVAL / 2) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_duco_client.async_get_nodes.await_count == 2