Homee sensor (#135447)

Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
Markus Adrario 2025-01-21 14:02:42 +00:00 committed by GitHub
parent 032940f1a9
commit fb96ef99d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 525 additions and 8 deletions

View File

@ -14,7 +14,7 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.COVER]
PLATFORMS = [Platform.COVER, Platform.SENSOR]
type HomeeConfigEntry = ConfigEntry[Homee]

View File

@ -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"}

View File

@ -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."""

View File

@ -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()

View File

@ -0,0 +1,12 @@
{
"entity": {
"sensor": {
"link_quality": {
"default": "mdi:signal"
},
"window_position": {
"default": "mdi:window-closed"
}
}
}
}

View File

@ -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)

View File

@ -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"
}
}
}
}
}