From fb96ef99d03368875026c2a226bcb94734dd6ffe Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Tue, 21 Jan 2025 14:02:42 +0000 Subject: [PATCH] Homee sensor (#135447) Co-authored-by: Joostlek --- homeassistant/components/homee/__init__.py | 2 +- homeassistant/components/homee/const.py | 56 ++++ homeassistant/components/homee/entity.py | 71 ++++- homeassistant/components/homee/helpers.py | 10 +- homeassistant/components/homee/icons.json | 12 + homeassistant/components/homee/sensor.py | 303 ++++++++++++++++++++ homeassistant/components/homee/strings.json | 79 +++++ 7 files changed, 525 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/homee/icons.json create mode 100644 homeassistant/components/homee/sensor.py diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index ed5dd69767f..1ec09e09694 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -14,7 +14,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.COVER] +PLATFORMS = [Platform.COVER, Platform.SENSOR] type HomeeConfigEntry = ConfigEntry[Homee] diff --git a/homeassistant/components/homee/const.py b/homeassistant/components/homee/const.py index c96165ead81..8595f042af8 100644 --- a/homeassistant/components/homee/const.py +++ b/homeassistant/components/homee/const.py @@ -1,4 +1,60 @@ """Constants for the homee integration.""" +from homeassistant.const import ( + LIGHT_LUX, + PERCENTAGE, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, + UnitOfSpeed, + UnitOfTemperature, + UnitOfTime, + UnitOfVolume, +) + # General DOMAIN = "homee" + +# Sensor mappings +HOMEE_UNIT_TO_HA_UNIT = { + "": None, + "n/a": None, + "text": None, + "%": PERCENTAGE, + "lx": LIGHT_LUX, + "klx": LIGHT_LUX, + "A": UnitOfElectricCurrent.AMPERE, + "V": UnitOfElectricPotential.VOLT, + "kWh": UnitOfEnergy.KILO_WATT_HOUR, + "W": UnitOfPower.WATT, + "m/s": UnitOfSpeed.METERS_PER_SECOND, + "km/h": UnitOfSpeed.KILOMETERS_PER_HOUR, + "°F": UnitOfTemperature.FAHRENHEIT, + "°C": UnitOfTemperature.CELSIUS, + "K": UnitOfTemperature.KELVIN, + "s": UnitOfTime.SECONDS, + "min": UnitOfTime.MINUTES, + "h": UnitOfTime.HOURS, + "L": UnitOfVolume.LITERS, +} +OPEN_CLOSE_MAP = { + 0.0: "open", + 1.0: "closed", + 2.0: "partial", + 3.0: "opening", + 4.0: "closing", +} +OPEN_CLOSE_MAP_REVERSED = { + 0.0: "closed", + 1.0: "open", + 2.0: "partial", + 3.0: "cosing", + 4.0: "opening", +} +WINDOW_MAP = { + 0.0: "closed", + 1.0: "open", + 2.0: "tilted", +} +WINDOW_MAP_REVERSED = {0.0: "open", 1.0: "closed", 2.0: "tilted"} diff --git a/homeassistant/components/homee/entity.py b/homeassistant/components/homee/entity.py index 91b23b5a2c2..2af01358752 100644 --- a/homeassistant/components/homee/entity.py +++ b/homeassistant/components/homee/entity.py @@ -1,6 +1,6 @@ """Base Entities for Homee integration.""" -from pyHomee.const import AttributeType, NodeProfile, NodeState +from pyHomee.const import AttributeState, AttributeType, NodeProfile, NodeState from pyHomee.model import HomeeAttribute, HomeeNode from homeassistant.helpers.device_registry import DeviceInfo @@ -11,6 +11,56 @@ from .const import DOMAIN from .helpers import get_name_for_enum +class HomeeEntity(Entity): + """Represents a Homee entity consisting of a single HomeeAttribute.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, attribute: HomeeAttribute, entry: HomeeConfigEntry) -> None: + """Initialize the wrapper using a HomeeAttribute and target entity.""" + self._attribute = attribute + self._attr_unique_id = ( + f"{entry.runtime_data.settings.uid}-{attribute.node_id}-{attribute.id}" + ) + self._entry = entry + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, f"{entry.runtime_data.settings.uid}-{attribute.node_id}") + } + ) + + self._host_connected = entry.runtime_data.connected + + async def async_added_to_hass(self) -> None: + """Add the homee attribute entity to home assistant.""" + self.async_on_remove( + self._attribute.add_on_changed_listener(self._on_node_updated) + ) + self.async_on_remove( + await self._entry.runtime_data.add_connection_listener( + self._on_connection_changed + ) + ) + + @property + def available(self) -> bool: + """Return the availability of the underlying node.""" + return (self._attribute.state == AttributeState.NORMAL) and self._host_connected + + async def async_update(self) -> None: + """Update entity from homee.""" + homee = self._entry.runtime_data + await homee.update_attribute(self._attribute.node_id, self._attribute.id) + + def _on_node_updated(self, attribute: HomeeAttribute) -> None: + self.schedule_update_ha_state() + + async def _on_connection_changed(self, connected: bool) -> None: + self._host_connected = connected + self.schedule_update_ha_state() + + class HomeeNodeEntity(Entity): """Representation of an Entity that uses more than one HomeeAttribute.""" @@ -20,7 +70,7 @@ class HomeeNodeEntity(Entity): def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None: """Initialize the wrapper using a HomeeNode and target entity.""" self._node = node - self._attr_unique_id = f"{entry.runtime_data.settings.uid}-{node.id}" + self._attr_unique_id = f"{entry.unique_id}-{node.id}" self._entry = entry self._attr_device_info = DeviceInfo( @@ -41,6 +91,23 @@ class HomeeNodeEntity(Entity): ) ) + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + # Homee hub has id -1, but is identified only by the UID. + if self._node.id == -1: + return DeviceInfo( + identifiers={(DOMAIN, self._entry.runtime_data.settings.uid)}, + ) + + return DeviceInfo( + identifiers={(DOMAIN, f"{self._entry.unique_id}-{self._node.id}")}, + name=self._node.name, + model=get_name_for_enum(NodeProfile, self._node.profile), + sw_version=self._get_software_version(), + via_device=(DOMAIN, self._entry.runtime_data.settings.uid), + ) + @property def available(self) -> bool: """Return the availability of the underlying node.""" diff --git a/homeassistant/components/homee/helpers.py b/homeassistant/components/homee/helpers.py index 30826d7f47c..b73b1ae2bc9 100644 --- a/homeassistant/components/homee/helpers.py +++ b/homeassistant/components/homee/helpers.py @@ -1,16 +1,16 @@ """Helper functions for the homee custom component.""" +from enum import IntEnum import logging _LOGGER = logging.getLogger(__name__) -def get_name_for_enum(att_class, att_id) -> str: +def get_name_for_enum(att_class: type[IntEnum], att_id: int) -> str | None: """Return the enum item name for a given integer.""" try: - attribute_name = att_class(att_id).name + item = att_class(att_id) except ValueError: _LOGGER.warning("Value %s does not exist in %s", att_id, att_class.__name__) - return "Unknown" - - return attribute_name + return None + return item.name.lower() diff --git a/homeassistant/components/homee/icons.json b/homeassistant/components/homee/icons.json new file mode 100644 index 00000000000..3b1ee17b89c --- /dev/null +++ b/homeassistant/components/homee/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "link_quality": { + "default": "mdi:signal" + }, + "window_position": { + "default": "mdi:window-closed" + } + } + } +} diff --git a/homeassistant/components/homee/sensor.py b/homeassistant/components/homee/sensor.py new file mode 100644 index 00000000000..75b11811460 --- /dev/null +++ b/homeassistant/components/homee/sensor.py @@ -0,0 +1,303 @@ +"""The homee sensor platform.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from pyHomee.const import AttributeType, NodeState +from pyHomee.model import HomeeAttribute, HomeeNode + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import HomeeConfigEntry +from .const import ( + HOMEE_UNIT_TO_HA_UNIT, + OPEN_CLOSE_MAP, + OPEN_CLOSE_MAP_REVERSED, + WINDOW_MAP, + WINDOW_MAP_REVERSED, +) +from .entity import HomeeEntity, HomeeNodeEntity +from .helpers import get_name_for_enum + + +def get_open_close_value(attribute: HomeeAttribute) -> str | None: + """Return the open/close value.""" + vals = OPEN_CLOSE_MAP if not attribute.is_reversed else OPEN_CLOSE_MAP_REVERSED + return vals.get(attribute.current_value) + + +def get_window_value(attribute: HomeeAttribute) -> str | None: + """Return the states of a window open sensor.""" + vals = WINDOW_MAP if not attribute.is_reversed else WINDOW_MAP_REVERSED + return vals.get(attribute.current_value) + + +@dataclass(frozen=True, kw_only=True) +class HomeeSensorEntityDescription(SensorEntityDescription): + """A class that describes Homee sensor entities.""" + + value_fn: Callable[[HomeeAttribute], str | float | None] = ( + lambda value: value.current_value + ) + native_unit_of_measurement_fn: Callable[[str], str | None] = ( + lambda homee_unit: HOMEE_UNIT_TO_HA_UNIT[homee_unit] + ) + + +SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = { + AttributeType.ACCUMULATED_ENERGY_USE: HomeeSensorEntityDescription( + key="energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + AttributeType.BATTERY_LEVEL: HomeeSensorEntityDescription( + key="battery", + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.BRIGHTNESS: HomeeSensorEntityDescription( + key="brightness", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=( + lambda attribute: attribute.current_value * 1000 + if attribute.unit == "klx" + else attribute.current_value + ), + ), + AttributeType.CURRENT: HomeeSensorEntityDescription( + key="current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.CURRENT_ENERGY_USE: HomeeSensorEntityDescription( + key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.CURRENT_VALVE_POSITION: HomeeSensorEntityDescription( + key="valve_position", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.DAWN: HomeeSensorEntityDescription( + key="dawn", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.DEVICE_TEMPERATURE: HomeeSensorEntityDescription( + key="device_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.LEVEL: HomeeSensorEntityDescription( + key="level", + device_class=SensorDeviceClass.VOLUME, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.LINK_QUALITY: HomeeSensorEntityDescription( + key="link_quality", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.POSITION: HomeeSensorEntityDescription( + key="position", + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.RAIN_FALL_LAST_HOUR: HomeeSensorEntityDescription( + key="rainfall_hour", + device_class=SensorDeviceClass.PRECIPITATION, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.RAIN_FALL_TODAY: HomeeSensorEntityDescription( + key="rainfall_day", + device_class=SensorDeviceClass.PRECIPITATION, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.RELATIVE_HUMIDITY: HomeeSensorEntityDescription( + key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.TEMPERATURE: HomeeSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.TOTAL_ACCUMULATED_ENERGY_USE: HomeeSensorEntityDescription( + key="total_energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + AttributeType.TOTAL_CURRENT: HomeeSensorEntityDescription( + key="total_current", + device_class=SensorDeviceClass.CURRENT, + ), + AttributeType.TOTAL_CURRENT_ENERGY_USE: HomeeSensorEntityDescription( + key="total_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.TOTAL_VOLTAGE: HomeeSensorEntityDescription( + key="total_voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.UP_DOWN: HomeeSensorEntityDescription( + key="up_down", + device_class=SensorDeviceClass.ENUM, + options=[ + "open", + "closed", + "partial", + "opening", + "closing", + ], + value_fn=get_open_close_value, + ), + AttributeType.UV: HomeeSensorEntityDescription( + key="uv", + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.VOLTAGE: HomeeSensorEntityDescription( + key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.WIND_SPEED: HomeeSensorEntityDescription( + key="wind_speed", + device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.WINDOW_POSITION: HomeeSensorEntityDescription( + key="window_position", + device_class=SensorDeviceClass.ENUM, + options=["closed", "open", "tilted"], + value_fn=get_window_value, + ), +} + + +@dataclass(frozen=True, kw_only=True) +class HomeeNodeSensorEntityDescription(SensorEntityDescription): + """Describes Homee node sensor entities.""" + + value_fn: Callable[[HomeeNode], str | None] + + +NODE_SENSOR_DESCRIPTIONS: tuple[HomeeNodeSensorEntityDescription, ...] = ( + HomeeNodeSensorEntityDescription( + key="state", + device_class=SensorDeviceClass.ENUM, + options=[ + "available", + "unavailable", + "update_in_progress", + "waiting_for_attributes", + "initializing", + "user_interaction_required", + "password_required", + "host_unavailable", + "delete_in_progress", + "cosi_connected", + "blocked", + "waiting_for_wakeup", + "remote_node_deleted", + "firmware_update_in_progress", + ], + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda node: get_name_for_enum(NodeState, node.state), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_devices: AddEntitiesCallback, +) -> None: + """Add the homee platform for the sensor components.""" + + devices: list[HomeeSensor | HomeeNodeSensor] = [] + for node in config_entry.runtime_data.nodes: + # Node properties that are sensors. + devices.extend( + HomeeNodeSensor(node, config_entry, description) + for description in NODE_SENSOR_DESCRIPTIONS + ) + + # Node attributes that are sensors. + devices.extend( + HomeeSensor(attribute, config_entry, SENSOR_DESCRIPTIONS[attribute.type]) + for attribute in node.attributes + if attribute.type in SENSOR_DESCRIPTIONS and not attribute.editable + ) + + if devices: + async_add_devices(devices) + + +class HomeeSensor(HomeeEntity, SensorEntity): + """Representation of a homee sensor.""" + + entity_description: HomeeSensorEntityDescription + + def __init__( + self, + attribute: HomeeAttribute, + entry: HomeeConfigEntry, + description: HomeeSensorEntityDescription, + ) -> None: + """Initialize a homee sensor entity.""" + super().__init__(attribute, entry) + self.entity_description = description + self._attr_translation_key = description.key + if attribute.instance > 0: + self._attr_translation_key = f"{description.translation_key}_instance" + self._attr_translation_placeholders = {"instance": str(attribute.instance)} + + @property + def native_value(self) -> float | str | None: + """Return the native value of the sensor.""" + return self.entity_description.value_fn(self._attribute) + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the native unit of the sensor.""" + return self.entity_description.native_unit_of_measurement_fn( + self._attribute.unit + ) + + +class HomeeNodeSensor(HomeeNodeEntity, SensorEntity): + """Represents a sensor based on a node's property.""" + + entity_description: HomeeNodeSensorEntityDescription + + def __init__( + self, + node: HomeeNode, + entry: HomeeConfigEntry, + description: HomeeNodeSensorEntityDescription, + ) -> None: + """Initialize a homee node sensor entity.""" + super().__init__(node, entry) + self.entity_description = description + self._attr_translation_key = f"node_{description.key}" + self._node = node + self._attr_unique_id = f"{self._attr_unique_id}-{description.key}" + + @property + def native_value(self) -> str | None: + """Return the sensors value.""" + return self.entity_description.value_fn(self._node) diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index 54f80ba2977..a657465126b 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -24,5 +24,84 @@ } } } + }, + "entity": { + "sensor": { + "brightness_instance": { + "name": "Illuminance {instance}" + }, + "current_instance": { + "name": "Current {instance}" + }, + "dawn": { + "name": "Dawn" + }, + "device_temperature": { + "name": "Device temperature" + }, + "energy_instance": { + "name": "Energy {instance}" + }, + "level": { + "name": "Level" + }, + "link_quality": { + "name": "Link quality" + }, + "node_state": { + "name": "Node state" + }, + "position": { + "name": "Position" + }, + "power_instance": { + "name": "Power {instance}" + }, + "rainfall_hour": { + "name": "Rainfall last hour" + }, + "rainfall_day": { + "name": "Rainfall today" + }, + "total_current": { + "name": "Total current" + }, + "total_energy": { + "name": "Total energy" + }, + "total_power": { + "name": "Total power" + }, + "total_voltage": { + "name": "Total voltage" + }, + "up_down": { + "name": "State", + "state": { + "open": "[%key:common::state::open%]", + "closed": "[%key:common::state::closed%]", + "partial": "Partially open", + "opening": "Opening", + "closing": "Closing" + } + }, + "uv": { + "name": "Ultraviolet" + }, + "valve_position": { + "name": "Valve position" + }, + "voltage_instance": { + "name": "Voltage {instance}" + }, + "window_position": { + "name": "Window position", + "state": { + "closed": "[%key:common::state::closed%]", + "open": "[%key:common::state::open%]", + "tilted": "Tilted" + } + } + } } }