Add files via upload

main 0.7.6
Nero 2026-03-18 18:25:19 +01:00 committed by GitHub
parent a42b17da00
commit 5b2d726fd7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 504 additions and 843 deletions

View File

@ -1,28 +1,22 @@
"""XT211 HAN integration for Home Assistant. """XT211 HAN integration for Home Assistant."""
Reads DLMS/COSEM PUSH data from a Sagemcom XT211 smart meter via a
RS485-to-Ethernet adapter (e.g. PUSR USR-DR134) over TCP.
No ESP32 or dedicated hardware needed beyond the adapter.
"""
from __future__ import annotations from __future__ import annotations
import logging import logging
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_NAME, Platform from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .const import DOMAIN, DEFAULT_NAME from .const import DEFAULT_NAME, DOMAIN
from .coordinator import XT211Coordinator from .coordinator import XT211Coordinator
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.SENSOR] PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up XT211 HAN from a config entry."""
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})
coordinator = XT211Coordinator( coordinator = XT211Coordinator(
@ -31,13 +25,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
port=entry.data[CONF_PORT], port=entry.data[CONF_PORT],
name=entry.data.get(CONF_NAME, DEFAULT_NAME), name=entry.data.get(CONF_NAME, DEFAULT_NAME),
) )
hass.data[DOMAIN][entry.entry_id] = coordinator hass.data[DOMAIN][entry.entry_id] = coordinator
# Start the background TCP listener
await coordinator.async_setup() await coordinator.async_setup()
# Set up sensor platform
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
_LOGGER.info( _LOGGER.info(
@ -49,14 +39,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" coordinator: XT211Coordinator | None = hass.data[DOMAIN].get(entry.entry_id)
coordinator: XT211Coordinator = hass.data[DOMAIN].get(entry.entry_id)
if coordinator: if coordinator:
await coordinator.async_shutdown() await coordinator.async_shutdown()
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok: if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id, None) hass.data[DOMAIN].pop(entry.entry_id, None)
return unload_ok return unload_ok

View File

@ -0,0 +1,81 @@
"""Binary sensor platform for XT211 HAN integration."""
from __future__ import annotations
from homeassistant.components.binary_sensor import BinarySensorDeviceClass, BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import XT211Coordinator
from .sensor import BINARY_OBIS, build_enabled_obis, _device_info
from .dlms_parser import OBIS_DESCRIPTIONS
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: XT211Coordinator = hass.data[DOMAIN][entry.entry_id]
enabled_obis = build_enabled_obis(entry)
entities = [
XT211BinarySensorEntity(coordinator, entry, obis, meta)
for obis, meta in OBIS_DESCRIPTIONS.items()
if obis in enabled_obis and obis in BINARY_OBIS
]
async_add_entities(entities)
registered_obis = {entity._obis for entity in entities}
@callback
def _on_update() -> None:
if not coordinator.data:
return
new_entities = []
for obis, data in coordinator.data.items():
if obis in registered_obis or obis not in enabled_obis or obis not in BINARY_OBIS:
continue
registered_obis.add(obis)
new_entities.append(XT211BinarySensorEntity(coordinator, entry, obis, data))
if new_entities:
async_add_entities(new_entities)
coordinator.async_add_listener(_on_update)
class XT211BinarySensorEntity(CoordinatorEntity[XT211Coordinator], BinarySensorEntity):
_attr_has_entity_name = True
_attr_device_class = BinarySensorDeviceClass.POWER
def __init__(self, coordinator: XT211Coordinator, entry: ConfigEntry, obis: str, meta: dict) -> None:
super().__init__(coordinator)
self._entry = entry
self._obis = obis
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:
obj = (self.coordinator.data or {}).get(self._obis)
if obj is None:
return None
value = obj.get("value")
if isinstance(value, bool):
return value
try:
return int(value) != 0
except (TypeError, ValueError):
return None
@property
def available(self) -> bool:
return self.coordinator.data is not None

View File

@ -1,50 +1,42 @@
"""Config flow for XT211 HAN integration. """Config flow for XT211 HAN integration."""
Discovery order:
1. DHCP discovery automatic, triggered by HA when USR-DR134 appears on network
2. Network scan user clicks "Search network" in the UI
3. Manual entry user types IP + port manually (always available as fallback)
"""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging import logging
import socket import socket
import struct from ipaddress import IPv4Network
from ipaddress import IPv4Network, IPv4Address
from typing import Any from typing import Any
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components import dhcp from homeassistant.components import dhcp
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_NAME from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.data_entry_flow import FlowResult from homeassistant.data_entry_flow import FlowResult
import homeassistant.helpers.config_validation as cv
from .const import ( from .const import (
DOMAIN,
DEFAULT_PORT,
DEFAULT_NAME,
CONF_PHASES,
CONF_HAS_FVE, CONF_HAS_FVE,
CONF_TARIFFS, CONF_PHASES,
CONF_RELAY_COUNT, CONF_RELAY_COUNT,
CONF_TARIFFS,
DEFAULT_NAME,
DEFAULT_PORT,
DOMAIN,
PHASES_1, PHASES_1,
PHASES_3, PHASES_3,
TARIFFS_1,
TARIFFS_2,
TARIFFS_4,
RELAYS_0, RELAYS_0,
RELAYS_4, RELAYS_4,
RELAYS_6, RELAYS_6,
TARIFFS_1,
TARIFFS_2,
TARIFFS_4,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# Known MAC prefixes for USR IOT devices (USR-DR134)
USR_IOT_MAC_PREFIXES = ("d8b04c", "b4e62d") USR_IOT_MAC_PREFIXES = ("d8b04c", "b4e62d")
MANUAL_CHOICE = "__manual__"
STEP_CONNECTION_SCHEMA = vol.Schema( STEP_CONNECTION_SCHEMA = vol.Schema(
{ {
@ -78,17 +70,9 @@ STEP_METER_SCHEMA = vol.Schema(
) )
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _test_connection(host: str, port: int, timeout: float = 5.0) -> str | None: async def _test_connection(host: str, port: int, timeout: float = 5.0) -> str | None:
"""Try TCP connection. Returns error key or None on success."""
try: try:
reader, writer = await asyncio.wait_for( reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=timeout)
asyncio.open_connection(host, port),
timeout=timeout,
)
writer.close() writer.close()
await writer.wait_closed() await writer.wait_closed()
return None return None
@ -96,83 +80,55 @@ async def _test_connection(host: str, port: int, timeout: float = 5.0) -> str |
return "cannot_connect" return "cannot_connect"
except OSError: except OSError:
return "cannot_connect" return "cannot_connect"
except Exception: except Exception: # pragma: no cover - defensive
return "unknown" return "unknown"
async def _scan_network(port: int, timeout: float = 1.0) -> list[str]: async def _scan_network(port: int, timeout: float = 1.0) -> list[str]:
"""
Scan the local network for open TCP port.
Returns list of IP addresses that responded.
"""
# Get real local IP by connecting to a public address (no data sent)
local_ip = "192.168.1.1" local_ip = "192.168.1.1"
try: try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
s.settimeout(0) sock.settimeout(0)
s.connect(("8.8.8.8", 80)) sock.connect(("8.8.8.8", 80))
local_ip = s.getsockname()[0] local_ip = sock.getsockname()[0]
except Exception: except Exception:
try: try:
local_ip = socket.gethostbyname(socket.gethostname()) local_ip = socket.gethostbyname(socket.gethostname())
except Exception: except Exception:
pass pass
_LOGGER.debug("XT211 scan: local IP detected as %s", local_ip)
# Fallback if we still got loopback
if local_ip.startswith("127.") or local_ip == "0.0.0.0": if local_ip.startswith("127.") or local_ip == "0.0.0.0":
local_ip = "192.168.1.1" local_ip = "192.168.1.1"
_LOGGER.warning("XT211 scan: loopback detected, falling back to %s", local_ip)
try: try:
network = IPv4Network(f"{local_ip}/24", strict=False) network = IPv4Network(f"{local_ip}/24", strict=False)
except ValueError: except ValueError:
network = IPv4Network("192.168.1.0/24", strict=False) network = IPv4Network("192.168.1.0/24", strict=False)
_LOGGER.debug("XT211 scan: scanning %s on port %d", network, port)
found: list[str] = [] found: list[str] = []
async def _probe(ip: str) -> None: async def _probe(ip: str) -> None:
try: try:
_, writer = await asyncio.wait_for( _, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
asyncio.open_connection(ip, port),
timeout=timeout,
)
writer.close() writer.close()
try: try:
await writer.wait_closed() await writer.wait_closed()
except Exception: except Exception:
pass pass
found.append(ip) found.append(ip)
_LOGGER.debug("XT211 scan: found device at %s:%d", ip, port)
except Exception: except Exception:
pass pass
# Probe all hosts in /24 concurrently hosts = [str(host) for host in network.hosts()]
hosts = [str(h) for h in network.hosts()] for index in range(0, len(hosts), 50):
# Split into batches to avoid overwhelming the network stack await asyncio.gather(*(_probe(ip) for ip in hosts[index:index + 50]))
batch_size = 50
for i in range(0, len(hosts), batch_size):
batch = hosts[i:i + batch_size]
await asyncio.gather(*[_probe(ip) for ip in batch])
return sorted(found) found.sort()
_LOGGER.debug("XT211 scan found %d host(s) on port %d: %s", len(found), port, found)
return found
# ---------------------------------------------------------------------------
# Config Flow
# ---------------------------------------------------------------------------
class XT211HANConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class XT211HANConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""
Three-path config flow:
- DHCP discovery (automatic)
- Network scan (semi-automatic)
- Manual entry (always available)
"""
VERSION = 1 VERSION = 1
def __init__(self) -> None: def __init__(self) -> None:
@ -181,31 +137,21 @@ class XT211HANConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._discovered_port: int = DEFAULT_PORT self._discovered_port: int = DEFAULT_PORT
self._scan_results: list[str] = [] self._scan_results: list[str] = []
# ------------------------------------------------------------------
# Path 1 DHCP discovery (triggered automatically by HA)
# ------------------------------------------------------------------
async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult:
"""Handle DHCP discovery of a USR IOT device."""
mac = discovery_info.macaddress.replace(":", "").lower() mac = discovery_info.macaddress.replace(":", "").lower()
if not any(mac.startswith(prefix) for prefix in USR_IOT_MAC_PREFIXES): if not any(mac.startswith(prefix) for prefix in USR_IOT_MAC_PREFIXES):
return self.async_abort(reason="not_supported") return self.async_abort(reason="not_supported")
ip = discovery_info.ip ip = discovery_info.ip
_LOGGER.info("XT211 HAN: DHCP discovered USR IOT device at %s (MAC %s)", ip, mac)
# Check not already configured
await self.async_set_unique_id(f"{ip}:{DEFAULT_PORT}") await self.async_set_unique_id(f"{ip}:{DEFAULT_PORT}")
self._abort_if_unique_id_configured(updates={CONF_HOST: ip}) self._abort_if_unique_id_configured(updates={CONF_HOST: ip})
self._discovered_host = ip self._discovered_host = ip
self._discovered_port = DEFAULT_PORT self._discovered_port = DEFAULT_PORT
_LOGGER.info("XT211 HAN: DHCP discovered USR IOT device at %s (MAC %s)", ip, mac)
return await self.async_step_dhcp_confirm() return await self.async_step_dhcp_confirm()
async def async_step_dhcp_confirm( async def async_step_dhcp_confirm(self, user_input: dict[str, Any] | None = None) -> FlowResult:
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Ask user to confirm the DHCP-discovered device."""
if user_input is not None: if user_input is not None:
error = await _test_connection(self._discovered_host, self._discovered_port) error = await _test_connection(self._discovered_host, self._discovered_port)
if error: if error:
@ -232,19 +178,9 @@ class XT211HANConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
}, },
) )
# ------------------------------------------------------------------ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult:
# Path 2 + 3 User-initiated: scan or manual
# ------------------------------------------------------------------
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""First screen: choose between scan or manual entry."""
if user_input is not None: if user_input is not None:
if user_input.get("method") == "scan": return await (self.async_step_scan() if user_input.get("method") == "scan" else self.async_step_manual())
return await self.async_step_scan()
else:
return await self.async_step_manual()
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user",
@ -260,16 +196,12 @@ class XT211HANConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
), ),
) )
# ------------------------------------------------------------------ async def async_step_scan(self, user_input: dict[str, Any] | None = None) -> FlowResult:
# Path 2 Network scan
# ------------------------------------------------------------------
async def async_step_scan(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Scan the local network for devices with the configured port open."""
if user_input is not None: if user_input is not None:
host = user_input[CONF_HOST] host = user_input[CONF_HOST]
if host == MANUAL_CHOICE:
return await self.async_step_manual()
port = user_input.get(CONF_PORT, DEFAULT_PORT) port = user_input.get(CONF_PORT, DEFAULT_PORT)
name = user_input.get(CONF_NAME, DEFAULT_NAME) name = user_input.get(CONF_NAME, DEFAULT_NAME)
@ -280,49 +212,38 @@ class XT211HANConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if error: if error:
return self.async_show_form( return self.async_show_form(
step_id="scan", step_id="scan",
data_schema=self._scan_schema(port), data_schema=self._scan_schema(port, include_choices=not self._scan_results == []),
errors={"base": error}, errors={"base": error},
) )
self._connection_data = { self._connection_data = {CONF_HOST: host, CONF_PORT: port, CONF_NAME: name}
CONF_HOST: host,
CONF_PORT: port,
CONF_NAME: name,
}
return await self.async_step_meter() return await self.async_step_meter()
# Run the scan
_LOGGER.debug("XT211 HAN: scanning network for port %d", DEFAULT_PORT)
self._scan_results = await _scan_network(DEFAULT_PORT) self._scan_results = await _scan_network(DEFAULT_PORT)
_LOGGER.debug("XT211 HAN: scan found %d device(s): %s", len(self._scan_results), self._scan_results)
if not self._scan_results: if not self._scan_results:
# Nothing found fall through to manual with a warning
return self.async_show_form( return self.async_show_form(
step_id="scan", step_id="scan",
data_schema=self._scan_schema(DEFAULT_PORT), data_schema=self._scan_schema(DEFAULT_PORT, include_choices=False),
errors={"base": "no_devices_found"}, errors={"base": "no_devices_found"},
) )
# Build selector: found IPs + option to type manually
choices = {ip: f"{ip}:{DEFAULT_PORT}" for ip in self._scan_results}
choices["manual"] = "✏️ Zadat jinak ručně"
return self.async_show_form( return self.async_show_form(
step_id="scan", step_id="scan",
data_schema=self._scan_schema(DEFAULT_PORT, choices), data_schema=self._scan_schema(DEFAULT_PORT, include_choices=True),
) )
def _scan_schema( def _scan_schema(self, port: int, include_choices: bool) -> vol.Schema:
self, port: int, choices: dict | None = None if include_choices:
) -> vol.Schema: choices = {ip: f"{ip}:{port}" for ip in self._scan_results}
if choices: choices[MANUAL_CHOICE] = "✏️ Zadat IP adresu ručně"
return vol.Schema( return vol.Schema(
{ {
vol.Required(CONF_HOST): vol.In(choices), vol.Required(CONF_HOST): vol.In(choices),
vol.Optional(CONF_PORT, default=port): int,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, vol.Optional(CONF_NAME, default=DEFAULT_NAME): str,
} }
) )
return vol.Schema( return vol.Schema(
{ {
vol.Required(CONF_HOST): str, vol.Required(CONF_HOST): str,
@ -331,16 +252,8 @@ class XT211HANConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
} }
) )
# ------------------------------------------------------------------ async def async_step_manual(self, user_input: dict[str, Any] | None = None) -> FlowResult:
# Path 3 Manual entry
# ------------------------------------------------------------------
async def async_step_manual(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Manual IP + port entry."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
host = user_input[CONF_HOST] host = user_input[CONF_HOST]
port = user_input[CONF_PORT] port = user_input[CONF_PORT]
@ -353,38 +266,17 @@ class XT211HANConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if error: if error:
errors["base"] = error errors["base"] = error
else: else:
self._connection_data = { self._connection_data = {CONF_HOST: host, CONF_PORT: port, CONF_NAME: name}
CONF_HOST: host,
CONF_PORT: port,
CONF_NAME: name,
}
return await self.async_step_meter() return await self.async_step_meter()
return self.async_show_form( return self.async_show_form(step_id="manual", data_schema=STEP_CONNECTION_SCHEMA, errors=errors)
step_id="manual",
data_schema=STEP_CONNECTION_SCHEMA,
errors=errors,
)
# ------------------------------------------------------------------ async def async_step_meter(self, user_input: dict[str, Any] | None = None) -> FlowResult:
# Step: meter configuration (shared by all paths)
# ------------------------------------------------------------------
async def async_step_meter(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Meter type, FVE, tariffs, relays."""
if user_input is not None: if user_input is not None:
data = {**self._connection_data, **user_input} data = {**self._connection_data, **user_input}
name = data.get(CONF_NAME, DEFAULT_NAME) name = data.get(CONF_NAME, DEFAULT_NAME)
host = data[CONF_HOST] host = data[CONF_HOST]
port = data[CONF_PORT] port = data[CONF_PORT]
return self.async_create_entry( return self.async_create_entry(title=f"{name} ({host}:{port})", data=data)
title=f"{name} ({host}:{port})",
data=data,
)
return self.async_show_form( return self.async_show_form(step_id="meter", data_schema=STEP_METER_SCHEMA)
step_id="meter",
data_schema=STEP_METER_SCHEMA,
)

View File

@ -1,51 +1,31 @@
""" """Coordinator for XT211 HAN integration."""
Coordinator for XT211 HAN integration.
Opens a persistent TCP connection to the RS485-to-Ethernet adapter
(e.g. USR-DR134) and receives DLMS/COSEM PUSH frames every 60 seconds.
"""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging import logging
from datetime import timedelta
from typing import Any from typing import Any
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .dlms_parser import DLMSParser, DLMSObject, OBIS_DESCRIPTIONS from .dlms_parser import DLMSObject, DLMSParser, OBIS_DESCRIPTIONS
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# We expect a frame every 60 s; allow some margin before timing out PUSH_TIMEOUT = 90
PUSH_TIMEOUT = 90 # seconds RECONNECT_DELAY = 10
RECONNECT_DELAY = 10 # seconds after connection loss
class XT211Coordinator(DataUpdateCoordinator[dict[str, Any]]): class XT211Coordinator(DataUpdateCoordinator[dict[str, Any]]):
""" """Persistent TCP listener for XT211 DLMS push frames."""
Coordinator that maintains a persistent TCP connection to the
RS485-to-Ethernet adapter and decodes incoming DLMS PUSH frames.
Data is published to HA listeners whenever a new frame arrives, def __init__(self, hass: HomeAssistant, host: str, port: int, name: str) -> None:
not on a fixed poll interval (update_interval=None triggers manual).
"""
def __init__(
self,
hass: HomeAssistant,
host: str,
port: int,
name: str,
) -> None:
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
name=f"XT211 HAN ({host}:{port})", name=f"XT211 HAN ({host}:{port})",
update_interval=None, # push-driven, not poll-driven update_interval=None,
) )
self.host = host self.host = host
self.port = port self.port = port
@ -55,17 +35,13 @@ class XT211Coordinator(DataUpdateCoordinator[dict[str, Any]]):
self._writer: asyncio.StreamWriter | None = None self._writer: asyncio.StreamWriter | None = None
self._listen_task: asyncio.Task | None = None self._listen_task: asyncio.Task | None = None
self._connected = False self._connected = False
self._frames_received = 0
# ------------------------------------------------------------------
# Public helpers
# ------------------------------------------------------------------
@property @property
def connected(self) -> bool: def connected(self) -> bool:
return self._connected return self._connected
async def async_setup(self) -> None: async def async_setup(self) -> None:
"""Start the background listener task."""
if self._listen_task is None or self._listen_task.done(): if self._listen_task is None or self._listen_task.done():
self._listen_task = self.hass.async_create_background_task( self._listen_task = self.hass.async_create_background_task(
self._listen_loop(), self._listen_loop(),
@ -73,7 +49,6 @@ class XT211Coordinator(DataUpdateCoordinator[dict[str, Any]]):
) )
async def async_shutdown(self) -> None: async def async_shutdown(self) -> None:
"""Stop the listener and close the connection."""
if self._listen_task: if self._listen_task:
self._listen_task.cancel() self._listen_task.cancel()
try: try:
@ -82,22 +57,10 @@ class XT211Coordinator(DataUpdateCoordinator[dict[str, Any]]):
pass pass
await self._disconnect() await self._disconnect()
# ------------------------------------------------------------------
# DataUpdateCoordinator required override
# ------------------------------------------------------------------
async def _async_update_data(self) -> dict[str, Any]: async def _async_update_data(self) -> dict[str, Any]:
"""Called by HA when entities want a refresh. Returns current data.""" return self.data or {}
if self.data is None:
return {}
return self.data
# ------------------------------------------------------------------
# TCP listener loop
# ------------------------------------------------------------------
async def _listen_loop(self) -> None: async def _listen_loop(self) -> None:
"""Main loop: connect → receive → reconnect on error."""
while True: while True:
try: try:
await self._connect() await self._connect()
@ -106,11 +69,14 @@ class XT211Coordinator(DataUpdateCoordinator[dict[str, Any]]):
_LOGGER.info("XT211 listener task cancelled") _LOGGER.info("XT211 listener task cancelled")
raise raise
except Exception as exc: except Exception as exc:
self._connected = False
_LOGGER.warning( _LOGGER.warning(
"XT211 connection error (%s:%d): %s retrying in %ds", "XT211 connection error (%s:%d): %s retrying in %ds",
self.host, self.port, exc, RECONNECT_DELAY, self.host,
self.port,
exc,
RECONNECT_DELAY,
) )
self._connected = False
finally: finally:
await self._disconnect() await self._disconnect()
@ -122,8 +88,8 @@ class XT211Coordinator(DataUpdateCoordinator[dict[str, Any]]):
asyncio.open_connection(self.host, self.port), asyncio.open_connection(self.host, self.port),
timeout=10, timeout=10,
) )
self._parser = DLMSParser()
self._connected = True self._connected = True
self._parser = DLMSParser() # reset parser state on new connection
_LOGGER.info("Connected to XT211 adapter at %s:%d", self.host, self.port) _LOGGER.info("Connected to XT211 adapter at %s:%d", self.host, self.port)
async def _disconnect(self) -> None: async def _disconnect(self) -> None:
@ -138,59 +104,68 @@ class XT211Coordinator(DataUpdateCoordinator[dict[str, Any]]):
self._reader = None self._reader = None
async def _receive_loop(self) -> None: async def _receive_loop(self) -> None:
"""Read bytes from the TCP stream and feed them to the parser."""
assert self._reader is not None assert self._reader is not None
while True: while True:
try: try:
chunk = await asyncio.wait_for( chunk = await asyncio.wait_for(self._reader.read(4096), timeout=PUSH_TIMEOUT)
self._reader.read(4096), except asyncio.TimeoutError as exc:
timeout=PUSH_TIMEOUT, _LOGGER.warning("No data from XT211 for %d s reconnecting", PUSH_TIMEOUT)
) raise ConnectionError("Push timeout") from exc
except asyncio.TimeoutError:
_LOGGER.warning(
"No data from XT211 for %d s reconnecting", PUSH_TIMEOUT
)
raise ConnectionError("Push timeout")
if not chunk: if not chunk:
_LOGGER.warning("XT211 adapter closed connection") _LOGGER.warning("XT211 adapter closed connection")
raise ConnectionError("Remote closed") raise ConnectionError("Remote closed")
_LOGGER.debug("XT211 RX %d bytes: %s", len(chunk), chunk.hex())
self._parser.feed(chunk) self._parser.feed(chunk)
# Process all complete frames in the buffer
while True: while True:
result = self._parser.get_frame() result = self._parser.get_frame()
if result is None: if result is None:
break break
self._frames_received += 1
if result.success: if result.success:
_LOGGER.debug(
"XT211 frame #%d parsed OK: %d object(s)",
self._frames_received,
len(result.objects),
)
await self._process_frame(result.objects) await self._process_frame(result.objects)
else: else:
_LOGGER.debug( _LOGGER.debug(
"Frame parse error: %s (raw: %s)", "XT211 frame #%d parse error: %s (raw: %s)",
result.error, result.raw_hex[:80], self._frames_received,
result.error,
result.raw_hex[:120],
) )
async def _process_frame(self, objects: list[DLMSObject]) -> None: async def _process_frame(self, objects: list[DLMSObject]) -> None:
"""Update coordinator data from a decoded DLMS frame."""
if not objects: if not objects:
_LOGGER.debug("Received empty DLMS frame") _LOGGER.debug("Received empty DLMS frame")
return return
current = dict(self.data or {}) current = dict(self.data or {})
changed: list[str] = []
for obj in objects: for obj in objects:
meta = OBIS_DESCRIPTIONS.get(obj.obis, {}) meta = OBIS_DESCRIPTIONS.get(obj.obis, {})
current[obj.obis] = { new_value = {
"value": obj.value, "value": obj.value,
"unit": obj.unit or meta.get("unit", ""), "unit": obj.unit or meta.get("unit", ""),
"name": meta.get("name", obj.obis), "name": meta.get("name", obj.obis),
"class": meta.get("class", "sensor"), "class": meta.get("class", "sensor"),
} }
_LOGGER.debug( if current.get(obj.obis) != new_value:
"OBIS %s = %s %s", obj.obis, obj.value, obj.unit changed.append(obj.obis)
) current[obj.obis] = new_value
_LOGGER.debug("XT211 OBIS %s = %r %s", obj.obis, obj.value, new_value["unit"])
self.async_set_updated_data(current) self.async_set_updated_data(current)
_LOGGER.debug("Updated %d DLMS objects from XT211 frame", len(objects)) _LOGGER.debug(
"Coordinator updated with %d object(s), %d changed: %s",
len(objects),
len(changed),
", ".join(changed[:10]),
)

