Files
core/homeassistant/components/duco/sensor.py
T

363 lines
14 KiB
Python

"""Sensor platform for the Duco integration."""
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
import logging
from typing import override
from duco_connectivity.models import Node, NodeType, VentilationState
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
UnitOfRatio,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import BOX_NODE_ID, DOMAIN, VENTILATION_CAPABLE_NODE_TYPES
from .coordinator import DucoConfigEntry, DucoCoordinator
from .entity import DucoEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class DucoSensorEntityDescription(SensorEntityDescription):
"""Duco sensor entity description."""
value_fn: Callable[[Node], datetime | int | float | str | None]
node_types: tuple[NodeType, ...]
@dataclass(frozen=True, kw_only=True)
class DucoBoxSensorEntityDescription(SensorEntityDescription):
"""Duco sensor entity description for box-level diagnostic data."""
supported_fn: Callable[[DucoCoordinator], bool] = lambda _: True
value_fn: Callable[[DucoCoordinator], int | float | None]
SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
DucoSensorEntityDescription(
key="ventilation_state",
translation_key="ventilation_state",
device_class=SensorDeviceClass.ENUM,
options=[
state.lower()
for state in VentilationState
if state != VentilationState.UNKNOWN
],
value_fn=lambda node: (
node.ventilation.state.lower()
if node.ventilation and node.ventilation.state != VentilationState.UNKNOWN
else None
),
node_types=VENTILATION_CAPABLE_NODE_TYPES,
),
DucoSensorEntityDescription(
key="target_flow_level",
translation_key="target_flow_level",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfRatio.PERCENTAGE,
suggested_display_precision=0,
value_fn=lambda node: (
node.ventilation.flow_lvl_tgt if node.ventilation else None
),
node_types=VENTILATION_CAPABLE_NODE_TYPES,
),
DucoSensorEntityDescription(
key="time_state_end",
translation_key="time_state_end",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda node: (
dt_util.utc_from_timestamp(node.ventilation.time_state_end)
if node.ventilation and node.ventilation.time_state_end != 0
else None
),
node_types=VENTILATION_CAPABLE_NODE_TYPES,
),
DucoSensorEntityDescription(
key="co2",
device_class=SensorDeviceClass.CO2,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfRatio.PARTS_PER_MILLION,
value_fn=lambda node: node.sensor.co2 if node.sensor else None,
node_types=(
NodeType.BSCO2,
NodeType.UCCO2,
NodeType.VLVCO2,
NodeType.VLVCO2RH,
),
),
DucoSensorEntityDescription(
key="iaq_co2",
translation_key="iaq_co2",
native_unit_of_measurement=UnitOfRatio.PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda node: node.sensor.iaq_co2 if node.sensor else None,
node_types=(
NodeType.BSCO2,
NodeType.UCCO2,
NodeType.VLVCO2,
NodeType.VLVCO2RH,
),
),
DucoSensorEntityDescription(
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfRatio.PERCENTAGE,
value_fn=lambda node: node.sensor.rh if node.sensor else None,
node_types=(NodeType.BSRH, NodeType.UCRH, NodeType.VLVRH, NodeType.VLVCO2RH),
),
DucoSensorEntityDescription(
key="iaq_rh",
translation_key="iaq_rh",
native_unit_of_measurement=UnitOfRatio.PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda node: node.sensor.iaq_rh if node.sensor else None,
node_types=(NodeType.BSRH, NodeType.UCRH, NodeType.VLVRH, NodeType.VLVCO2RH),
),
)
BOX_SENSOR_DESCRIPTIONS: tuple[DucoBoxSensorEntityDescription, ...] = (
DucoBoxSensorEntityDescription(
key="filter_remaining",
translation_key="filter_remaining",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.DAYS,
suggested_display_precision=0,
supported_fn=lambda coordinator: (
coordinator.data.time_filter_remain is not None
),
value_fn=lambda coordinator: coordinator.data.time_filter_remain,
),
DucoBoxSensorEntityDescription(
key="rssi_wifi",
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda coordinator: coordinator.data.rssi_wifi,
),
DucoBoxSensorEntityDescription(
key="outdoor_air_temperature",
translation_key="outdoor_air_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
supported_fn=lambda coordinator: (
coordinator.data.ventilation_temperatures is not None
and coordinator.data.ventilation_temperatures.temp_oda is not None
),
value_fn=lambda coordinator: (
coordinator.data.ventilation_temperatures.temp_oda
if coordinator.data.ventilation_temperatures
else None
),
),
DucoBoxSensorEntityDescription(
key="supply_air_temperature",
translation_key="supply_air_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
supported_fn=lambda coordinator: (
coordinator.data.ventilation_temperatures is not None
and coordinator.data.ventilation_temperatures.temp_sup is not None
),
value_fn=lambda coordinator: (
coordinator.data.ventilation_temperatures.temp_sup
if coordinator.data.ventilation_temperatures
else None
),
),
DucoBoxSensorEntityDescription(
key="extract_air_temperature",
translation_key="extract_air_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
supported_fn=lambda coordinator: (
coordinator.data.ventilation_temperatures is not None
and coordinator.data.ventilation_temperatures.temp_eta is not None
),
value_fn=lambda coordinator: (
coordinator.data.ventilation_temperatures.temp_eta
if coordinator.data.ventilation_temperatures
else None
),
),
DucoBoxSensorEntityDescription(
key="exhaust_air_temperature",
translation_key="exhaust_air_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
supported_fn=lambda coordinator: (
coordinator.data.ventilation_temperatures is not None
and coordinator.data.ventilation_temperatures.temp_eha is not None
),
value_fn=lambda coordinator: (
coordinator.data.ventilation_temperatures.temp_eha
if coordinator.data.ventilation_temperatures
else None
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: DucoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Duco sensor entities."""
coordinator = entry.runtime_data
# Track the node IDs for which node entities have already been created, so
# we can detect both newly added and stale (deregistered) nodes on every
# coordinator update.
known_nodes: set[int] = set()
# Track optional box-level sensors separately so they can still be added
# later if their capability probe transiently failed during initial setup.
known_box_sensors: set[tuple[int, str]] = set()
@callback
def _async_add_new_entities() -> None:
"""Add new sensor entities and remove stale ones on coordinator updates."""
# Remove devices whose nodes have disappeared from the API.
# The firmware removes deregistered RF/wired nodes automatically.
# BSRH box sensors that are physically unplugged from the PCB are
# not deregistered by the firmware and will never appear here as stale.
# The BOX node can transiently disappear from the API response, so keep
# node 1 to avoid removing the main controller device.
stale_node_ids = {
node_id
for node_id in known_nodes - coordinator.data.nodes.keys()
if node_id != BOX_NODE_ID
}
if stale_node_ids:
device_reg = dr.async_get(hass)
mac = entry.unique_id
for node_id in stale_node_ids:
device = device_reg.async_get_device_by_identifier(
(DOMAIN, f"{mac}_{node_id}"), entry.entry_id
)
if device:
device_reg.async_remove_device(device.id)
known_nodes.difference_update(stale_node_ids)
known_box_sensors.difference_update(
{
description_key
for description_key in known_box_sensors
if description_key[0] in stale_node_ids
}
)
new_entities: list[SensorEntity] = []
for node in coordinator.data.nodes.values():
if node.node_id not in known_nodes:
if node.general.node_type == NodeType.UNKNOWN:
# Do not add the node to known_nodes so that it is re-evaluated
# on every coordinator update. This allows entities to be
# created automatically once a firmware update or library
# update adds support for the device type.
_LOGGER.debug(
"Duco node %s (%s) has an unsupported device type and will be "
"retried on subsequent coordinator updates",
node.node_id,
node.general.name,
)
continue
known_nodes.add(node.node_id)
new_entities.extend(
DucoSensorEntity(coordinator, node, description)
for description in SENSOR_DESCRIPTIONS
if node.general.node_type in description.node_types
)
if node.general.node_type != NodeType.BOX:
continue
for description in BOX_SENSOR_DESCRIPTIONS:
description_key = (node.node_id, description.key)
if description_key in known_box_sensors:
continue
if not description.supported_fn(coordinator):
continue
known_box_sensors.add(description_key)
new_entities.append(DucoBoxSensorEntity(coordinator, node, description))
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 DucoSensorEntity(DucoEntity, SensorEntity):
"""Sensor entity for a Duco node."""
entity_description: DucoSensorEntityDescription
def __init__(
self,
coordinator: DucoCoordinator,
node: Node,
description: DucoSensorEntityDescription,
) -> None:
"""Initialize the sensor entity."""
super().__init__(coordinator, node)
self.entity_description = description
self._attr_unique_id = (
f"{coordinator.config_entry.unique_id}_{node.node_id}_{description.key}"
)
@property
@override
def native_value(self) -> datetime | int | float | str | None:
"""Return the sensor value."""
return self.entity_description.value_fn(self._node)
class DucoBoxSensorEntity(DucoEntity, SensorEntity):
"""Sensor entity for box-level diagnostic data."""
entity_description: DucoBoxSensorEntityDescription
def __init__(
self,
coordinator: DucoCoordinator,
node: Node,
description: DucoBoxSensorEntityDescription,
) -> None:
"""Initialize the box sensor entity."""
super().__init__(coordinator, node)
self.entity_description = description
self._attr_unique_id = (
f"{coordinator.config_entry.unique_id}_{node.node_id}_{description.key}"
)
@property
@override
def native_value(self) -> int | float | None:
"""Return the sensor value."""
return self.entity_description.value_fn(self.coordinator)