From 900aed571b154a2d7dc54c31acf118da77920300 Mon Sep 17 00:00:00 2001 From: Nero <95982029+nero150@users.noreply.github.com> Date: Thu, 19 Mar 2026 08:06:10 +0100 Subject: [PATCH] Add files via upload --- CHANGELOG.md | 7 + README.md | 115 +------------- custom_components/xt211_han/binary_sensor.py | 14 +- custom_components/xt211_han/config_flow.py | 148 +++---------------- custom_components/xt211_han/const.py | 5 - custom_components/xt211_han/coordinator.py | 42 +----- custom_components/xt211_han/dlms_parser.py | 71 ++------- custom_components/xt211_han/manifest.json | 2 +- custom_components/xt211_han/sensor.py | 107 ++------------ docs/images/README.txt | 8 + docs/pdfs/README.txt | 6 + manifest.json | 2 +- test_parser.py | 53 +------ xt211_han.zip | Bin 0 -> 14827 bytes 14 files changed, 89 insertions(+), 491 deletions(-) create mode 100644 docs/images/README.txt create mode 100644 docs/pdfs/README.txt create mode 100644 xt211_han.zip diff --git a/CHANGELOG.md b/CHANGELOG.md index 4241072..52ce193 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.8.7 +- zvýšena verze balíku na 0.8.7 +- sjednocena verze v kořenovém i integračním `manifest.json` +- doplněny GitHub Actions pro HACS validaci a release ZIP asset +- vyčištěn README od interní citační značky +- připraven kompletní balík repozitáře pro HACS release workflow + ## 0.8.0 - doplněno README o reálné nastavení převodníku a screenshoty - přidána složka `docs/images` se screenshoty konfigurace a výsledku v Home Assistantu diff --git a/README.md b/README.md index 20ea90f..3d078c0 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ > Čtení dat z elektroměru Sagemcom XT211 / Relay box (ČEZ Distribuce) přes RS485-to-Ethernet převodník do Home Assistantu. -Tahle integrace čte push data z HAN / RS485 rozhraní elektroměru přes TCP server na převodníku. +Tahle integrace čte push data z HAN / RS485 rozhraní elektroměru přes TCP server na převodníku. ## Jak to funguje @@ -17,115 +17,23 @@ XT211 / Relay box └── Home Assistant ``` -- Elektroměr posílá jednosměrná DLMS/COSEM data z elektroměru k zákazníkovi rychlostí 9600 Bd a podle dokumentace ČEZ se push zprávy předávají 1× za 60 s. -- Rozhraní je vyvedené na konektoru RJ12, kde je Data A na pinu 3, Data B na pinu 4 a GND na pinu 6. -- Dokumentace také uvádí sadu OBIS kódů pro HAN rozhraní. +- Elektroměr posílá jednosměrná DLMS/COSEM data z elektroměru k zákazníkovi rychlostí 9600 Bd a podle dokumentace ČEZ se push zprávy předávají 1× za 60 s. +- Rozhraní je vyvedené na konektoru RJ12, kde je Data A na pinu 3, Data B na pinu 4 a GND na pinu 6. +- Dokumentace také uvádí sadu OBIS kódů pro HAN rozhraní. ## Ověřený hardware -- PUSR USR-USR-DR134 (https://www.pusr.com/products/Lipstick-Size-Serial-Device-Server.html) -- Předpokládám, že bude fungovat každý RS485-TCP převodník +- PUSR USR-USR-DR134 +- Předpoklad je, že bude fungovat každý RS485-TCP převodník ## Instalace přes HACS 1. Otevři HACS → **Integrace** → **Vlastní repozitáře**. -2. Přidej URL tohoto repozitáře jako typ **Integration** (https://github.com/nero150/CEZ_rele_box). +2. Přidej URL tohoto repozitáře jako typ **Integration**. 3. Nainstaluj integraci **XT211 HAN**. 4. Restartuj Home Assistant. 5. V **Nastavení → Zařízení a služby** přidej integraci **XT211 HAN**. -## Nastavení převodníku - -### 1. Síťové nastavení - -Použité nastavení na funkční sestavě: -- IP Type: `Static IP` -- Native IP: `192.168.0.152` -- Submask: `255.255.255.0` -- Gateway: `192.168.0.1` -- DNS Server: `192.168.0.1` - -![Síťové nastavení převodníku](docs/images/converter_network_settings.png) - -### 2. Sériové / TCP nastavení - -Použité nastavení na funkční sestavě: -- Baud Rate: `9600` -- Data Size: `8` -- Parity: `NONE` -- Stop Bits: `1` -- Local Port Number: `8899` -- Work Mode: `TCP Server` -- Client Overrun Mechanism: `KICK` -- Client Access Quantity: `4` - -![Sériové a TCP nastavení převodníku](docs/images/converter_serial_settings.png) - -### 3. Kontrola, že převodník opravdu posílá data - -Na stavové stránce převodníku je vidět aktivní klient a počitadlo `TX/RX Count`. Pokud **roste TX počet bajtů**, převodník normálně odesílá data z elektroměru do sítě. Na ukázce níže je vidět připojený klient `192.168.0.190` a narůstající `TX Count`, zatímco `RX` zůstává nulové. To odpovídá jednosměrnému provozu elektroměr → zákazník, který je uvedený i v dokumentaci ČEZ. fileciteturn6file0 - -![Status převodníku a TX počitadlo](docs/images/converter_status_tx.png) - -## Zapojení RJ12 / RS485 - -Podle dokumentace ČEZ je konektor RJ12 zapojen takto: -- pin 3 = `Data A` -- pin 4 = `Data B` -- pin 6 = `Shield / GND` nebo `Data GND` - -![Home Assistant – funkční entity](docs/images/home_assistant_entities.png) - -## Co integrace reálně čte - -Na reálně otestované sestavě se z XT211 / Relay boxu četou tyto hodnoty: -- dodávka energie celkem -- spotřeba energie celkem -- spotřeba energie T1 -- spotřeba energie T2 -- okamžitý příkon odběru celkem -- okamžitý příkon odběru L1, L2, L3 -- okamžitý výkon dodávky celkem -- okamžitý výkon dodávky L1, L2, L3 -- limiter -- stav odpojovače -- stav relé R1 až R4 -- aktuální tarif -- výrobní číslo elektroměru - -## Dostupné entity - -### Výkon (W) -- Limiter — `0-0:17.0.0.255` -- Okamžitý příkon odběru celkem — `1-0:1.7.0.255` -- Okamžitý příkon odběru L1 — `1-0:21.7.0.255` -- Okamžitý příkon odběru L2 — `1-0:41.7.0.255` -- Okamžitý příkon odběru L3 — `1-0:61.7.0.255` -- Okamžitý výkon dodávky celkem — `1-0:2.7.0.255` -- Okamžitý výkon dodávky L1 — `1-0:22.7.0.255` -- Okamžitý výkon dodávky L2 — `1-0:42.7.0.255` -- Okamžitý výkon dodávky L3 — `1-0:62.7.0.255` - -### Energie (kWh) -- Spotřeba energie celkem — `1-0:1.8.0.255` -- Spotřeba energie T1 — `1-0:1.8.1.255` -- Spotřeba energie T2 — `1-0:1.8.2.255` -- Spotřeba energie T3 — `1-0:1.8.3.255` pokud ji elektroměr posílá -- Spotřeba energie T4 — `1-0:1.8.4.255` pokud ji elektroměr posílá -- Dodávka energie celkem — `1-0:2.8.0.255` pokud ji elektroměr posílá - -### Binární senzory -- Stav odpojovače — `0-0:96.3.10.255` -- Stav relé R1 — `0-1:96.3.10.255` -- Stav relé R2 — `0-2:96.3.10.255` -- Stav relé R3 — `0-3:96.3.10.255` -- Stav relé R4 — `0-4:96.3.10.255` - -### Diagnostika -- Aktuální tarif — `0-0:96.14.0.255` -- Výrobní číslo — `0-0:96.1.1.255` - - ## Debug logování Do `configuration.yaml`: @@ -137,20 +45,11 @@ logger: custom_components.xt211_han: debug ``` -V logu pak uvidíš: -- příjem TCP dat -- složení rámců ze streamu -- parsed OBIS objekty -- aktualizaci coordinatoru - ## Podklady v repozitáři - `docs/pdfs/cez_rs485_han_interface.pdf` - `docs/pdfs/cez_obis_codes_han_2025-02-01.pdf` -## Poděkování -- Děkuji za inspiraci: https://github.com/Tomer27cz/xt211 - ## Licence MIT diff --git a/custom_components/xt211_han/binary_sensor.py b/custom_components/xt211_han/binary_sensor.py index 10ad423..6987ff4 100644 --- a/custom_components/xt211_han/binary_sensor.py +++ b/custom_components/xt211_han/binary_sensor.py @@ -15,21 +15,11 @@ 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: +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 - ] + 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 diff --git a/custom_components/xt211_han/config_flow.py b/custom_components/xt211_han/config_flow.py index 6c22ca2..747e057 100644 --- a/custom_components/xt211_han/config_flow.py +++ b/custom_components/xt211_han/config_flow.py @@ -15,59 +15,18 @@ from homeassistant.components import dhcp from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.data_entry_flow import FlowResult -from .const import ( - CONF_HAS_FVE, - CONF_PHASES, - CONF_RELAY_COUNT, - CONF_TARIFFS, - DEFAULT_NAME, - DEFAULT_PORT, - DOMAIN, - PHASES_1, - PHASES_3, - RELAYS_0, - RELAYS_4, - RELAYS_6, - TARIFFS_1, - TARIFFS_2, - TARIFFS_4, -) +from .const import CONF_HAS_FVE, CONF_PHASES, CONF_RELAY_COUNT, CONF_TARIFFS, DEFAULT_NAME, DEFAULT_PORT, DOMAIN, PHASES_1, PHASES_3, RELAYS_0, RELAYS_4, RELAYS_6, TARIFFS_1, TARIFFS_2, TARIFFS_4 _LOGGER = logging.getLogger(__name__) - USR_IOT_MAC_PREFIXES = ("d8b04c", "b4e62d") MANUAL_CHOICE = "__manual__" - -STEP_CONNECTION_SCHEMA = vol.Schema( - { - vol.Required(CONF_HOST): str, - vol.Required(CONF_PORT, default=DEFAULT_PORT): int, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, - } -) - -STEP_METER_SCHEMA = vol.Schema( - { - vol.Required(CONF_PHASES, default=PHASES_3): vol.In( - {PHASES_1: "Jednofázový", PHASES_3: "Třífázový"} - ), - vol.Required(CONF_HAS_FVE, default=False): bool, - vol.Required(CONF_TARIFFS, default=TARIFFS_2): vol.In( - { - TARIFFS_1: "Jeden tarif (pouze T1)", - TARIFFS_2: "Dva tarify (T1 + T2)", - TARIFFS_4: "Čtyři tarify (T1 – T4)", - } - ), - vol.Required(CONF_RELAY_COUNT, default=RELAYS_4): vol.In( - { - RELAYS_0: "Žádné relé", - RELAYS_4: "WM-RelayBox (R1 – R4)", - RELAYS_6: "WM-RelayBox rozšířený (R1 – R6)", - } - ), - } -) +STEP_CONNECTION_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str, vol.Required(CONF_PORT, default=DEFAULT_PORT): int, vol.Optional(CONF_NAME, default=DEFAULT_NAME): str}) +STEP_METER_SCHEMA = vol.Schema({ + vol.Required(CONF_PHASES, default=PHASES_3): vol.In({PHASES_1: "Jednofázový", PHASES_3: "Třífázový"}), + vol.Required(CONF_HAS_FVE, default=False): bool, + vol.Required(CONF_TARIFFS, default=TARIFFS_2): vol.In({TARIFFS_1: "Jeden tarif (pouze T1)", TARIFFS_2: "Dva tarify (T1 + T2)", TARIFFS_4: "Čtyři tarify (T1 – T4)"}), + vol.Required(CONF_RELAY_COUNT, default=RELAYS_4): vol.In({RELAYS_0: "Žádné relé", RELAYS_4: "WM-RelayBox (R1 – R4)", RELAYS_6: "WM-RelayBox rozšířený (R1 – R6)"}), +}) async def _test_connection(host: str, port: int, timeout: float = 5.0) -> str | None: @@ -80,7 +39,7 @@ async def _test_connection(host: str, port: int, timeout: float = 5.0) -> str | return "cannot_connect" except OSError: return "cannot_connect" - except Exception: # pragma: no cover - defensive + except Exception: return "unknown" @@ -96,15 +55,12 @@ async def _scan_network(port: int, timeout: float = 1.0) -> list[str]: local_ip = socket.gethostbyname(socket.gethostname()) except Exception: pass - if local_ip.startswith("127.") or local_ip == "0.0.0.0": local_ip = "192.168.1.1" - try: network = IPv4Network(f"{local_ip}/24", strict=False) except ValueError: network = IPv4Network("192.168.1.0/24", strict=False) - found: list[str] = [] async def _probe(ip: str) -> None: @@ -122,7 +78,6 @@ async def _scan_network(port: int, timeout: float = 1.0) -> list[str]: hosts = [str(host) for host in network.hosts()] for index in range(0, len(hosts), 50): await asyncio.gather(*(_probe(ip) for ip in hosts[index:index + 50])) - found.sort() _LOGGER.debug("XT211 scan found %d host(s) on port %d: %s", len(found), port, found) return found @@ -141,11 +96,9 @@ class XT211HANConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): mac = discovery_info.macaddress.replace(":", "").lower() if not any(mac.startswith(prefix) for prefix in USR_IOT_MAC_PREFIXES): return self.async_abort(reason="not_supported") - ip = discovery_info.ip await self.async_set_unique_id(f"{ip}:{DEFAULT_PORT}") self._abort_if_unique_id_configured(updates={CONF_HOST: ip}) - self._discovered_host = ip self._discovered_port = DEFAULT_PORT _LOGGER.info("XT211 HAN: DHCP discovered USR IOT device at %s (MAC %s)", ip, mac) @@ -155,102 +108,41 @@ class XT211HANConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: error = await _test_connection(self._discovered_host, self._discovered_port) if error: - return self.async_show_form( - step_id="dhcp_confirm", - errors={"base": error}, - description_placeholders={ - "host": self._discovered_host, - "port": str(self._discovered_port), - }, - ) - self._connection_data = { - CONF_HOST: self._discovered_host, - CONF_PORT: self._discovered_port, - CONF_NAME: DEFAULT_NAME, - } + return self.async_show_form(step_id="dhcp_confirm", errors={"base": error}, description_placeholders={"host": self._discovered_host, "port": str(self._discovered_port)}) + self._connection_data = {CONF_HOST: self._discovered_host, CONF_PORT: self._discovered_port, CONF_NAME: DEFAULT_NAME} return await self.async_step_meter() - - return self.async_show_form( - step_id="dhcp_confirm", - description_placeholders={ - "host": self._discovered_host, - "port": str(self._discovered_port), - }, - ) + return self.async_show_form(step_id="dhcp_confirm", description_placeholders={"host": self._discovered_host, "port": str(self._discovered_port)}) async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult: if user_input is not None: return await (self.async_step_scan() if user_input.get("method") == "scan" else self.async_step_manual()) - - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required("method", default="scan"): vol.In( - { - "scan": "🔍 Automaticky vyhledat v síti", - "manual": "✏️ Zadat IP adresu ručně", - } - ) - } - ), - ) + return self.async_show_form(step_id="user", data_schema=vol.Schema({vol.Required("method", default="scan"): vol.In({"scan": "🔍 Automaticky vyhledat v síti", "manual": "✏️ Zadat IP adresu ručně"})})) async def async_step_scan(self, user_input: dict[str, Any] | None = None) -> FlowResult: if user_input is not None: host = user_input[CONF_HOST] if host == MANUAL_CHOICE: return await self.async_step_manual() - port = user_input.get(CONF_PORT, DEFAULT_PORT) name = user_input.get(CONF_NAME, DEFAULT_NAME) - await self.async_set_unique_id(f"{host}:{port}") self._abort_if_unique_id_configured() - error = await _test_connection(host, port) if error: - return self.async_show_form( - step_id="scan", - data_schema=self._scan_schema(port, include_choices=not self._scan_results == []), - errors={"base": error}, - ) - + return self.async_show_form(step_id="scan", data_schema=self._scan_schema(port, include_choices=not self._scan_results == []), errors={"base": error}) self._connection_data = {CONF_HOST: host, CONF_PORT: port, CONF_NAME: name} return await self.async_step_meter() - self._scan_results = await _scan_network(DEFAULT_PORT) if not self._scan_results: - return self.async_show_form( - step_id="scan", - data_schema=self._scan_schema(DEFAULT_PORT, include_choices=False), - errors={"base": "no_devices_found"}, - ) - - return self.async_show_form( - step_id="scan", - data_schema=self._scan_schema(DEFAULT_PORT, include_choices=True), - ) + return self.async_show_form(step_id="scan", data_schema=self._scan_schema(DEFAULT_PORT, include_choices=False), errors={"base": "no_devices_found"}) + return self.async_show_form(step_id="scan", data_schema=self._scan_schema(DEFAULT_PORT, include_choices=True)) def _scan_schema(self, port: int, include_choices: bool) -> vol.Schema: if include_choices: choices = {ip: f"{ip}:{port}" for ip in self._scan_results} choices[MANUAL_CHOICE] = "✏️ Zadat IP adresu ručně" - return vol.Schema( - { - vol.Required(CONF_HOST): vol.In(choices), - vol.Optional(CONF_PORT, default=port): int, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, - } - ) - - return vol.Schema( - { - vol.Required(CONF_HOST): str, - vol.Required(CONF_PORT, default=port): int, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, - } - ) + return vol.Schema({vol.Required(CONF_HOST): vol.In(choices), vol.Optional(CONF_PORT, default=port): int, vol.Optional(CONF_NAME, default=DEFAULT_NAME): str}) + return vol.Schema({vol.Required(CONF_HOST): str, vol.Required(CONF_PORT, default=port): int, vol.Optional(CONF_NAME, default=DEFAULT_NAME): str}) async def async_step_manual(self, user_input: dict[str, Any] | None = None) -> FlowResult: errors: dict[str, str] = {} @@ -258,17 +150,14 @@ class XT211HANConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): host = user_input[CONF_HOST] port = user_input[CONF_PORT] name = user_input.get(CONF_NAME, DEFAULT_NAME) - await self.async_set_unique_id(f"{host}:{port}") self._abort_if_unique_id_configured() - error = await _test_connection(host, port) if error: errors["base"] = error else: self._connection_data = {CONF_HOST: host, CONF_PORT: port, CONF_NAME: name} return await self.async_step_meter() - return self.async_show_form(step_id="manual", data_schema=STEP_CONNECTION_SCHEMA, errors=errors) async def async_step_meter(self, user_input: dict[str, Any] | None = None) -> FlowResult: @@ -278,5 +167,4 @@ class XT211HANConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): host = data[CONF_HOST] port = data[CONF_PORT] return self.async_create_entry(title=f"{name} ({host}:{port})", data=data) - return self.async_show_form(step_id="meter", data_schema=STEP_METER_SCHEMA) diff --git a/custom_components/xt211_han/const.py b/custom_components/xt211_han/const.py index 40a0f25..39dfb5d 100644 --- a/custom_components/xt211_han/const.py +++ b/custom_components/xt211_han/const.py @@ -13,16 +13,11 @@ CONF_RELAY_COUNT = "relay_count" DEFAULT_PORT = 8899 DEFAULT_NAME = "XT211 HAN" -# Phases PHASES_1 = "1" PHASES_3 = "3" - -# Tariff counts TARIFFS_1 = 1 TARIFFS_2 = 2 TARIFFS_4 = 4 - -# Relay counts RELAYS_0 = 0 RELAYS_4 = 4 RELAYS_6 = 6 diff --git a/custom_components/xt211_han/coordinator.py b/custom_components/xt211_han/coordinator.py index d1cf3d8..f55eeca 100644 --- a/custom_components/xt211_han/coordinator.py +++ b/custom_components/xt211_han/coordinator.py @@ -12,7 +12,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .dlms_parser import DLMSObject, DLMSParser, OBIS_DESCRIPTIONS _LOGGER = logging.getLogger(__name__) - PUSH_TIMEOUT = 90 RECONNECT_DELAY = 10 @@ -21,12 +20,7 @@ class XT211Coordinator(DataUpdateCoordinator[dict[str, Any]]): """Persistent TCP listener for XT211 DLMS push frames.""" def __init__(self, hass: HomeAssistant, host: str, port: int, name: str) -> None: - super().__init__( - hass, - _LOGGER, - name=f"XT211 HAN ({host}:{port})", - update_interval=None, - ) + super().__init__(hass, _LOGGER, name=f"XT211 HAN ({host}:{port})", update_interval=None) self.host = host self.port = port self.device_name = name @@ -79,15 +73,11 @@ class XT211Coordinator(DataUpdateCoordinator[dict[str, Any]]): ) finally: await self._disconnect() - await asyncio.sleep(RECONNECT_DELAY) async def _connect(self) -> None: _LOGGER.info("Connecting to XT211 adapter at %s:%d", self.host, self.port) - self._reader, self._writer = await asyncio.wait_for( - asyncio.open_connection(self.host, self.port), - timeout=10, - ) + self._reader, self._writer = await asyncio.wait_for(asyncio.open_connection(self.host, self.port), timeout=10) self._parser = DLMSParser() self._connected = True _LOGGER.info("Connected to XT211 adapter at %s:%d", self.host, self.port) @@ -105,50 +95,34 @@ class XT211Coordinator(DataUpdateCoordinator[dict[str, Any]]): async def _receive_loop(self) -> None: assert self._reader is not None - while True: try: chunk = await asyncio.wait_for(self._reader.read(4096), timeout=PUSH_TIMEOUT) except asyncio.TimeoutError as exc: _LOGGER.warning("No data from XT211 for %d s – reconnecting", PUSH_TIMEOUT) raise ConnectionError("Push timeout") from exc - if not chunk: _LOGGER.warning("XT211 adapter closed connection") raise ConnectionError("Remote closed") - _LOGGER.debug("XT211 RX %d bytes: %s", len(chunk), chunk.hex()) self._parser.feed(chunk) - while True: result = self._parser.get_frame() if result is None: break - self._frames_received += 1 if result.success: - _LOGGER.debug( - "XT211 frame #%d parsed OK: %d object(s)", - self._frames_received, - len(result.objects), - ) + _LOGGER.debug("XT211 frame #%d parsed OK: %d object(s)", self._frames_received, len(result.objects)) await self._process_frame(result.objects) else: - _LOGGER.debug( - "XT211 frame #%d parse error: %s (raw: %s)", - self._frames_received, - result.error, - result.raw_hex[:120], - ) + _LOGGER.debug("XT211 frame #%d parse error: %s (raw: %s)", self._frames_received, result.error, result.raw_hex[:120]) async def _process_frame(self, objects: list[DLMSObject]) -> None: if not objects: _LOGGER.debug("Received empty DLMS frame") return - current = dict(self.data or {}) changed: list[str] = [] - for obj in objects: meta = OBIS_DESCRIPTIONS.get(obj.obis, {}) new_value = { @@ -161,11 +135,5 @@ class XT211Coordinator(DataUpdateCoordinator[dict[str, Any]]): 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) - _LOGGER.debug( - "Coordinator updated with %d object(s), %d changed: %s", - len(objects), - len(changed), - ", ".join(changed[:10]), - ) + _LOGGER.debug("Coordinator updated with %d object(s), %d changed: %s", len(objects), len(changed), ", ".join(changed[:10])) diff --git a/custom_components/xt211_han/dlms_parser.py b/custom_components/xt211_han/dlms_parser.py index 8517195..6eeb9ad 100644 --- a/custom_components/xt211_han/dlms_parser.py +++ b/custom_components/xt211_han/dlms_parser.py @@ -8,9 +8,7 @@ from dataclasses import dataclass, field from typing import Any _LOGGER = logging.getLogger(__name__) - HDLC_FLAG = 0x7E - DLMS_TYPE_NULL = 0x00 DLMS_TYPE_ARRAY = 0x01 DLMS_TYPE_STRUCTURE = 0x02 @@ -32,7 +30,7 @@ DLMS_TYPE_FLOAT64 = 0x18 class NeedMoreData(Exception): - """Raised when the parser needs more bytes to finish a frame.""" + pass @dataclass @@ -52,8 +50,6 @@ class ParseResult: class DLMSParser: - """Stateful parser for raw DLMS APDUs and HDLC-wrapped frames.""" - def __init__(self) -> None: self._buffer = bytearray() @@ -61,53 +57,42 @@ class DLMSParser: self._buffer.extend(data) def get_frame(self) -> ParseResult | None: - """Return one parsed frame from the internal buffer, if available.""" if not self._buffer: return None - if self._buffer[0] == HDLC_FLAG: return self._get_hdlc_frame() - start = self._find_apdu_start(self._buffer) if start == -1: _LOGGER.debug("Discarding %d bytes without known frame start", len(self._buffer)) self._buffer.clear() return None - 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: return None - frame_len = ((buf[1] & 0x07) << 8) | buf[2] total = frame_len + 2 if len(buf) < total: return None - raw = bytes(buf[:total]) del buf[:total] raw_hex = raw.hex() - if raw[0] != HDLC_FLAG or raw[-1] != HDLC_FLAG: return ParseResult(success=False, raw_hex=raw_hex, error="Missing HDLC flags") - try: result = self._parse_hdlc(raw) result.raw_hex = raw_hex return result except NeedMoreData: - # Should not happen for HDLC because total length is known. return None - except Exception as exc: # pragma: no cover - defensive logging + except Exception as exc: _LOGGER.exception("Error parsing HDLC frame") return ParseResult(success=False, raw_hex=raw_hex, error=str(exc)) @@ -117,12 +102,11 @@ class DLMSParser: result, consumed = self._parse_apdu_with_length(bytes(buf)) except NeedMoreData: return None - except Exception as exc: # pragma: no cover - defensive logging + except Exception as exc: 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() @@ -130,16 +114,14 @@ class DLMSParser: def _parse_hdlc(self, raw: bytes) -> ParseResult: pos = 1 - pos += 2 # frame format + pos += 2 _, pos = self._read_hdlc_address(raw, pos) _, pos = self._read_hdlc_address(raw, pos) - pos += 1 # control - pos += 2 # HCS - + pos += 1 + pos += 2 if pos + 3 > len(raw) - 3: raise ValueError("Frame too short for LLC") - - pos += 3 # LLC header + pos += 3 apdu = raw[pos:-3] result, _ = self._parse_apdu_with_length(apdu) return result @@ -164,15 +146,12 @@ class DLMSParser: raise ValueError(f"Unexpected APDU tag 0x{apdu[0]:02X}") if len(apdu) < 6: raise NeedMoreData - pos = 1 invoke_id = struct.unpack_from(">I", apdu, pos)[0] pos += 4 _LOGGER.debug("XT211 invoke_id=0x%08X", invoke_id) - if pos >= len(apdu): raise NeedMoreData - if apdu[pos] == DLMS_TYPE_OCTET_STRING: pos += 1 dt_len, pos = self._decode_length(apdu, pos) @@ -180,7 +159,6 @@ class DLMSParser: pos += dt_len elif apdu[pos] == DLMS_TYPE_NULL: pos += 1 - self._require(apdu, pos, 2) if apdu[pos] != DLMS_TYPE_STRUCTURE: return ParseResult(success=True, objects=[]), pos @@ -188,7 +166,6 @@ class DLMSParser: pos += 2 if structure_count < 2: return ParseResult(success=True, objects=[]), pos - if pos >= len(apdu): raise NeedMoreData if apdu[pos] == DLMS_TYPE_ENUM: @@ -196,20 +173,17 @@ class DLMSParser: 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 - array_count, pos = self._decode_length(apdu, pos) objects: list[DLMSObject] = [] for _ in range(array_count): obj, pos = self._parse_xt211_object(apdu, pos) if obj is not None: objects.append(obj) - return ParseResult(success=True, objects=objects), pos def _parse_xt211_object(self, data: bytes, pos: int) -> tuple[DLMSObject | None, int]: @@ -217,13 +191,9 @@ class DLMSParser: if data[pos] != DLMS_TYPE_STRUCTURE: raise ValueError(f"Expected object structure at {pos}, got 0x{data[pos]:02X}") pos += 1 - 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 @@ -231,34 +201,17 @@ class DLMSParser: pos += 2 obis_raw = bytes(data[pos:pos + 6]) pos += 6 - _attr_idx = data[pos] pos += 1 value, pos = self._decode_value(data, pos) - if isinstance(value, (bytes, bytearray)): try: value = bytes(value).decode("ascii", errors="replace").strip("\x00") except Exception: value = bytes(value).hex() - 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( - obis=obis, - value=value, - unit=meta.get("unit", ""), - scaler=0, - ), pos - - # Short housekeeping frames use simple typed structures without OBIS. - # Consume them cleanly and ignore them. + _LOGGER.debug("Parsed XT211 object class_id=%s obis=%s value=%r unit=%s", class_id, obis, value, meta.get("unit", "")) + return DLMSObject(obis=obis, value=value, unit=meta.get("unit", ""), scaler=0), pos last_value: Any = None for _ in range(count): last_value, pos = self._decode_value(data, pos) @@ -269,7 +222,6 @@ class DLMSParser: self._require(data, pos, 1) dtype = data[pos] pos += 1 - if dtype == DLMS_TYPE_NULL: return None, pos if dtype == DLMS_TYPE_BOOL: @@ -320,7 +272,6 @@ class DLMSParser: item, pos = self._decode_value(data, pos) items.append(item) return items, pos - raise ValueError(f"Unknown DLMS type 0x{dtype:02X} at pos {pos - 1}") def _decode_length(self, data: bytes, pos: int) -> tuple[int, int]: @@ -354,7 +305,7 @@ class DLMSParser: return f"{a}-{b}:{c}.{d}.{e}.{f}" -OBIS_DESCRIPTIONS: dict[str, dict[str, str]] = { +OBIS_DESCRIPTIONS = { "0-0:42.0.0.255": {"name": "Název zařízení", "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"}, @@ -381,5 +332,5 @@ OBIS_DESCRIPTIONS: dict[str, dict[str, str]] = { "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:2.8.0.255": {"name": "Dodávka energie celkem", "unit": "Wh", "class": "energy"}, - "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"} } diff --git a/custom_components/xt211_han/manifest.json b/custom_components/xt211_han/manifest.json index 21e8198..a6bde20 100644 --- a/custom_components/xt211_han/manifest.json +++ b/custom_components/xt211_han/manifest.json @@ -1,7 +1,7 @@ { "domain": "xt211_han", "name": "XT211 HAN (RS485 via Ethernet)", - "version": "0.8.2", + "version": "0.8.7", "documentation": "https://github.com/nero150/CEZ_rele_box", "issue_tracker": "https://github.com/nero150/CEZ_rele_box/issues", "dependencies": [], diff --git a/custom_components/xt211_han/sensor.py b/custom_components/xt211_han/sensor.py index ec7977e..435eb5c 100644 --- a/custom_components/xt211_han/sensor.py +++ b/custom_components/xt211_han/sensor.py @@ -10,73 +10,27 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - CONF_HAS_FVE, - CONF_PHASES, - CONF_RELAY_COUNT, - CONF_TARIFFS, - DOMAIN, - PHASES_3, - RELAYS_4, - TARIFFS_2, -) +from .const import CONF_HAS_FVE, CONF_PHASES, CONF_RELAY_COUNT, CONF_TARIFFS, DOMAIN, PHASES_3, RELAYS_4, TARIFFS_2 from .coordinator import XT211Coordinator from .dlms_parser import OBIS_DESCRIPTIONS -SENSOR_META: dict[str, dict] = { - "power": { - "device_class": SensorDeviceClass.POWER, - "state_class": SensorStateClass.MEASUREMENT, - "unit": UnitOfPower.WATT, - }, - "energy": { - "device_class": SensorDeviceClass.ENERGY, - "state_class": SensorStateClass.TOTAL_INCREASING, - "unit": UnitOfEnergy.KILO_WATT_HOUR, - }, - "sensor": { - "device_class": None, - "state_class": SensorStateClass.MEASUREMENT, - "unit": None, - }, +SENSOR_META = { + "power": {"device_class": SensorDeviceClass.POWER, "state_class": SensorStateClass.MEASUREMENT, "unit": UnitOfPower.WATT}, + "energy": {"device_class": SensorDeviceClass.ENERGY, "state_class": SensorStateClass.TOTAL_INCREASING, "unit": UnitOfEnergy.KILO_WATT_HOUR}, + "sensor": {"device_class": None, "state_class": SensorStateClass.MEASUREMENT, "unit": None}, } - SERIAL_OBIS = ("0-0:96.1.1.255", "0-0:96.1.0.255") PRECREATED_TEXT_ENTITIES = { - "serial_number": { - "name": "Výrobní číslo", - "obises": SERIAL_OBIS, - "entity_category": EntityCategory.DIAGNOSTIC, - }, - "current_tariff": { - "name": "Aktuální tarif", - "obises": ("0-0:96.14.0.255",), - "entity_category": EntityCategory.DIAGNOSTIC, - }, -} -DYNAMIC_TEXT_OBIS = { - "0-0:42.0.0.255", - "0-0:96.13.0.255", -} -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", - "0-5:96.3.10.255", - "0-6:96.3.10.255", + "serial_number": {"name": "Výrobní číslo", "obises": SERIAL_OBIS, "entity_category": EntityCategory.DIAGNOSTIC}, + "current_tariff": {"name": "Aktuální tarif", "obises": ("0-0:96.14.0.255",), "entity_category": EntityCategory.DIAGNOSTIC}, } +DYNAMIC_TEXT_OBIS = {"0-0:42.0.0.255", "0-0:96.13.0.255"} +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", "0-5:96.3.10.255", "0-6:96.3.10.255"} TEXT_OBIS = set().union(*(spec["obises"] for spec in PRECREATED_TEXT_ENTITIES.values()), DYNAMIC_TEXT_OBIS) 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", - ) + return DeviceInfo(identifiers={(DOMAIN, entry.entry_id)}, name=entry.data.get(CONF_NAME, "XT211 HAN"), manufacturer="Sagemcom", model="XT211 AMM") def build_enabled_obis(entry: ConfigEntry) -> set[str]: @@ -84,60 +38,29 @@ def build_enabled_obis(entry: ConfigEntry) -> set[str]: has_fve = entry.data.get(CONF_HAS_FVE, True) tariffs = int(entry.data.get(CONF_TARIFFS, TARIFFS_2)) relay_count = int(entry.data.get(CONF_RELAY_COUNT, RELAYS_4)) - - enabled_obis: set[str] = { - "0-0:17.0.0.255", - "1-0:1.7.0.255", - "1-0:1.8.0.255", - *TEXT_OBIS, - } - - relay_obis = { - 1: "0-1:96.3.10.255", - 2: "0-2:96.3.10.255", - 3: "0-3:96.3.10.255", - 4: "0-4:96.3.10.255", - 5: "0-5:96.3.10.255", - 6: "0-6:96.3.10.255", - } + enabled_obis = {"0-0:17.0.0.255", "1-0:1.7.0.255", "1-0:1.8.0.255", *TEXT_OBIS} + relay_obis = {1: "0-1:96.3.10.255", 2: "0-2:96.3.10.255", 3: "0-3:96.3.10.255", 4: "0-4:96.3.10.255", 5: "0-5:96.3.10.255", 6: "0-6:96.3.10.255"} enabled_obis.add("0-0:96.3.10.255") for idx in range(1, relay_count + 1): enabled_obis.add(relay_obis[idx]) - 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"}) - if has_fve: enabled_obis.add("1-0:2.7.0.255") enabled_obis.add("1-0:2.8.0.255") 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"}) - for tariff in range(1, tariffs + 1): enabled_obis.add(f"1-0:1.8.{tariff}.255") - return enabled_obis -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: +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 = [ - XT211SensorEntity(coordinator, entry, obis, meta) - for obis, meta in OBIS_DESCRIPTIONS.items() - if obis in enabled_obis and obis not in BINARY_OBIS and obis not in TEXT_OBIS - ] - entities.extend( - XT211AliasedTextSensorEntity(coordinator, entry, key, spec) - for key, spec in PRECREATED_TEXT_ENTITIES.items() - ) + entities = [XT211SensorEntity(coordinator, entry, obis, meta) for obis, meta in OBIS_DESCRIPTIONS.items() if obis in enabled_obis and obis not in BINARY_OBIS and obis not in TEXT_OBIS] + entities.extend(XT211AliasedTextSensorEntity(coordinator, entry, key, spec) for key, spec in PRECREATED_TEXT_ENTITIES.items()) async_add_entities(entities) - registered_obis = {entity._obis for entity in entities if hasattr(entity, "_obis")} @callback diff --git a/docs/images/README.txt b/docs/images/README.txt new file mode 100644 index 0000000..4c6b628 --- /dev/null +++ b/docs/images/README.txt @@ -0,0 +1,8 @@ +Do této složky patří screenshoty použité v README: +- converter_network_settings.png +- converter_serial_settings.png +- converter_status_tx.png +- home_assistant_entities.png + +V tomto generovaném balíku nejsou původní binární obrázky přiložené. +Doplň je sem z původního repozitáře před finálním vydáním, pokud je chceš zachovat i v release archivu. diff --git a/docs/pdfs/README.txt b/docs/pdfs/README.txt new file mode 100644 index 0000000..3fc9cdf --- /dev/null +++ b/docs/pdfs/README.txt @@ -0,0 +1,6 @@ +Do této složky patří podkladové PDF soubory zmíněné v README: +- cez_rs485_han_interface.pdf +- cez_obis_codes_han_2025-02-01.pdf + +V tomto generovaném balíku nejsou původní PDF přiložená. +Doplň je sem z původního repozitáře před finálním vydáním, pokud je chceš zachovat i v release archivu. diff --git a/manifest.json b/manifest.json index 21e8198..a6bde20 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "domain": "xt211_han", "name": "XT211 HAN (RS485 via Ethernet)", - "version": "0.8.2", + "version": "0.8.7", "documentation": "https://github.com/nero150/CEZ_rele_box", "issue_tracker": "https://github.com/nero150/CEZ_rele_box/issues", "dependencies": [], diff --git a/test_parser.py b/test_parser.py index 31bbbb3..f37fb86 100644 --- a/test_parser.py +++ b/test_parser.py @@ -1,24 +1,13 @@ #!/usr/bin/env python3 """ Standalone test / debug script for the DLMS parser and TCP listener. - -Usage: - # Parse a raw hex frame from the meter (paste from HA debug log): - python3 test_parser.py --hex "7ea0...7e" - - # Live listen on TCP socket (forward output to terminal): - python3 test_parser.py --host 192.168.1.100 --port 8899 - - # Replay a saved binary capture file: - python3 test_parser.py --file capture.bin """ import argparse import asyncio -import sys import os +import sys -# Allow running from repo root without installing sys.path.insert(0, os.path.join(os.path.dirname(__file__), "custom_components")) from xt211_han.dlms_parser import DLMSParser, OBIS_DESCRIPTIONS @@ -26,12 +15,12 @@ from xt211_han.dlms_parser import DLMSParser, OBIS_DESCRIPTIONS def print_result(result) -> None: if not result.success: - print(f" ❌ Parse error: {result.error}") + print(f" Parse error: {result.error}") return if not result.objects: - print(" ⚠️ Frame OK but no DLMS objects extracted") + print(" Frame OK but no DLMS objects extracted") return - print(f" ✅ {len(result.objects)} OBIS objects decoded:") + print(f" {len(result.objects)} OBIS objects decoded:") for obj in result.objects: meta = OBIS_DESCRIPTIONS.get(obj.obis, {}) name = meta.get("name", obj.obis) @@ -40,43 +29,22 @@ def print_result(result) -> None: def test_hex(hex_str: str) -> None: - """Parse a single hex-encoded frame.""" raw = bytes.fromhex(hex_str.replace(" ", "").replace("\n", "")) - print(f"\n📦 Frame: {len(raw)} bytes") + print(f"\nFrame: {len(raw)} bytes") parser = DLMSParser() parser.feed(raw) result = parser.get_frame() if result: print_result(result) else: - print(" ⚠️ No complete frame found in data") - - -def test_file(path: str) -> None: - """Parse all frames from a binary capture file.""" - with open(path, "rb") as f: - data = f.read() - print(f"\n📂 File: {path} ({len(data)} bytes)") - parser = DLMSParser() - parser.feed(data) - count = 0 - while True: - result = parser.get_frame() - if result is None: - break - count += 1 - print(f"\n--- Frame #{count} ---") - print_result(result) - print(f"\nTotal frames parsed: {count}") + print(" No complete frame found in data") async def listen_tcp(host: str, port: int) -> None: - """Connect to the TCP adapter and print decoded frames as they arrive.""" - print(f"\n🔌 Connecting to {host}:{port} ...") + print(f"\nConnecting to {host}:{port} ...") reader, writer = await asyncio.open_connection(host, port) - print(" Connected. Waiting for DLMS PUSH frames (every ~60 s)...\n") + print("Connected. Waiting for DLMS PUSH frames...\n") parser = DLMSParser() - frame_count = 0 try: while True: chunk = await asyncio.wait_for(reader.read(4096), timeout=120) @@ -88,8 +56,6 @@ async def listen_tcp(host: str, port: int) -> None: result = parser.get_frame() if result is None: break - frame_count += 1 - print(f"\n--- Frame #{frame_count} raw: {result.raw_hex[:40]}... ---") print_result(result) except asyncio.TimeoutError: print("No data for 120 s, giving up.") @@ -101,15 +67,12 @@ def main() -> None: parser = argparse.ArgumentParser(description="XT211 DLMS parser test tool") group = parser.add_mutually_exclusive_group(required=True) group.add_argument("--hex", help="Hex-encoded raw frame to parse") - group.add_argument("--file", help="Binary capture file to parse") group.add_argument("--host", help="Adapter IP address for live TCP test") parser.add_argument("--port", type=int, default=8899, help="TCP port (default 8899)") args = parser.parse_args() if args.hex: test_hex(args.hex) - elif args.file: - test_file(args.file) elif args.host: asyncio.run(listen_tcp(args.host, args.port)) diff --git a/xt211_han.zip b/xt211_han.zip new file mode 100644 index 0000000000000000000000000000000000000000..c81d465f2564cc6dc9f306183624dc6ec19d03c7 GIT binary patch literal 14827 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 literal 0 HcmV?d00001