View File

@ -1,184 +1,90 @@
""" """DLMS/COSEM PUSH parser for Sagemcom XT211 smart meter."""
DLMS/COSEM PUSH mode parser for Sagemcom XT211 smart meter.
The XT211 sends unsolicited HDLC-framed DLMS/COSEM data every 60 seconds
over RS485 (9600 baud, 8N1). This module decodes those frames.
Frame structure (HDLC):
7E - HDLC flag
A0 xx - Frame type + length
00 02 00 01 ... - Destination / source addresses
13 - Control byte (UI frame)
xx xx - HCS (header checksum)
[LLC header] - E6 E7 00
[APDU] - DLMS application data (tag 0F = Data-notification)
xx xx - FCS (frame checksum)
7E - HDLC flag
OBIS codes supported (from ČEZ Distribuce spec):
0-0:96.1.1.255 - Serial number (Device ID)
0-0:96.3.10.255 - Disconnector status
0-0:96.14.0.255 - Current tariff
1-0:1.7.0.255 - Instant active power consumption (W)
1-0:2.7.0.255 - Instant active power delivery (W)
1-0:21.7.0.255 - Instant power L1 (W)
1-0:41.7.0.255 - Instant power L2 (W)
1-0:61.7.0.255 - Instant power L3 (W)
1-0:1.8.0.255 - Active energy consumed (Wh)
1-0:1.8.1.255 - Active energy T1 (Wh)
1-0:1.8.2.255 - Active energy T2 (Wh)
1-0:2.8.0.255 - Active energy delivered (Wh)
0-1:96.3.10.255 - Relay R1 status
0-2:96.3.10.255 - Relay R2 status
0-3:96.3.10.255 - Relay R3 status
0-4:96.3.10.255 - Relay R4 status
"""
from __future__ import annotations from __future__ import annotations
import logging import logging
import struct import struct
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import Any from typing import Any
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
HDLC_FLAG = 0x7E HDLC_FLAG = 0x7E
# DLMS data types
DLMS_TYPE_NULL = 0x00 DLMS_TYPE_NULL = 0x00
DLMS_TYPE_ARRAY = 0x01
DLMS_TYPE_STRUCTURE = 0x02
DLMS_TYPE_BOOL = 0x03 DLMS_TYPE_BOOL = 0x03
DLMS_TYPE_INT32 = 0x05
DLMS_TYPE_UINT32 = 0x06
DLMS_TYPE_OCTET_STRING = 0x09
DLMS_TYPE_VISIBLE_STRING = 0x0A
DLMS_TYPE_INT8 = 0x0F DLMS_TYPE_INT8 = 0x0F
DLMS_TYPE_INT16 = 0x10 DLMS_TYPE_INT16 = 0x10
DLMS_TYPE_UINT8 = 0x11 DLMS_TYPE_UINT8 = 0x11
DLMS_TYPE_UINT16 = 0x12 DLMS_TYPE_UINT16 = 0x12
DLMS_TYPE_INT32 = 0x05 DLMS_TYPE_COMPACT_ARRAY = 0x13
DLMS_TYPE_UINT32 = 0x06
DLMS_TYPE_INT64 = 0x14 DLMS_TYPE_INT64 = 0x14
DLMS_TYPE_UINT64 = 0x15 DLMS_TYPE_UINT64 = 0x15
DLMS_TYPE_FLOAT32 = 0x16 DLMS_TYPE_ENUM = 0x16
DLMS_TYPE_FLOAT64 = 0x17 DLMS_TYPE_FLOAT32 = 0x17
DLMS_TYPE_OCTET_STRING = 0x09 DLMS_TYPE_FLOAT64 = 0x18
DLMS_TYPE_VISIBLE_STRING = 0x0A
DLMS_TYPE_ARRAY = 0x01
DLMS_TYPE_STRUCTURE = 0x02
DLMS_TYPE_COMPACT_ARRAY = 0x13
# SI unit multipliers (DLMS scaler)
# Scaler is a signed int8 representing 10^scaler class NeedMoreData(Exception):
def apply_scaler(value: int | float, scaler: int) -> float: """Raised when the parser needs more bytes to finish a frame."""
"""Apply DLMS scaler (10^scaler) to a raw value."""
return float(value) * (10 ** scaler)
@dataclass @dataclass
class DLMSObject: class DLMSObject:
"""A single decoded DLMS COSEM object.""" obis: str
obis: str # e.g. "1-0:1.8.0.255" value: Any
value: Any # decoded Python value unit: str = ""
unit: str = "" # e.g. "W", "Wh", "" scaler: int = 0
scaler: int = 0 # raw scaler from frame
@dataclass @dataclass
class ParseResult: class ParseResult:
"""Result of parsing one HDLC frame."""
success: bool success: bool
objects: list[DLMSObject] = None objects: list[DLMSObject] = field(default_factory=list)
raw_hex: str = "" raw_hex: str = ""
error: str = "" error: str = ""
def __post_init__(self):
if self.objects is None:
self.objects = []
class DLMSParser: class DLMSParser:
""" """Stateful parser for raw DLMS APDUs and HDLC-wrapped frames."""
Stateful DLMS/COSEM PUSH mode parser for XT211.
Usage:
parser = DLMSParser()
parser.feed(bytes_from_tcp)
while (result := parser.get_frame()):
process(result)
"""
# DLMS unit codes → human readable strings
UNIT_MAP = {
1: "a", 2: "mo", 3: "wk", 4: "d", 5: "h",
6: "min", 7: "s", 8: "°", 9: "°C", 10: "currency",
11: "m", 12: "m/s", 13: "", 14: "", 15: "m³/h",
16: "m³/h", 17: "m³/d", 18: "m³/d", 19: "l", 20: "kg",
21: "N", 22: "Nm", 23: "Pa", 24: "bar", 25: "J",
26: "J/h", 27: "W", 28: "VA", 29: "var", 30: "Wh",
31: "VAh", 32: "varh", 33: "A", 34: "C", 35: "V",
36: "V/m", 37: "F", 38: "Ω", 39: "Ωm²/m",40: "Wb",
41: "T", 42: "A/m", 43: "H", 44: "Hz", 45: "1/Wh",
46: "1/varh",47: "1/VAh",48: "V²h", 49: "A²h", 50: "kg/s",
51: "S", 52: "K", 53: "1/(V²h)",54: "1/(A²h)",
255: "", 0: "",
}
def __init__(self) -> None: def __init__(self) -> None:
self._buffer = bytearray() self._buffer = bytearray()
def feed(self, data: bytes) -> None: def feed(self, data: bytes) -> None:
"""Add raw bytes from TCP socket to the internal buffer."""
self._buffer.extend(data) self._buffer.extend(data)
def get_frame(self) -> ParseResult | None: def get_frame(self) -> ParseResult | None:
""" """Return one parsed frame from the internal buffer, if available."""
Try to extract and parse one complete frame from the buffer. if not self._buffer:
Supports two formats:
1. HDLC-wrapped: 7E A0 xx ... 7E
2. Raw DLMS APDU: 0F [4B invoke-id] [optional datetime] [body]
(USR-DR134 strips the HDLC wrapper and sends raw APDU)
"""
buf = self._buffer
if not buf:
return None return None
# ---------------------------------------------------------------- if self._buffer[0] == HDLC_FLAG:
# Format 2: Raw DLMS APDU starting with 0x0F (Data-Notification) return self._get_hdlc_frame()
# USR-DR134 sends this directly without HDLC framing
# ----------------------------------------------------------------
if buf[0] == 0x0F:
# We need at least 5 bytes (tag + 4B invoke-id)
if len(buf) < 5:
return None
# Heuristic: find the end of this APDU start = self._find_apdu_start(self._buffer)
# The USR-DR134 sends one complete APDU per TCP segment
# We consume everything in the buffer as one frame
raw = bytes(buf)
self._buffer.clear()
raw_hex = raw.hex()
_LOGGER.debug("Raw DLMS APDU (%d bytes): %s", len(raw), raw_hex[:80])
try:
result = self._parse_apdu(raw)
result.raw_hex = raw_hex
return result
except Exception as exc:
_LOGGER.exception("Error parsing raw DLMS APDU")
return ParseResult(success=False, raw_hex=raw_hex, error=str(exc))
# ----------------------------------------------------------------
# Format 1: HDLC-wrapped frame starting with 0x7E
# ----------------------------------------------------------------
start = buf.find(HDLC_FLAG)
if start == -1: if start == -1:
_LOGGER.debug("Discarding %d bytes without known frame start", len(self._buffer))
self._buffer.clear() self._buffer.clear()
return None return None
if start > 0:
_LOGGER.debug("Discarding %d bytes before HDLC flag", start)
del self._buffer[:start]
buf = self._buffer
if start > 0:
_LOGGER.debug("Discarding %d leading byte(s) before APDU", start)
del self._buffer[:start]
if self._buffer and self._buffer[0] == 0x0F:
return self._get_raw_apdu_frame()
return None
def _get_hdlc_frame(self) -> ParseResult | None:
buf = self._buffer
if len(buf) < 3: if len(buf) < 3:
return None return None
@ -188,10 +94,8 @@ class DLMSParser:
return None return None
raw = bytes(buf[:total]) raw = bytes(buf[:total])
del self._buffer[:total] del buf[:total]
raw_hex = raw.hex() raw_hex = raw.hex()
_LOGGER.debug("HDLC frame (%d bytes): %s", len(raw), raw_hex[:80])
if raw[0] != HDLC_FLAG or raw[-1] != HDLC_FLAG: if raw[0] != HDLC_FLAG or raw[-1] != HDLC_FLAG:
return ParseResult(success=False, raw_hex=raw_hex, error="Missing HDLC flags") return ParseResult(success=False, raw_hex=raw_hex, error="Missing HDLC flags")
@ -200,312 +104,282 @@ class DLMSParser:
result = self._parse_hdlc(raw) result = self._parse_hdlc(raw)
result.raw_hex = raw_hex result.raw_hex = raw_hex
return result return result
except Exception as exc: except NeedMoreData:
# Should not happen for HDLC because total length is known.
return None
except Exception as exc: # pragma: no cover - defensive logging
_LOGGER.exception("Error parsing HDLC frame") _LOGGER.exception("Error parsing HDLC frame")
return ParseResult(success=False, raw_hex=raw_hex, error=str(exc)) return ParseResult(success=False, raw_hex=raw_hex, error=str(exc))
# ------------------------------------------------------------------ def _get_raw_apdu_frame(self) -> ParseResult | None:
# Internal parsing methods buf = self._buffer
# ------------------------------------------------------------------ try:
result, consumed = self._parse_apdu_with_length(bytes(buf))
except NeedMoreData:
return None
except Exception as exc: # pragma: no cover - defensive logging
raw_hex = bytes(buf).hex()
_LOGGER.exception("Error parsing raw DLMS APDU")
del buf[:]
return ParseResult(success=False, raw_hex=raw_hex, error=str(exc))
raw = bytes(buf[:consumed])
del buf[:consumed]
result.raw_hex = raw.hex()
return result
def _parse_hdlc(self, raw: bytes) -> ParseResult: def _parse_hdlc(self, raw: bytes) -> ParseResult:
"""Parse full HDLC frame and extract DLMS objects.""" pos = 1
pos = 1 # skip opening flag pos += 2 # frame format
_, pos = self._read_hdlc_address(raw, pos)
_, pos = self._read_hdlc_address(raw, pos)
pos += 1 # control
pos += 2 # HCS
# Frame format byte (should be A0 or A8)
# bits 11-0 = length
_frame_type = raw[pos] & 0xF8
frame_len = ((raw[pos] & 0x07) << 8) | raw[pos + 1]
pos += 2
# Destination address (variable length, LSB=1 means last byte)
dest_addr, pos = self._read_hdlc_address(raw, pos)
# Source address
src_addr, pos = self._read_hdlc_address(raw, pos)
# Control byte
control = raw[pos]; pos += 1
# HCS (2 bytes header checksum) - skip
pos += 2
# From here: LLC + APDU
# LLC header: E6 E7 00 (or E6 E6 00 for request)
if pos + 3 > len(raw) - 3: if pos + 3 > len(raw) - 3:
return ParseResult(success=False, error="Frame too short for LLC") raise ValueError("Frame too short for LLC")
llc = raw[pos:pos+3]; pos += 3 pos += 3 # LLC header
_LOGGER.debug("LLC: %s dest=%s src=%s", llc.hex(), dest_addr, src_addr)
# APDU starts here, ends 3 bytes before end (FCS + closing flag)
apdu = raw[pos:-3] apdu = raw[pos:-3]
_LOGGER.debug("APDU (%d bytes): %s", len(apdu), apdu.hex()) result, _ = self._parse_apdu_with_length(apdu)
return result
return self._parse_apdu(apdu)
def _read_hdlc_address(self, data: bytes, pos: int) -> tuple[int, int]: def _read_hdlc_address(self, data: bytes, pos: int) -> tuple[int, int]:
"""Read HDLC variable-length address. Returns (address_value, new_pos)."""
addr = 0 addr = 0
shift = 0 shift = 0
while pos < len(data): while True:
byte = data[pos]; pos += 1 if pos >= len(data):
raise NeedMoreData
byte = data[pos]
pos += 1
addr |= (byte >> 1) << shift addr |= (byte >> 1) << shift
shift += 7 shift += 7
if byte & 0x01: # last byte of address if byte & 0x01:
break
return addr, pos return addr, pos
def _parse_apdu(self, apdu: bytes) -> ParseResult: def _parse_apdu_with_length(self, apdu: bytes) -> tuple[ParseResult, int]:
"""
Parse DLMS APDU (Data-Notification = tag 0x0F).
XT211 frame structure:
0F - Data-Notification tag
[4B invoke-id] - MSB set = data frame, clear = push-setup (skip)
00 - datetime absent
02 02 - outer structure(2)
16 [push_type] - elem[0]: enum (push type, ignore)
01 [N] - elem[1]: array(N captured objects)
[N x object] - see _parse_xt211_object
Each captured object (11-byte header + type-tagged value):
02 02 00 - structure prefix (3 bytes, ignored)
[class_id] - 1 byte DLMS class ID
[A B C D E F] - 6-byte raw OBIS (NO type tag!)
[attr_idx] - 1 byte attribute index (ignored)
[type][value bytes] - standard DLMS type-tagged value
"""
if not apdu: if not apdu:
return ParseResult(success=False, objects=[], error="Empty APDU") raise NeedMoreData
if apdu[0] != 0x0F: if apdu[0] != 0x0F:
return ParseResult( raise ValueError(f"Unexpected APDU tag 0x{apdu[0]:02X}")
success=False, objects=[],
error=f"Unexpected APDU tag 0x{apdu[0]:02X} (expected 0x0F)"
)
if len(apdu) < 6: if len(apdu) < 6:
return ParseResult(success=False, objects=[], error="APDU too short") raise NeedMoreData
pos = 1 pos = 1
invoke_id = struct.unpack_from(">I", apdu, pos)[0]; pos += 4 invoke_id = struct.unpack_from(">I", apdu, pos)[0]
_LOGGER.debug("Invoke ID: 0x%08X", invoke_id) pos += 4
_LOGGER.debug("XT211 invoke_id=0x%08X", invoke_id)
# Skip push-setup frames (invoke_id MSB = 0) if pos >= len(apdu):
#if not (invoke_id & 0x80000000): raise NeedMoreData
# _LOGGER.debug("Push-setup frame, skipping")
# return ParseResult(success=True, objects=[])
# Datetime: 0x09 = octet-string, 0x00 = absent if apdu[pos] == DLMS_TYPE_OCTET_STRING:
if pos < len(apdu) and apdu[pos] == 0x09:
pos += 1 pos += 1
dt_len = apdu[pos]; pos += 1 + dt_len dt_len, pos = self._decode_length(apdu, pos)
elif pos < len(apdu) and apdu[pos] == 0x00: self._require(apdu, pos, dt_len)
pos += dt_len
elif apdu[pos] == DLMS_TYPE_NULL:
pos += 1 pos += 1
# Outer structure(2): skip tag + count self._require(apdu, pos, 2)
if pos + 2 > len(apdu) or apdu[pos] != 0x02: if apdu[pos] != DLMS_TYPE_STRUCTURE:
return ParseResult(success=True, objects=[]) return ParseResult(success=True, objects=[]), pos
pos += 2 # 02 02 structure_count = apdu[pos + 1]
# Element[0]: enum = push type (skip 2 bytes: 16 XX)
if pos < len(apdu) and apdu[pos] == 0x16:
pos += 2 pos += 2
if structure_count < 2:
return ParseResult(success=True, objects=[]), pos
# Element[1]: array of captured objects if pos >= len(apdu):
if pos >= len(apdu) or apdu[pos] != 0x01: raise NeedMoreData
return ParseResult(success=True, objects=[]) if apdu[pos] == DLMS_TYPE_ENUM:
self._require(apdu, pos, 2)
pos += 2
else:
_, pos = self._decode_value(apdu, pos)
if pos >= len(apdu):
raise NeedMoreData
if apdu[pos] != DLMS_TYPE_ARRAY:
return ParseResult(success=True, objects=[]), pos
pos += 1 pos += 1
array_count, pos = self._decode_length(apdu, pos) array_count, pos = self._decode_length(apdu, pos)
_LOGGER.debug("Array count: %d objects", array_count) objects: list[DLMSObject] = []
for _ in range(array_count):
objects = []
for i in range(array_count):
if pos + 11 > len(apdu):
break
try:
obj, pos = self._parse_xt211_object(apdu, pos) obj, pos = self._parse_xt211_object(apdu, pos)
if obj: if obj is not None:
objects.append(obj) objects.append(obj)
_LOGGER.debug("OBIS %s = %s %s", obj.obis, obj.value, obj.unit)
except Exception as exc:
_LOGGER.debug("Error parsing object %d at pos %d: %s", i, pos, exc)
break
return ParseResult(success=True, objects=objects) return ParseResult(success=True, objects=objects), pos
def _parse_xt211_object(self, data: bytes, pos: int) -> tuple[DLMSObject | None, int]: def _parse_xt211_object(self, data: bytes, pos: int) -> tuple[DLMSObject | None, int]:
""" self._require(data, pos, 1)
Parse one captured object from XT211 push notification. if data[pos] != DLMS_TYPE_STRUCTURE:
raise ValueError(f"Expected object structure at {pos}, got 0x{data[pos]:02X}")
Format per object:
02 02 00 - 3-byte structure prefix (ignored)
[class_id] - 1 byte
[A B C D E F] - 6-byte raw OBIS (no type tag)
[attr_idx] - 1 byte (ignored)
[type][value] - DLMS type-tagged value
"""
if data[pos] != 0x02:
_LOGGER.debug("Expected 0x02 at pos %d, got 0x%02X", pos, data[pos])
return None, pos + 1
pos += 3 # skip: 02 02 00
# Class ID
pos += 1 # class_id (not needed for value extraction)
# Raw OBIS (6 bytes, no type tag)
if pos + 6 > len(data):
return None, pos
obis_raw = data[pos:pos+6]; pos += 6
obis_str = self._format_obis(obis_raw)
# Attribute index (skip)
pos += 1 pos += 1
# Type-tagged value count, pos = self._decode_length(data, pos)
if count < 1:
raise ValueError(f"Unexpected object element count {count}")
# XT211 measurement objects use a raw descriptor layout:
# 02 02 00 [class_id_hi class_id_lo] [6B OBIS] [attr_idx] [typed value]
if pos < len(data) and data[pos] == 0x00:
if pos + 10 > len(data):
raise NeedMoreData
class_id = int.from_bytes(data[pos:pos + 2], "big")
pos += 2
obis_raw = bytes(data[pos:pos + 6])
pos += 6
_attr_idx = data[pos]
pos += 1
value, pos = self._decode_value(data, pos) value, pos = self._decode_value(data, pos)
# Convert bytes to string for text objects
if isinstance(value, (bytes, bytearray)): if isinstance(value, (bytes, bytearray)):
try: try:
value = value.decode("ascii", errors="replace").strip("\x00") value = bytes(value).decode("ascii", errors="replace").strip("\x00")
except Exception: except Exception:
value = value.hex() value = bytes(value).hex()
meta = OBIS_DESCRIPTIONS.get(obis_str, {}) obis = self._format_obis(obis_raw)
meta = OBIS_DESCRIPTIONS.get(obis, {})
_LOGGER.debug(
"Parsed XT211 object class_id=%s obis=%s value=%r unit=%s",
class_id,
obis,
value,
meta.get("unit", ""),
)
return DLMSObject( return DLMSObject(
obis=obis_str, obis=obis,
value=value, value=value,
unit=meta.get("unit", ""), unit=meta.get("unit", ""),
scaler=0, scaler=0,
), pos ), pos
# Short housekeeping frames use simple typed structures without OBIS.
def _decode_value(self, data: bytes, pos: int) -> tuple[Any, int]: # Consume them cleanly and ignore them.
"""Recursively decode a DLMS typed value. Returns (value, new_pos).""" last_value: Any = None
if pos >= len(data): for _ in range(count):
last_value, pos = self._decode_value(data, pos)
_LOGGER.debug("Ignoring non-measurement structure value=%r", last_value)
return None, pos return None, pos
dtype = data[pos]; pos += 1 def _decode_value(self, data: bytes, pos: int) -> tuple[Any, int]:
self._require(data, pos, 1)
dtype = data[pos]
pos += 1
if dtype == DLMS_TYPE_NULL: if dtype == DLMS_TYPE_NULL:
return None, pos return None, pos
elif dtype == DLMS_TYPE_BOOL: if dtype == DLMS_TYPE_BOOL:
self._require(data, pos, 1)
return bool(data[pos]), pos + 1 return bool(data[pos]), pos + 1
elif dtype == DLMS_TYPE_INT8: if dtype == DLMS_TYPE_INT8:
self._require(data, pos, 1)
return struct.unpack_from(">b", data, pos)[0], pos + 1 return struct.unpack_from(">b", data, pos)[0], pos + 1
elif dtype == DLMS_TYPE_UINT8: if dtype == DLMS_TYPE_UINT8 or dtype == DLMS_TYPE_ENUM:
self._require(data, pos, 1)
return data[pos], pos + 1 return data[pos], pos + 1
elif dtype == 0x16: # enum = uint8 if dtype == DLMS_TYPE_INT16:
return data[pos], pos + 1 self._require(data, pos, 2)
elif dtype == DLMS_TYPE_INT16:
return struct.unpack_from(">h", data, pos)[0], pos + 2 return struct.unpack_from(">h", data, pos)[0], pos + 2
elif dtype == DLMS_TYPE_UINT16: if dtype == DLMS_TYPE_UINT16:
self._require(data, pos, 2)
return struct.unpack_from(">H", data, pos)[0], pos + 2 return struct.unpack_from(">H", data, pos)[0], pos + 2
elif dtype == DLMS_TYPE_INT32: if dtype == DLMS_TYPE_INT32:
self._require(data, pos, 4)
return struct.unpack_from(">i", data, pos)[0], pos + 4 return struct.unpack_from(">i", data, pos)[0], pos + 4
elif dtype == DLMS_TYPE_UINT32: if dtype == DLMS_TYPE_UINT32:
self._require(data, pos, 4)
return struct.unpack_from(">I", data, pos)[0], pos + 4 return struct.unpack_from(">I", data, pos)[0], pos + 4
elif dtype == DLMS_TYPE_INT64: if dtype == DLMS_TYPE_INT64:
self._require(data, pos, 8)
return struct.unpack_from(">q", data, pos)[0], pos + 8 return struct.unpack_from(">q", data, pos)[0], pos + 8
elif dtype == DLMS_TYPE_UINT64: if dtype == DLMS_TYPE_UINT64:
self._require(data, pos, 8)
return struct.unpack_from(">Q", data, pos)[0], pos + 8 return struct.unpack_from(">Q", data, pos)[0], pos + 8
elif dtype == DLMS_TYPE_FLOAT32: if dtype == DLMS_TYPE_FLOAT32:
self._require(data, pos, 4)
return struct.unpack_from(">f", data, pos)[0], pos + 4 return struct.unpack_from(">f", data, pos)[0], pos + 4
elif dtype == DLMS_TYPE_FLOAT64: if dtype == DLMS_TYPE_FLOAT64:
self._require(data, pos, 8)
return struct.unpack_from(">d", data, pos)[0], pos + 8 return struct.unpack_from(">d", data, pos)[0], pos + 8
elif dtype in (DLMS_TYPE_OCTET_STRING, DLMS_TYPE_VISIBLE_STRING): if dtype in (DLMS_TYPE_OCTET_STRING, DLMS_TYPE_VISIBLE_STRING):
length, pos = self._decode_length(data, pos) length, pos = self._decode_length(data, pos)
raw_bytes = data[pos:pos+length] self._require(data, pos, length)
raw = data[pos:pos + length]
pos += length pos += length
if dtype == DLMS_TYPE_VISIBLE_STRING: if dtype == DLMS_TYPE_VISIBLE_STRING:
try: return raw.decode("ascii", errors="replace"), pos
return raw_bytes.decode("ascii", errors="replace"), pos return bytes(raw), pos
except Exception: if dtype in (DLMS_TYPE_ARRAY, DLMS_TYPE_STRUCTURE, DLMS_TYPE_COMPACT_ARRAY):
return raw_bytes.hex(), pos
return raw_bytes, pos
elif dtype in (DLMS_TYPE_ARRAY, DLMS_TYPE_STRUCTURE, DLMS_TYPE_COMPACT_ARRAY):
count, pos = self._decode_length(data, pos) count, pos = self._decode_length(data, pos)
items = [] items: list[Any] = []
for _ in range(count): for _ in range(count):
val, pos = self._decode_value(data, pos) item, pos = self._decode_value(data, pos)
items.append(val) items.append(item)
return items, pos return items, pos
else:
_LOGGER.debug("Unknown DLMS type 0x%02X at pos %d, skipping", dtype, pos) raise ValueError(f"Unknown DLMS type 0x{dtype:02X} at pos {pos - 1}")
return None, pos
def _decode_length(self, data: bytes, pos: int) -> tuple[int, int]: def _decode_length(self, data: bytes, pos: int) -> tuple[int, int]:
"""Decode BER-style length field.""" self._require(data, pos, 1)
first = data[pos]; pos += 1 first = data[pos]
pos += 1
if first < 0x80: if first < 0x80:
return first, pos return first, pos
num_bytes = first & 0x7F num_bytes = first & 0x7F
self._require(data, pos, num_bytes)
length = 0 length = 0
for _ in range(num_bytes): for _ in range(num_bytes):
length = (length << 8) | data[pos]; pos += 1 length = (length << 8) | data[pos]
pos += 1
return length, pos return length, pos
def _require(self, data: bytes, pos: int, count: int) -> None:
if pos + count > len(data):
raise NeedMoreData
"""Convert 6 raw bytes to OBIS string notation A-B:C.D.E.F""" def _find_apdu_start(self, data: bytes) -> int:
try:
return data.index(0x0F)
except ValueError:
return -1
def _format_obis(self, raw: bytes) -> str:
if len(raw) != 6: if len(raw) != 6:
return raw.hex() return raw.hex()
a, b, c, d, e, f = raw a, b, c, d, e, f = raw
return f"{a}-{b}:{c}.{d}.{e}.{f}" return f"{a}-{b}:{c}.{d}.{e}.{f}"
# --------------------------------------------------------------------------- OBIS_DESCRIPTIONS: dict[str, dict[str, str]] = {
# Convenience: known OBIS codes for the XT211
# ---------------------------------------------------------------------------
OBIS_DESCRIPTIONS: dict[str, dict] = {
# --- Idx 1: COSEM logical device name ---
"0-0:42.0.0.255": {"name": "Název zařízení", "unit": "", "class": "text"}, "0-0:42.0.0.255": {"name": "Název zařízení", "unit": "", "class": "text"},
# --- Idx 3: Serial number ---
"0-0:96.1.0.255": {"name": "Výrobní číslo", "unit": "", "class": "text"}, "0-0:96.1.0.255": {"name": "Výrobní číslo", "unit": "", "class": "text"},
"0-0:96.1.1.255": {"name": "Výrobní číslo", "unit": "", "class": "text"},
# --- Idx 4: Disconnector ---
"0-0:96.3.10.255": {"name": "Stav odpojovače", "unit": "", "class": "binary"}, "0-0:96.3.10.255": {"name": "Stav odpojovače", "unit": "", "class": "binary"},
# --- Idx 5: Power limiter ---
"0-0:17.0.0.255": {"name": "Limitér", "unit": "W", "class": "power"}, "0-0:17.0.0.255": {"name": "Limitér", "unit": "W", "class": "power"},
# --- Idx 611: Relays R1R6 ---
"0-1:96.3.10.255": {"name": "Stav relé R1", "unit": "", "class": "binary"}, "0-1:96.3.10.255": {"name": "Stav relé R1", "unit": "", "class": "binary"},
"0-2:96.3.10.255": {"name": "Stav relé R2", "unit": "", "class": "binary"}, "0-2:96.3.10.255": {"name": "Stav relé R2", "unit": "", "class": "binary"},
"0-3:96.3.10.255": {"name": "Stav relé R3", "unit": "", "class": "binary"}, "0-3:96.3.10.255": {"name": "Stav relé R3", "unit": "", "class": "binary"},
"0-4:96.3.10.255": {"name": "Stav relé R4", "unit": "", "class": "binary"}, "0-4:96.3.10.255": {"name": "Stav relé R4", "unit": "", "class": "binary"},
"0-5:96.3.10.255": {"name": "Stav relé R5", "unit": "", "class": "binary"}, "0-5:96.3.10.255": {"name": "Stav relé R5", "unit": "", "class": "binary"},
"0-6:96.3.10.255": {"name": "Stav relé R6", "unit": "", "class": "binary"}, "0-6:96.3.10.255": {"name": "Stav relé R6", "unit": "", "class": "binary"},
# --- Idx 12: Active tariff ---
"0-0:96.14.0.255": {"name": "Aktuální tarif", "unit": "", "class": "text"}, "0-0:96.14.0.255": {"name": "Aktuální tarif", "unit": "", "class": "text"},
# --- Idx 1316: Instant power import (odběr) ---
"1-0:1.7.0.255": {"name": "Okamžitý příkon odběru celkem", "unit": "W", "class": "power"}, "1-0:1.7.0.255": {"name": "Okamžitý příkon odběru celkem", "unit": "W", "class": "power"},
"1-0:21.7.0.255": {"name": "Okamžitý příkon odběru L1", "unit": "W", "class": "power"}, "1-0:21.7.0.255": {"name": "Okamžitý příkon odběru L1", "unit": "W", "class": "power"},
"1-0:41.7.0.255": {"name": "Okamžitý příkon odběru L2", "unit": "W", "class": "power"}, "1-0:41.7.0.255": {"name": "Okamžitý příkon odběru L2", "unit": "W", "class": "power"},
"1-0:61.7.0.255": {"name": "Okamžitý příkon odběru L3", "unit": "W", "class": "power"}, "1-0:61.7.0.255": {"name": "Okamžitý příkon odběru L3", "unit": "W", "class": "power"},
# --- Idx 1720: Instant power export (dodávka / FVE) ---
"1-0:2.7.0.255": {"name": "Okamžitý výkon dodávky celkem", "unit": "W", "class": "power"}, "1-0:2.7.0.255": {"name": "Okamžitý výkon dodávky celkem", "unit": "W", "class": "power"},
"1-0:22.7.0.255": {"name": "Okamžitý výkon dodávky L1", "unit": "W", "class": "power"}, "1-0:22.7.0.255": {"name": "Okamžitý výkon dodávky L1", "unit": "W", "class": "power"},
"1-0:42.7.0.255": {"name": "Okamžitý výkon dodávky L2", "unit": "W", "class": "power"}, "1-0:42.7.0.255": {"name": "Okamžitý výkon dodávky L2", "unit": "W", "class": "power"},
"1-0:62.7.0.255": {"name": "Okamžitý výkon dodávky L3", "unit": "W", "class": "power"}, "1-0:62.7.0.255": {"name": "Okamžitý výkon dodávky L3", "unit": "W", "class": "power"},
# --- Idx 2125: Cumulative energy import (odběr kWh) ---
"1-0:1.8.0.255": {"name": "Spotřeba energie celkem", "unit": "Wh", "class": "energy"}, "1-0:1.8.0.255": {"name": "Spotřeba energie celkem", "unit": "Wh", "class": "energy"},
"1-0:1.8.1.255": {"name": "Spotřeba energie T1", "unit": "Wh", "class": "energy"}, "1-0:1.8.1.255": {"name": "Spotřeba energie T1", "unit": "Wh", "class": "energy"},
"1-0:1.8.2.255": {"name": "Spotřeba energie T2", "unit": "Wh", "class": "energy"}, "1-0:1.8.2.255": {"name": "Spotřeba energie T2", "unit": "Wh", "class": "energy"},
"1-0:1.8.3.255": {"name": "Spotřeba energie T3", "unit": "Wh", "class": "energy"}, "1-0:1.8.3.255": {"name": "Spotřeba energie T3", "unit": "Wh", "class": "energy"},
"1-0:1.8.4.255": {"name": "Spotřeba energie T4", "unit": "Wh", "class": "energy"}, "1-0:1.8.4.255": {"name": "Spotřeba energie T4", "unit": "Wh", "class": "energy"},
# --- Idx 26: Cumulative energy export (dodávka kWh) ---
"1-0:2.8.0.255": {"name": "Dodávka energie celkem", "unit": "Wh", "class": "energy"}, "1-0:2.8.0.255": {"name": "Dodávka energie celkem", "unit": "Wh", "class": "energy"},
# --- Idx 27: Consumer message ---
"0-0:96.13.0.255": {"name": "Zpráva pro zákazníka", "unit": "", "class": "text"}, "0-0:96.13.0.255": {"name": "Zpráva pro zákazníka", "unit": "", "class": "text"},
} }

