diff --git a/README.md b/README.md index 22fcf60..ed035b3 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Elektroměr posílá DLMS/COSEM PUSH zprávy každých **60 sekund**. Integrace ## Instalace přes HACS 1. Otevři HACS → **Integrace** → tři tečky vpravo nahoře → **Vlastní repozitáře** -2. Přidej URL tohoto repozitáře, kategorie: **Integration** (https://github.com/nero150/CEZ_rele_box) +2. Přidej URL tohoto repozitáře, kategorie: **Integration** 3. Najdi „XT211 HAN" a nainstaluj 4. Restartuj Home Assistant 5. **Nastavení → Zařízení a služby → Přidat integraci → XT211 HAN** @@ -73,22 +73,49 @@ Napájení USR-DR134: 5–24V DC (např. z USB adaptéru přes step-up, nebo 12V --- -## Dostupné senzory +## Dostupné entity -| Název | OBIS kód | Jednotka | -|-------|----------|----------| -| Active Power Consumption | `1-0:1.7.0.255` | W | -| Active Power Delivery | `1-0:2.7.0.255` | W | -| Active Power L1 | `1-0:21.7.0.255` | W | -| Active Power L2 | `1-0:41.7.0.255` | W | -| Active Power L3 | `1-0:61.7.0.255` | W | -| Energy Consumed | `1-0:1.8.0.255` | kWh | -| Energy Consumed T1 | `1-0:1.8.1.255` | kWh | -| Energy Consumed T2 | `1-0:1.8.2.255` | kWh | -| Energy Delivered | `1-0:2.8.0.255` | kWh | -| Serial Number | `0-0:96.1.1.255` | – | -| Current Tariff | `0-0:96.14.0.255` | – | -| Disconnector Status | `0-0:96.3.10.255` | – | +### 📊 Výkon (W) – okamžité hodnoty +| Název entity | OBIS kód | +|---|---| +| Active Power Consumption | `1-0:1.7.0.255` | +| Active Power Consumption L1 | `1-0:21.7.0.255` | +| Active Power Consumption L2 | `1-0:41.7.0.255` | +| Active Power Consumption L3 | `1-0:61.7.0.255` | +| Active Power Delivery | `1-0:2.7.0.255` | +| Active Power Delivery L1 | `1-0:22.7.0.255` | +| Active Power Delivery L2 | `1-0:42.7.0.255` | +| Active Power Delivery L3 | `1-0:62.7.0.255` | +| Limiter Value | `0-0:17.0.0.255` | + +### ⚡ Energie (kWh) – kumulativní +| Název entity | OBIS kód | +|---|---| +| Energy Consumed | `1-0:1.8.0.255` | +| Energy Consumed T1 | `1-0:1.8.1.255` | +| Energy Consumed T2 | `1-0:1.8.2.255` | +| Energy Consumed T3 | `1-0:1.8.3.255` | +| Energy Consumed T4 | `1-0:1.8.4.255` | +| Energy Delivered | `1-0:2.8.0.255` | +| Energy Delivered T1 | `1-0:2.8.1.255` | +| Energy Delivered T2 | `1-0:2.8.2.255` | +| Energy Delivered T3 | `1-0:2.8.3.255` | +| Energy Delivered T4 | `1-0:2.8.4.255` | + +### 🔀 Binární senzory (zapnuto/vypnuto) +| Název entity | OBIS kód | +|---|---| +| Disconnector Status | `0-0:96.3.10.255` | +| Relay R1 Status | `0-1:96.3.10.255` | +| Relay R2 Status | `0-2:96.3.10.255` | +| Relay R3 Status | `0-3:96.3.10.255` | +| Relay R4 Status | `0-4:96.3.10.255` | + +### 📋 Diagnostika (text) +| Název entity | OBIS kód | +|---|---| +| Serial Number | `0-0:96.1.1.255` | +| Current Tariff | `0-0:96.14.0.255` | --- diff --git a/custom_components/xt211_han/dlms_parser.py b/custom_components/xt211_han/dlms_parser.py index e0a4c04..68af542 100644 --- a/custom_components/xt211_han/dlms_parser.py +++ b/custom_components/xt211_han/dlms_parser.py @@ -436,23 +436,41 @@ class DLMSParser: # --------------------------------------------------------------------------- OBIS_DESCRIPTIONS: dict[str, dict] = { - "0-0:96.1.1.255": {"name": "Serial Number", "unit": "", "class": "text"}, - "0-0:96.3.10.255": {"name": "Disconnector Status", "unit": "", "class": "binary"}, - "0-0:96.14.0.255": {"name": "Current Tariff", "unit": "", "class": "text"}, - "1-0:1.7.0.255": {"name": "Active Power Consumption", "unit": "W", "class": "power"}, - "1-0:2.7.0.255": {"name": "Active Power Delivery", "unit": "W", "class": "power"}, - "1-0:21.7.0.255": {"name": "Active Power L1", "unit": "W", "class": "power"}, - "1-0:41.7.0.255": {"name": "Active Power L2", "unit": "W", "class": "power"}, - "1-0:61.7.0.255": {"name": "Active Power L3", "unit": "W", "class": "power"}, - "1-0:1.8.0.255": {"name": "Energy Consumed", "unit": "Wh", "class": "energy"}, - "1-0:1.8.1.255": {"name": "Energy Consumed T1", "unit": "Wh", "class": "energy"}, - "1-0:1.8.2.255": {"name": "Energy Consumed T2", "unit": "Wh", "class": "energy"}, - "1-0:1.8.3.255": {"name": "Energy Consumed T3", "unit": "Wh", "class": "energy"}, - "1-0:1.8.4.255": {"name": "Energy Consumed T4", "unit": "Wh", "class": "energy"}, - "1-0:2.8.0.255": {"name": "Energy Delivered", "unit": "Wh", "class": "energy"}, - "0-1:96.3.10.255": {"name": "Relay R1 Status", "unit": "", "class": "binary"}, - "0-2:96.3.10.255": {"name": "Relay R2 Status", "unit": "", "class": "binary"}, - "0-3:96.3.10.255": {"name": "Relay R3 Status", "unit": "", "class": "binary"}, - "0-4:96.3.10.255": {"name": "Relay R4 Status", "unit": "", "class": "binary"}, - "0-0:17.0.0.255": {"name": "Limiter Value", "unit": "W", "class": "power"}, + # --- Diagnostic / text --- + "0-0:96.1.1.255": {"name": "Serial Number", "unit": "", "class": "text"}, + "0-0:96.14.0.255": {"name": "Current Tariff", "unit": "", "class": "text"}, + "0-0:17.0.0.255": {"name": "Limiter Value", "unit": "W", "class": "power"}, + + # --- Binary (disconnector / relays) --- + "0-0:96.3.10.255": {"name": "Disconnector Status", "unit": "", "class": "binary"}, + "0-1:96.3.10.255": {"name": "Relay R1 Status", "unit": "", "class": "binary"}, + "0-2:96.3.10.255": {"name": "Relay R2 Status", "unit": "", "class": "binary"}, + "0-3:96.3.10.255": {"name": "Relay R3 Status", "unit": "", "class": "binary"}, + "0-4:96.3.10.255": {"name": "Relay R4 Status", "unit": "", "class": "binary"}, + + # --- Instant power – consumption (odběr) --- + "1-0:1.7.0.255": {"name": "Active Power Consumption", "unit": "W", "class": "power"}, + "1-0:21.7.0.255": {"name": "Active Power Consumption L1", "unit": "W", "class": "power"}, + "1-0:41.7.0.255": {"name": "Active Power Consumption L2", "unit": "W", "class": "power"}, + "1-0:61.7.0.255": {"name": "Active Power Consumption L3", "unit": "W", "class": "power"}, + + # --- Instant power – delivery (dodávka / FVE) --- + "1-0:2.7.0.255": {"name": "Active Power Delivery", "unit": "W", "class": "power"}, + "1-0:22.7.0.255": {"name": "Active Power Delivery L1", "unit": "W", "class": "power"}, + "1-0:42.7.0.255": {"name": "Active Power Delivery L2", "unit": "W", "class": "power"}, + "1-0:62.7.0.255": {"name": "Active Power Delivery L3", "unit": "W", "class": "power"}, + + # --- Energy – consumption (odběr kWh) --- + "1-0:1.8.0.255": {"name": "Energy Consumed", "unit": "Wh", "class": "energy"}, + "1-0:1.8.1.255": {"name": "Energy Consumed T1", "unit": "Wh", "class": "energy"}, + "1-0:1.8.2.255": {"name": "Energy Consumed T2", "unit": "Wh", "class": "energy"}, + "1-0:1.8.3.255": {"name": "Energy Consumed T3", "unit": "Wh", "class": "energy"}, + "1-0:1.8.4.255": {"name": "Energy Consumed T4", "unit": "Wh", "class": "energy"}, + + # --- Energy – delivery (dodávka kWh, FVE přetoky) --- + "1-0:2.8.0.255": {"name": "Energy Delivered", "unit": "Wh", "class": "energy"}, + "1-0:2.8.1.255": {"name": "Energy Delivered T1", "unit": "Wh", "class": "energy"}, + "1-0:2.8.2.255": {"name": "Energy Delivered T2", "unit": "Wh", "class": "energy"}, + "1-0:2.8.3.255": {"name": "Energy Delivered T3", "unit": "Wh", "class": "energy"}, + "1-0:2.8.4.255": {"name": "Energy Delivered T4", "unit": "Wh", "class": "energy"}, } diff --git a/custom_components/xt211_han/sensor.py b/custom_components/xt211_han/sensor.py index 17250a0..7d0e16d 100644 --- a/custom_components/xt211_han/sensor.py +++ b/custom_components/xt211_han/sensor.py @@ -1,10 +1,20 @@ -"""Sensor platform for XT211 HAN integration.""" +"""Sensor platform for XT211 HAN integration. + +Registers three types of entities: + - Numeric sensors (power, energy) + - Text sensors (serial number, tariff, limiter) + - Binary sensors (disconnector, relays) +""" from __future__ import annotations import logging from typing import Any +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -13,6 +23,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_NAME, + EntityCategory, UnitOfEnergy, UnitOfPower, ) @@ -27,7 +38,7 @@ from .dlms_parser import OBIS_DESCRIPTIONS _LOGGER = logging.getLogger(__name__) -# Map OBIS "class" strings → HA SensorDeviceClass + StateClass + unit +# Map OBIS "class" → HA SensorDeviceClass + StateClass + unit SENSOR_META: dict[str, dict] = { "power": { "device_class": SensorDeviceClass.POWER, @@ -37,7 +48,7 @@ SENSOR_META: dict[str, dict] = { "energy": { "device_class": SensorDeviceClass.ENERGY, "state_class": SensorStateClass.TOTAL_INCREASING, - "unit": UnitOfEnergy.WATT_HOUR, + "unit": UnitOfEnergy.KILO_WATT_HOUR, }, "sensor": { "device_class": None, @@ -46,8 +57,26 @@ SENSOR_META: dict[str, dict] = { }, } -# OBIS codes that are NOT numeric sensors (text / binary) – handled separately -NON_SENSOR_CLASSES = {"text", "binary"} +# OBIS codes that send text values (not numeric) +TEXT_OBIS = {"0-0:96.1.1.255", "0-0:96.14.0.255"} + +# OBIS codes that are binary (on/off) +BINARY_OBIS = { + "0-0:96.3.10.255", + "0-1:96.3.10.255", + "0-2:96.3.10.255", + "0-3:96.3.10.255", + "0-4:96.3.10.255", +} + + +def _device_info(entry: ConfigEntry) -> DeviceInfo: + return DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=entry.data.get(CONF_NAME, "XT211 HAN"), + manufacturer="Sagemcom", + model="XT211 AMM", + ) async def async_setup_entry( @@ -55,46 +84,52 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up XT211 HAN sensors from a config entry.""" + """Set up all XT211 HAN entities from a config entry.""" coordinator: XT211Coordinator = hass.data[DOMAIN][entry.entry_id] - # We create entities for all known OBIS codes upfront. - # Unknown codes that arrive later will be added dynamically. - entities: list[XT211SensorEntity] = [] + entities: list = [] registered_obis: set[str] = set() for obis, meta in OBIS_DESCRIPTIONS.items(): - if meta.get("class") in NON_SENSOR_CLASSES: - continue - entities.append( - XT211SensorEntity(coordinator, entry, obis, meta) - ) registered_obis.add(obis) + if obis in BINARY_OBIS: + entities.append(XT211BinarySensorEntity(coordinator, entry, obis, meta)) + elif obis in TEXT_OBIS: + entities.append(XT211TextSensorEntity(coordinator, entry, obis, meta)) + else: + entities.append(XT211SensorEntity(coordinator, entry, obis, meta)) async_add_entities(entities) - @callback - def _handle_new_obis(obis: str, data: dict) -> None: - """Dynamically add sensor for a previously unknown OBIS code.""" - if obis in registered_obis: - return - if data.get("class") in NON_SENSOR_CLASSES: - return - registered_obis.add(obis) - async_add_entities([XT211SensorEntity(coordinator, entry, obis, data)]) - - # Subscribe to coordinator updates to detect new OBIS codes + # Dynamically register any unknown OBIS codes that arrive at runtime @callback def _on_update() -> None: - if coordinator.data: - for obis, data in coordinator.data.items(): - _handle_new_obis(obis, data) + if not coordinator.data: + return + new: list = [] + for obis, data in coordinator.data.items(): + if obis in registered_obis: + continue + registered_obis.add(obis) + _LOGGER.info("XT211: discovered new OBIS code %s – adding entity", obis) + if obis in BINARY_OBIS: + new.append(XT211BinarySensorEntity(coordinator, entry, obis, data)) + elif obis in TEXT_OBIS: + new.append(XT211TextSensorEntity(coordinator, entry, obis, data)) + else: + new.append(XT211SensorEntity(coordinator, entry, obis, data)) + if new: + async_add_entities(new) coordinator.async_add_listener(_on_update) +# --------------------------------------------------------------------------- +# Numeric sensor +# --------------------------------------------------------------------------- + class XT211SensorEntity(CoordinatorEntity[XT211Coordinator], SensorEntity): - """A single numeric sensor entity backed by an OBIS code.""" + """Numeric sensor (power / energy / generic).""" _attr_has_entity_name = True @@ -107,7 +142,6 @@ class XT211SensorEntity(CoordinatorEntity[XT211Coordinator], SensorEntity): ) -> None: super().__init__(coordinator) self._obis = obis - self._meta = meta self._entry = entry sensor_type = meta.get("class", "sensor") @@ -118,33 +152,20 @@ class XT211SensorEntity(CoordinatorEntity[XT211Coordinator], SensorEntity): self._attr_device_class = sm["device_class"] self._attr_state_class = sm["state_class"] self._attr_native_unit_of_measurement = sm["unit"] or meta.get("unit") - - # Energy sensors: convert Wh → kWh for HA Energy dashboard - if sensor_type == "energy": - self._attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR - self._wh_to_kwh = True - else: - self._wh_to_kwh = False + self._wh_to_kwh = (sensor_type == "energy") @property def device_info(self) -> DeviceInfo: - return DeviceInfo( - identifiers={(DOMAIN, self._entry.entry_id)}, - name=self._entry.data.get(CONF_NAME, "XT211 HAN"), - manufacturer="Sagemcom", - model="XT211 AMM", - ) + return _device_info(self._entry) @property def native_value(self) -> float | None: - if self.coordinator.data is None: + if not self.coordinator.data: return None obj = self.coordinator.data.get(self._obis) if obj is None: return None raw = obj.get("value") - if raw is None: - return None try: val = float(raw) if self._wh_to_kwh: @@ -156,3 +177,95 @@ class XT211SensorEntity(CoordinatorEntity[XT211Coordinator], SensorEntity): @property def available(self) -> bool: return self.coordinator.connected and self.coordinator.data is not None + + +# --------------------------------------------------------------------------- +# Text sensor +# --------------------------------------------------------------------------- + +class XT211TextSensorEntity(CoordinatorEntity[XT211Coordinator], SensorEntity): + """Text sensor (serial number, tariff).""" + + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + coordinator: XT211Coordinator, + entry: ConfigEntry, + obis: str, + meta: dict, + ) -> None: + super().__init__(coordinator) + self._obis = obis + self._entry = entry + self._attr_unique_id = f"{entry.entry_id}_{obis}" + self._attr_name = meta.get("name", obis) + self._attr_device_class = None + self._attr_state_class = None + self._attr_native_unit_of_measurement = None + + @property + def device_info(self) -> DeviceInfo: + return _device_info(self._entry) + + @property + def native_value(self) -> str | None: + if not self.coordinator.data: + return None + obj = self.coordinator.data.get(self._obis) + if obj is None: + return None + val = obj.get("value") + return str(val) if val is not None else None + + @property + def available(self) -> bool: + return self.coordinator.connected and self.coordinator.data is not None + + +# --------------------------------------------------------------------------- +# Binary sensor +# --------------------------------------------------------------------------- + +class XT211BinarySensorEntity(CoordinatorEntity[XT211Coordinator], BinarySensorEntity): + """Binary sensor (disconnector / relay status).""" + + _attr_has_entity_name = True + _attr_device_class = BinarySensorDeviceClass.PLUG + + def __init__( + self, + coordinator: XT211Coordinator, + entry: ConfigEntry, + obis: str, + meta: dict, + ) -> None: + super().__init__(coordinator) + self._obis = obis + self._entry = entry + self._attr_unique_id = f"{entry.entry_id}_{obis}" + self._attr_name = meta.get("name", obis) + + @property + def device_info(self) -> DeviceInfo: + return _device_info(self._entry) + + @property + def is_on(self) -> bool | None: + if not self.coordinator.data: + return None + obj = self.coordinator.data.get(self._obis) + if obj is None: + return None + val = obj.get("value") + if isinstance(val, bool): + return val + try: + return int(val) != 0 + except (TypeError, ValueError): + return None + + @property + def available(self) -> bool: + return self.coordinator.connected and self.coordinator.data is not None