"""Select platform for the Duco integration.""" import logging from typing import override from duco_connectivity import ( ActionItem, DucoError, DucoRateLimitError, KnownActionName, Node, NodeListActionItemList, VentilationState, ) from homeassistant.components.select import SelectEntity from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, VENTILATION_CAPABLE_NODE_TYPES from .coordinator import DucoConfigEntry, DucoCoordinator from .entity import DucoEntity _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 def _get_ventilation_options(action: ActionItem) -> tuple[str, ...] | None: """Return ventilation options advertised by a node action.""" if action.action.known_value is not KnownActionName.SET_VENTILATION_STATE: return None options = tuple(str(value) for value in action.enum_values if value) return options or None def _discover_ventilation_options( node_actions: NodeListActionItemList, ) -> dict[int, tuple[str, ...]]: """Build a node-to-options map from the node action metadata.""" options_by_node: dict[int, tuple[str, ...]] = {} for node_action in node_actions.nodes: for action in node_action.actions: if options := _get_ventilation_options(action): options_by_node[node_action.node_id] = options break return options_by_node async def async_setup_entry( hass: HomeAssistant, entry: DucoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Duco select entities.""" coordinator = entry.runtime_data known_nodes: set[int] = set() @callback def _async_add_new_entities() -> None: """Add select entities for newly discovered controllable nodes.""" options_by_node = _discover_ventilation_options(coordinator.data.node_actions) new_entities: list[DucoVentilationStateSelect] = [] for node in coordinator.data.nodes.values(): if node.node_id in known_nodes: continue # Duco advertises SetVentilationState broadly, so keep the select # limited to the box and known valve node families. if node.general.node_type not in VENTILATION_CAPABLE_NODE_TYPES: continue options = options_by_node.get(node.node_id) if options is None: continue known_nodes.add(node.node_id) new_entities.append(DucoVentilationStateSelect(coordinator, node, options)) 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 DucoVentilationStateSelect(DucoEntity, SelectEntity): """Select entity for node ventilation states.""" _attr_translation_key = "ventilation_state" def __init__( self, coordinator: DucoCoordinator, node: Node, options: tuple[str, ...], ) -> None: """Initialize the select entity.""" super().__init__(coordinator, node) self._attr_unique_id = ( f"{coordinator.config_entry.unique_id}_{node.node_id}_ventilation_state" ) self._attr_options = list(options) @property @override def current_option(self) -> str | None: """Return the current ventilation state when it is selectable.""" if (ventilation := self._node.ventilation) is None: return None if ventilation.state is VentilationState.UNKNOWN: return None state = ventilation.state.value if state not in self.options: return None return state @override async def async_select_option(self, option: str) -> None: """Set a new ventilation state on the node.""" try: # SelectEntity exposes string options, and passing the raw API value # through keeps newly added Duco states forward-compatible. await self.coordinator.client.async_set_ventilation_state( self._node_id, option ) 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_state", ) from err # Duco may normalize the requested action on readback, such as # MAN1x2 -> MAN1 or AUTO -> CNT1, so refresh the authoritative state. await self.coordinator.async_refresh()