View File

@ -1,16 +1,22 @@
{ {
"domain": "xt211_han", "domain": "xt211_han",
"name": "XT211 HAN (RS485 via Ethernet)", "name": "XT211 HAN (RS485 via Ethernet)",
"version": "0.7.5", "version": "0.7.6",
"documentation": "https://github.com/nero150/xt211-han-ha", "documentation": "https://github.com/nero150/xt211-han-ha",
"issue_tracker": "https://github.com/nero150/xt211-han-ha/issues", "issue_tracker": "https://github.com/nero150/xt211-han-ha/issues",
"dependencies": [], "dependencies": [],
"codeowners": ["@nero150"], "codeowners": [
"@nero150"
],
"requirements": [], "requirements": [],
"iot_class": "local_push", "iot_class": "local_push",
"config_flow": true, "config_flow": true,
"dhcp": [ "dhcp": [
{"macaddress": "D8B04C*"}, {
{"macaddress": "B4E62D*"} "macaddress": "D8B04C*"
},
{
"macaddress": "B4E62D*"
}
] ]
} }

View File

@ -1,53 +1,28 @@
"""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 from __future__ import annotations
import logging from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
from typing import Any
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import CONF_NAME, EntityCategory, UnitOfEnergy, UnitOfPower
CONF_NAME,
EntityCategory,
UnitOfEnergy,
UnitOfPower,
)
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ( from .const import (
DOMAIN,
CONF_PHASES,
CONF_HAS_FVE, CONF_HAS_FVE,
CONF_TARIFFS, CONF_PHASES,
CONF_RELAY_COUNT, CONF_RELAY_COUNT,
CONF_TARIFFS,
DOMAIN,
PHASES_3, PHASES_3,
TARIFFS_2,
RELAYS_4, RELAYS_4,
TARIFFS_2,
) )
from .coordinator import XT211Coordinator from .coordinator import XT211Coordinator
from .dlms_parser import OBIS_DESCRIPTIONS from .dlms_parser import OBIS_DESCRIPTIONS
_LOGGER = logging.getLogger(__name__)
# Map OBIS "class" → HA SensorDeviceClass + StateClass + unit
SENSOR_META: dict[str, dict] = { SENSOR_META: dict[str, dict] = {
"power": { "power": {
"device_class": SensorDeviceClass.POWER, "device_class": SensorDeviceClass.POWER,
@ -66,23 +41,22 @@ SENSOR_META: dict[str, dict] = {
}, },
} }
# OBIS codes that send text values (not numeric)
TEXT_OBIS = { TEXT_OBIS = {
"0-0:42.0.0.255", # COSEM logical device name "0-0:42.0.0.255",
"0-0:96.1.0.255", # Serial number "0-0:96.1.0.255",
"0-0:96.14.0.255", # Current tariff "0-0:96.1.1.255",
"0-0:96.13.0.255", # Consumer message "0-0:96.14.0.255",
"0-0:96.13.0.255",
} }
# OBIS codes that are binary (on/off)
BINARY_OBIS = { BINARY_OBIS = {
"0-0:96.3.10.255", # Disconnector "0-0:96.3.10.255",
"0-1:96.3.10.255", # Relay R1 "0-1:96.3.10.255",
"0-2:96.3.10.255", # Relay R2 "0-2:96.3.10.255",
"0-3:96.3.10.255", # Relay R3 "0-3:96.3.10.255",
"0-4:96.3.10.255", # Relay R4 "0-4:96.3.10.255",
"0-5:96.3.10.255", # Relay R5 "0-5:96.3.10.255",
"0-6:96.3.10.255", # Relay R6 "0-6:96.3.10.255",
} }
@ -95,33 +69,24 @@ def _device_info(entry: ConfigEntry) -> DeviceInfo:
) )
async def async_setup_entry( def build_enabled_obis(entry: ConfigEntry) -> set[str]:
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up all XT211 HAN entities from a config entry, filtered by meter config."""
coordinator: XT211Coordinator = hass.data[DOMAIN][entry.entry_id]
phases = entry.data.get(CONF_PHASES, PHASES_3) phases = entry.data.get(CONF_PHASES, PHASES_3)
has_fve = entry.data.get(CONF_HAS_FVE, True) has_fve = entry.data.get(CONF_HAS_FVE, True)
tariffs = int(entry.data.get(CONF_TARIFFS, TARIFFS_2)) tariffs = int(entry.data.get(CONF_TARIFFS, TARIFFS_2))
relay_count = int(entry.data.get(CONF_RELAY_COUNT, RELAYS_4)) relay_count = int(entry.data.get(CONF_RELAY_COUNT, RELAYS_4))
# Build set of OBIS codes to include based on user config enabled_obis: set[str] = {
enabled_obis: set[str] = set()
# Always include: device name, serial, tariff, consumer message, disconnector, limiter
enabled_obis.update({
"0-0:42.0.0.255", "0-0:42.0.0.255",
"0-0:96.1.0.255", "0-0:96.1.0.255",
"0-0:96.1.1.255",
"0-0:96.14.0.255", "0-0:96.14.0.255",
"0-0:96.13.0.255", "0-0:96.13.0.255",
"0-0:96.3.10.255", "0-0:96.3.10.255",
"0-0:17.0.0.255", "0-0:17.0.0.255",
}) "1-0:1.7.0.255",
"1-0:1.8.0.255",
}
# Relays according to relay_count
relay_obis = { relay_obis = {
1: "0-1:96.3.10.255", 1: "0-1:96.3.10.255",
2: "0-2:96.3.10.255", 2: "0-2:96.3.10.255",
@ -130,215 +95,96 @@ async def async_setup_entry(
5: "0-5:96.3.10.255", 5: "0-5:96.3.10.255",
6: "0-6:96.3.10.255", 6: "0-6:96.3.10.255",
} }
for i in range(1, relay_count + 1): for idx in range(1, relay_count + 1):
enabled_obis.add(relay_obis[i]) enabled_obis.add(relay_obis[idx])
# Instant power import total always included
enabled_obis.add("1-0:1.7.0.255")
if phases == PHASES_3: if phases == PHASES_3:
enabled_obis.update({"1-0:21.7.0.255", "1-0:41.7.0.255", "1-0:61.7.0.255"}) enabled_obis.update({"1-0:21.7.0.255", "1-0:41.7.0.255", "1-0:61.7.0.255"})
# Instant power export only with FVE
if has_fve: if has_fve:
enabled_obis.add("1-0:2.7.0.255") enabled_obis.add("1-0:2.7.0.255")
enabled_obis.add("1-0:2.8.0.255")
if phases == PHASES_3: if phases == PHASES_3:
enabled_obis.update({"1-0:22.7.0.255", "1-0:42.7.0.255", "1-0:62.7.0.255"}) enabled_obis.update({"1-0:22.7.0.255", "1-0:42.7.0.255", "1-0:62.7.0.255"})
# Cumulative energy import total + tariffs for tariff in range(1, tariffs + 1):
enabled_obis.add("1-0:1.8.0.255") enabled_obis.add(f"1-0:1.8.{tariff}.255")
for t in range(1, tariffs + 1):
enabled_obis.add(f"1-0:1.8.{t}.255")
# Cumulative energy export only with FVE return enabled_obis
if has_fve:
enabled_obis.add("1-0:2.8.0.255")
_LOGGER.debug(
"XT211 config: phases=%s fve=%s tariffs=%d relays=%d%d entities",
phases, has_fve, tariffs, relay_count, len(enabled_obis),
)
entities: list = [] async def async_setup_entry(
registered_obis: set[str] = set() hass: HomeAssistant,
entry: ConfigEntry,
for obis, meta in OBIS_DESCRIPTIONS.items(): async_add_entities: AddEntitiesCallback,
if obis not in enabled_obis: ) -> None:
continue coordinator: XT211Coordinator = hass.data[DOMAIN][entry.entry_id]
registered_obis.add(obis) enabled_obis = build_enabled_obis(entry)
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))
entities = [
XT211SensorEntity(coordinator, entry, obis, meta)
for obis, meta in OBIS_DESCRIPTIONS.items()
if obis in enabled_obis and obis not in BINARY_OBIS
]
async_add_entities(entities) async_add_entities(entities)
# Dynamically register any unknown OBIS codes that arrive at runtime registered_obis = {entity._obis for entity in entities}
@callback @callback
def _on_update() -> None: def _on_update() -> None:
if not coordinator.data: if not coordinator.data:
return return
new: list = [] new_entities = []
for obis, data in coordinator.data.items(): for obis, data in coordinator.data.items():
if obis in registered_obis or obis not in enabled_obis: if obis in registered_obis or obis not in enabled_obis or obis in BINARY_OBIS:
continue continue
registered_obis.add(obis) registered_obis.add(obis)
_LOGGER.info("XT211: discovered new OBIS code %s adding entity", obis) new_entities.append(XT211SensorEntity(coordinator, entry, obis, data))
if obis in BINARY_OBIS: if new_entities:
new.append(XT211BinarySensorEntity(coordinator, entry, obis, data)) async_add_entities(new_entities)
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) coordinator.async_add_listener(_on_update)
# ---------------------------------------------------------------------------
# Numeric sensor
# ---------------------------------------------------------------------------
class XT211SensorEntity(CoordinatorEntity[XT211Coordinator], SensorEntity): class XT211SensorEntity(CoordinatorEntity[XT211Coordinator], SensorEntity):
"""Numeric sensor (power / energy / generic)."""
_attr_has_entity_name = True _attr_has_entity_name = True
def __init__( def __init__(self, coordinator: XT211Coordinator, entry: ConfigEntry, obis: str, meta: dict) -> None:
self,
coordinator: XT211Coordinator,
entry: ConfigEntry,
obis: str,
meta: dict,
) -> None:
super().__init__(coordinator) super().__init__(coordinator)
self._obis = obis
self._entry = entry
sensor_type = meta.get("class", "sensor") sensor_type = meta.get("class", "sensor")
sm = SENSOR_META.get(sensor_type, SENSOR_META["sensor"]) sensor_meta = SENSOR_META.get(sensor_type, SENSOR_META["sensor"])
self._entry = entry
self._obis = obis
self._wh_to_kwh = sensor_type == "energy"
self._text = obis in TEXT_OBIS
self._attr_unique_id = f"{entry.entry_id}_{obis}" self._attr_unique_id = f"{entry.entry_id}_{obis}"
self._attr_name = meta.get("name", obis) self._attr_name = meta.get("name", obis)
self._attr_device_class = sm["device_class"] self._attr_device_class = None if self._text else sensor_meta["device_class"]
self._attr_state_class = sm["state_class"] self._attr_state_class = None if self._text else sensor_meta["state_class"]
self._attr_native_unit_of_measurement = sm["unit"] or meta.get("unit") self._attr_native_unit_of_measurement = None if self._text else (sensor_meta["unit"] or meta.get("unit"))
self._wh_to_kwh = (sensor_type == "energy") if self._text:
self._attr_entity_category = EntityCategory.DIAGNOSTIC
@property @property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
return _device_info(self._entry) return _device_info(self._entry)
@property @property
def native_value(self) -> float | None: def native_value(self):
if not self.coordinator.data: obj = (self.coordinator.data or {}).get(self._obis)
return None
obj = self.coordinator.data.get(self._obis)
if obj is None: if obj is None:
return None return None
raw = obj.get("value") value = obj.get("value")
if self._text:
return None if value is None else str(value)
try: try:
val = float(raw) number = float(value)
except (TypeError, ValueError):
return None
if self._wh_to_kwh: if self._wh_to_kwh:
val = val / 1000.0 number /= 1000.0
return round(val, 3) return round(number, 3)
except (TypeError, ValueError):
return None
@property @property
def available(self) -> bool: def available(self) -> bool:
return self.coordinator.connected and self.coordinator.data is not None return 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