Compare commits
18 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
b80cd0d443 | |
|
|
641a8eb531 | |
|
|
a8b69d2487 | |
|
|
67be2ae88a | |
|
|
8432ae6153 | |
|
|
900aed571b | |
|
|
f96b6e6210 | |
|
|
68f60dc12e | |
|
|
2d333197f8 | |
|
|
facbd154bf | |
|
|
002d0059eb | |
|
|
cdd3b96486 | |
|
|
cb2c35b99e | |
|
|
7339ca8d1f | |
|
|
a4c4e79ac8 | |
|
|
c62f35f863 | |
|
|
e59dae1315 | |
|
|
c9037948de |
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 0.8.9
|
||||||
|
- opraven HACS release balík pro `content_in_root: true`
|
||||||
|
- release asset `xt211_han.zip` už obsahuje přímo soubory integrace v rootu ZIPu
|
||||||
|
- doplněno `content_in_root` do `hacs.json`
|
||||||
|
- sjednocena verze balíku na 0.8.9
|
||||||
|
|
||||||
|
## 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
|
||||||
|
- přidána složka `docs/pdfs` s podkladovou dokumentací k RS485 HAN a OBIS kódům
|
||||||
|
- README upraveno podle reálně ověřeného chování elektroměru XT211 / Relay box
|
||||||
|
- sjednocen popis dostupných entit a známých omezení
|
||||||
|
|
||||||
|
## 0.7.7
|
||||||
|
- opraven parser DLMS/COSEM a zpracování TCP streamu
|
||||||
|
- opraveno vytváření binárních senzorů
|
||||||
|
- opraveno mapování výrobního čísla na `0-0:96.1.1.255`
|
||||||
|
- odstraněno vytváření trvale prázdných textových entit
|
||||||
|
|
||||||
|
## 0.7.6
|
||||||
|
- první větší oprava parseru a logování
|
||||||
223
README.md
223
README.md
|
|
@ -3,127 +3,132 @@
|
||||||
[](https://github.com/hacs/integration)
|
[](https://github.com/hacs/integration)
|
||||||

|

|
||||||
|
|
||||||
> **Čtení dat z elektroměru Sagemcom XT211 (ČEZ Distribuce) přes RS485-to-Ethernet adaptér – bez ESP32.**
|
> Čtení dat z elektroměru Sagemcom XT211 / Relay box (ČEZ Distribuce) přes RS485-to-Ethernet převodník do Home Assistantu.
|
||||||
|
|
||||||
Tato integrace nahrazuje ESPHome řešení s ESP32 + RS485→TTL převodníkem. Místo toho používá průmyslový RS485-to-Ethernet adaptér (doporučen **PUSR USR-DR134**), který posílá syrová RS485 data přes TCP přímo do Home Assistantu.
|
Tahle integrace čte push data z HAN / RS485 rozhraní elektroměru přes TCP server na převodníku.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Jak to funguje
|
## Jak to funguje
|
||||||
|
|
||||||
```
|
```text
|
||||||
XT211 / WM-RelayBox
|
XT211 / Relay box
|
||||||
└── RJ12 HAN port (RS485, 9600 baud)
|
└── RJ12 HAN port (RS485, 9600 Bd)
|
||||||
└── USR-DR134 (RS485 → Ethernet)
|
└── RS485 → Ethernet převodník
|
||||||
└── TCP socket (LAN)
|
└── TCP server na LAN
|
||||||
└── Home Assistant (tato integrace)
|
└── Home Assistant
|
||||||
```
|
```
|
||||||
|
|
||||||
Elektroměr posílá DLMS/COSEM PUSH zprávy každých **60 sekund**. Integrace udržuje persistentní TCP spojení k adaptéru a dekóduje příchozí HDLC rámce.
|
- 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
|
||||||
|
|
||||||
## Požadavky
|
- 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
|
||||||
- Home Assistant 2024.1+
|
|
||||||
- RS485-to-Ethernet adaptér s TCP server módem:
|
|
||||||
- **PUSR USR-DR134** (doporučeno) – RS485, DIN rail, 5–24V
|
|
||||||
- Nebo jiný kompatibilní adaptér (USR-TCP232-410S, Waveshare, apod.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Instalace přes HACS
|
## Instalace přes HACS
|
||||||
|
|
||||||
1. Otevři HACS → **Integrace** → tři tečky vpravo nahoře → **Vlastní repozitáře**
|
1. Otevři HACS → **Integrace** → **Vlastní repozitáře**.
|
||||||
2. Přidej URL tohoto repozitáře, kategorie: **Integration**
|
2. Přidej URL tohoto repozitáře jako typ **Integration** (https://github.com/nero150/CEZ_rele_box).
|
||||||
3. Najdi „XT211 HAN" a nainstaluj
|
3. Nainstaluj integraci **XT211 HAN**.
|
||||||
4. Restartuj Home Assistant
|
4. Restartuj Home Assistant.
|
||||||
5. **Nastavení → Zařízení a služby → Přidat integraci → XT211 HAN**
|
5. V **Nastavení → Zařízení a služby** přidej integraci **XT211 HAN**.
|
||||||
|
|
||||||
---
|
## Nastavení převodníku
|
||||||
|
|
||||||
## Nastavení adaptéru USR-DR134
|
### 1. Síťové nastavení
|
||||||
|
|
||||||
Nastavení přes webové rozhraní adaptéru (výchozí IP `192.168.0.7`):
|
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`
|
||||||
|
|
||||||
| Parametr | Hodnota |
|

|
||||||
|----------|---------|
|
|
||||||
| Work Mode | **TCP Server** |
|
|
||||||
| Local Port | `8899` (nebo libovolný) |
|
|
||||||
| Baud Rate | `9600` |
|
|
||||||
| Data Bits | `8` |
|
|
||||||
| Stop Bits | `1` |
|
|
||||||
| Parity | `None` |
|
|
||||||
| Flow Control | `None` |
|
|
||||||
|
|
||||||
> ⚠️ Použij model **USR-DR134** (RS485), ne DR132 (RS232)!
|
### 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`
|
||||||
|
|
||||||
## Zapojení
|

|
||||||
|
|
||||||
```
|
### 3. Kontrola, že převodník opravdu posílá data
|
||||||
WM-RelayBox HAN port (RJ12):
|
|
||||||
Pin 3 (Data A+) → USR-DR134 terminal A+
|
|
||||||
Pin 4 (Data B-) → USR-DR134 terminal B-
|
|
||||||
Pin 6 (GND) → USR-DR134 GND (volitelné)
|
|
||||||
```
|
|
||||||
|
|
||||||
Napájení USR-DR134: 5–24V DC (např. z USB adaptéru přes step-up, nebo 12V zdroj).
|
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.
|
||||||
|
|
||||||
---
|

|
||||||
|
|
||||||
## Dostupné entity (27 celkem)
|
## Zapojení RJ12 / RS485
|
||||||
|
|
||||||
### 📊 Výkon (W) – okamžité hodnoty
|
Podle dokumentace ČEZ je konektor RJ12 zapojen takto:
|
||||||
| # | Název entity | OBIS kód |
|
- pin 3 = `Data A`
|
||||||
|---|---|---|
|
- pin 4 = `Data B`
|
||||||
| 5 | Power Limiter Value | `0-0:17.0.0.255` |
|
- pin 6 = `Shield / GND` nebo `Data GND`
|
||||||
| 13 | Active Power Import | `1-0:1.7.0.255` |
|
|
||||||
| 14 | Active Power Import L1 | `1-0:21.7.0.255` |
|
|
||||||
| 15 | Active Power Import L2 | `1-0:41.7.0.255` |
|
|
||||||
| 16 | Active Power Import L3 | `1-0:61.7.0.255` |
|
|
||||||
| 17 | Active Power Export | `1-0:2.7.0.255` |
|
|
||||||
| 18 | Active Power Export L1 | `1-0:22.7.0.255` |
|
|
||||||
| 19 | Active Power Export L2 | `1-0:42.7.0.255` |
|
|
||||||
| 20 | Active Power Export L3 | `1-0:62.7.0.255` |
|
|
||||||
|
|
||||||
### ⚡ Energie (kWh) – kumulativní
|

|
||||||
| # | Název entity | OBIS kód |
|
|
||||||
|---|---|---|
|
|
||||||
| 21 | Energy Import | `1-0:1.8.0.255` |
|
|
||||||
| 22 | Energy Import T1 | `1-0:1.8.1.255` |
|
|
||||||
| 23 | Energy Import T2 | `1-0:1.8.2.255` |
|
|
||||||
| 24 | Energy Import T3 | `1-0:1.8.3.255` |
|
|
||||||
| 25 | Energy Import T4 | `1-0:1.8.4.255` |
|
|
||||||
| 26 | Energy Export | `1-0:2.8.0.255` |
|
|
||||||
|
|
||||||
### 🔀 Binární senzory (zapnuto/vypnuto)
|
## Co integrace reálně čte
|
||||||
| # | Název entity | OBIS kód |
|
|
||||||
|---|---|---|
|
|
||||||
| 4 | Disconnector Status | `0-0:96.3.10.255` |
|
|
||||||
| 6 | Relay R1 Status | `0-1:96.3.10.255` |
|
|
||||||
| 7 | Relay R2 Status | `0-2:96.3.10.255` |
|
|
||||||
| 8 | Relay R3 Status | `0-3:96.3.10.255` |
|
|
||||||
| 9 | Relay R4 Status | `0-4:96.3.10.255` |
|
|
||||||
| 10 | Relay R5 Status | `0-5:96.3.10.255` |
|
|
||||||
| 11 | Relay R6 Status | `0-6:96.3.10.255` |
|
|
||||||
|
|
||||||
### 📋 Diagnostika (text)
|
Na reálně otestované sestavě se z XT211 / Relay boxu četou tyto hodnoty:
|
||||||
| # | Název entity | OBIS kód |
|
- dodávka energie celkem
|
||||||
|---|---|---|
|
- spotřeba energie celkem
|
||||||
| 1 | COSEM Logical Device Name | `0-0:42.0.0.255` |
|
- spotřeba energie T1
|
||||||
| 3 | Serial Number | `0-0:96.1.0.255` |
|
- spotřeba energie T2
|
||||||
| 12 | Current Tariff | `0-0:96.14.0.255` |
|
- okamžitý příkon odběru celkem
|
||||||
| 27 | Consumer Message | `0-0:96.13.0.255` |
|
- 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
|
||||||
|
|
||||||
> ℹ️ Idx 2 (`0-2:25.9.0.255` – Push setup) je interní konfigurační objekt elektroměru, neobsahuje měřená data.
|
## 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`
|
||||||
|
|
||||||
## Ladění (debug)
|
### 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á
|
||||||
|
|
||||||
Přidej do `configuration.yaml`:
|
### 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`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
logger:
|
logger:
|
||||||
|
|
@ -132,35 +137,19 @@ logger:
|
||||||
custom_components.xt211_han: debug
|
custom_components.xt211_han: debug
|
||||||
```
|
```
|
||||||
|
|
||||||
V logu uvidíš surová hex data každého HDLC rámce a dekódované OBIS hodnoty.
|
V logu pak uvidíš:
|
||||||
|
- příjem TCP dat
|
||||||
|
- složení rámců ze streamu
|
||||||
|
- parsed OBIS objekty
|
||||||
|
- aktualizaci coordinatoru
|
||||||
|
|
||||||
---
|
## Podklady v repozitáři
|
||||||
|
|
||||||
## Struktura repozitáře
|
- `docs/pdfs/cez_rs485_han_interface.pdf`
|
||||||
|
- `docs/pdfs/cez_obis_codes_han_2025-02-01.pdf`
|
||||||
|
|
||||||
```
|
## Poděkování
|
||||||
custom_components/xt211_han/
|
- Děkuji za inspiraci: https://github.com/Tomer27cz/xt211
|
||||||
├── __init__.py # Inicializace integrace
|
|
||||||
├── manifest.json # Metadata pro HA / HACS
|
|
||||||
├── const.py # Konstanty
|
|
||||||
├── config_flow.py # UI průvodce nastavením
|
|
||||||
├── coordinator.py # TCP listener + DataUpdateCoordinator
|
|
||||||
├── sensor.py # Senzorová platforma
|
|
||||||
├── dlms_parser.py # HDLC / DLMS / COSEM parser
|
|
||||||
├── strings.json # Texty UI
|
|
||||||
└── translations/
|
|
||||||
├── cs.json # Čeština
|
|
||||||
└── en.json # Angličtina
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Poděkování / Credits
|
|
||||||
|
|
||||||
- [Tomer27cz/xt211](https://github.com/Tomer27cz/xt211) – původní ESPHome komponenta a dokumentace protokolu
|
|
||||||
- ČEZ Distribuce – dokumentace OBIS kódů a RS485 rozhraní
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -15,21 +15,11 @@ from .sensor import BINARY_OBIS, build_enabled_obis, _device_info
|
||||||
from .dlms_parser import OBIS_DESCRIPTIONS
|
from .dlms_parser import OBIS_DESCRIPTIONS
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
|
||||||
hass: HomeAssistant,
|
|
||||||
entry: ConfigEntry,
|
|
||||||
async_add_entities: AddEntitiesCallback,
|
|
||||||
) -> None:
|
|
||||||
coordinator: XT211Coordinator = hass.data[DOMAIN][entry.entry_id]
|
coordinator: XT211Coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
enabled_obis = build_enabled_obis(entry)
|
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)
|
async_add_entities(entities)
|
||||||
|
|
||||||
registered_obis = {entity._obis for entity in entities}
|
registered_obis = {entity._obis for entity in entities}
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
|
|
|
||||||
|
|
@ -15,59 +15,18 @@ from homeassistant.components import dhcp
|
||||||
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
|
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
|
||||||
from homeassistant.data_entry_flow import FlowResult
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
|
|
||||||
from .const import (
|
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
|
||||||
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__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
USR_IOT_MAC_PREFIXES = ("d8b04c", "b4e62d")
|
USR_IOT_MAC_PREFIXES = ("d8b04c", "b4e62d")
|
||||||
MANUAL_CHOICE = "__manual__"
|
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_CONNECTION_SCHEMA = vol.Schema(
|
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_HOST): str,
|
vol.Required(CONF_HAS_FVE, default=False): bool,
|
||||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
|
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.Optional(CONF_NAME, default=DEFAULT_NAME): str,
|
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_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:
|
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"
|
return "cannot_connect"
|
||||||
except OSError:
|
except OSError:
|
||||||
return "cannot_connect"
|
return "cannot_connect"
|
||||||
except Exception: # pragma: no cover - defensive
|
except Exception:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -96,15 +55,12 @@ async def _scan_network(port: int, timeout: float = 1.0) -> list[str]:
|
||||||
local_ip = socket.gethostbyname(socket.gethostname())
|
local_ip = socket.gethostbyname(socket.gethostname())
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
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"
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
found: list[str] = []
|
found: list[str] = []
|
||||||
|
|
||||||
async def _probe(ip: str) -> None:
|
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()]
|
hosts = [str(host) for host in network.hosts()]
|
||||||
for index in range(0, len(hosts), 50):
|
for index in range(0, len(hosts), 50):
|
||||||
await asyncio.gather(*(_probe(ip) for ip in hosts[index:index + 50]))
|
await asyncio.gather(*(_probe(ip) for ip in hosts[index:index + 50]))
|
||||||
|
|
||||||
found.sort()
|
found.sort()
|
||||||
_LOGGER.debug("XT211 scan found %d host(s) on port %d: %s", len(found), port, found)
|
_LOGGER.debug("XT211 scan found %d host(s) on port %d: %s", len(found), port, found)
|
||||||
return found
|
return found
|
||||||
|
|
@ -141,11 +96,9 @@ class XT211HANConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
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
|
||||||
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)
|
_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:
|
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:
|
||||||
return self.async_show_form(
|
return self.async_show_form(step_id="dhcp_confirm", errors={"base": error}, description_placeholders={"host": self._discovered_host, "port": str(self._discovered_port)})
|
||||||
step_id="dhcp_confirm",
|
self._connection_data = {CONF_HOST: self._discovered_host, CONF_PORT: self._discovered_port, CONF_NAME: DEFAULT_NAME}
|
||||||
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 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:
|
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult:
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
return await (self.async_step_scan() if user_input.get("method") == "scan" else self.async_step_manual())
|
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:
|
async def async_step_scan(self, user_input: dict[str, Any] | None = None) -> FlowResult:
|
||||||
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:
|
if host == MANUAL_CHOICE:
|
||||||
return await self.async_step_manual()
|
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)
|
||||||
|
|
||||||
await self.async_set_unique_id(f"{host}:{port}")
|
await self.async_set_unique_id(f"{host}:{port}")
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
error = await _test_connection(host, port)
|
error = await _test_connection(host, port)
|
||||||
if error:
|
if error:
|
||||||
return self.async_show_form(
|
return self.async_show_form(step_id="scan", data_schema=self._scan_schema(port, include_choices=not self._scan_results == []), errors={"base": error})
|
||||||
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}
|
self._connection_data = {CONF_HOST: host, CONF_PORT: port, CONF_NAME: name}
|
||||||
return await self.async_step_meter()
|
return await self.async_step_meter()
|
||||||
|
|
||||||
self._scan_results = await _scan_network(DEFAULT_PORT)
|
self._scan_results = await _scan_network(DEFAULT_PORT)
|
||||||
if not self._scan_results:
|
if not self._scan_results:
|
||||||
return self.async_show_form(
|
return self.async_show_form(step_id="scan", data_schema=self._scan_schema(DEFAULT_PORT, include_choices=False), errors={"base": "no_devices_found"})
|
||||||
step_id="scan",
|
return self.async_show_form(step_id="scan", data_schema=self._scan_schema(DEFAULT_PORT, include_choices=True))
|
||||||
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:
|
def _scan_schema(self, port: int, include_choices: bool) -> vol.Schema:
|
||||||
if include_choices:
|
if include_choices:
|
||||||
choices = {ip: f"{ip}:{port}" for ip in self._scan_results}
|
choices = {ip: f"{ip}:{port}" for ip in self._scan_results}
|
||||||
choices[MANUAL_CHOICE] = "✏️ Zadat IP adresu ručně"
|
choices[MANUAL_CHOICE] = "✏️ Zadat IP adresu ručně"
|
||||||
return vol.Schema(
|
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})
|
||||||
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:
|
async def async_step_manual(self, user_input: dict[str, Any] | None = None) -> FlowResult:
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
|
|
@ -258,17 +150,14 @@ class XT211HANConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
host = user_input[CONF_HOST]
|
host = user_input[CONF_HOST]
|
||||||
port = user_input[CONF_PORT]
|
port = user_input[CONF_PORT]
|
||||||
name = user_input.get(CONF_NAME, DEFAULT_NAME)
|
name = user_input.get(CONF_NAME, DEFAULT_NAME)
|
||||||
|
|
||||||
await self.async_set_unique_id(f"{host}:{port}")
|
await self.async_set_unique_id(f"{host}:{port}")
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
error = await _test_connection(host, port)
|
error = await _test_connection(host, port)
|
||||||
if error:
|
if error:
|
||||||
errors["base"] = error
|
errors["base"] = error
|
||||||
else:
|
else:
|
||||||
self._connection_data = {CONF_HOST: host, CONF_PORT: port, CONF_NAME: name}
|
self._connection_data = {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(step_id="manual", data_schema=STEP_CONNECTION_SCHEMA, errors=errors)
|
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:
|
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]
|
host = data[CONF_HOST]
|
||||||
port = data[CONF_PORT]
|
port = data[CONF_PORT]
|
||||||
return self.async_create_entry(title=f"{name} ({host}:{port})", data=data)
|
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)
|
return self.async_show_form(step_id="meter", data_schema=STEP_METER_SCHEMA)
|
||||||
|
|
|
||||||
|
|
@ -13,16 +13,11 @@ CONF_RELAY_COUNT = "relay_count"
|
||||||
DEFAULT_PORT = 8899
|
DEFAULT_PORT = 8899
|
||||||
DEFAULT_NAME = "XT211 HAN"
|
DEFAULT_NAME = "XT211 HAN"
|
||||||
|
|
||||||
# Phases
|
|
||||||
PHASES_1 = "1"
|
PHASES_1 = "1"
|
||||||
PHASES_3 = "3"
|
PHASES_3 = "3"
|
||||||
|
|
||||||
# Tariff counts
|
|
||||||
TARIFFS_1 = 1
|
TARIFFS_1 = 1
|
||||||
TARIFFS_2 = 2
|
TARIFFS_2 = 2
|
||||||
TARIFFS_4 = 4
|
TARIFFS_4 = 4
|
||||||
|
|
||||||
# Relay counts
|
|
||||||
RELAYS_0 = 0
|
RELAYS_0 = 0
|
||||||
RELAYS_4 = 4
|
RELAYS_4 = 4
|
||||||
RELAYS_6 = 6
|
RELAYS_6 = 6
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||||
from .dlms_parser import DLMSObject, DLMSParser, OBIS_DESCRIPTIONS
|
from .dlms_parser import DLMSObject, DLMSParser, OBIS_DESCRIPTIONS
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
PUSH_TIMEOUT = 90
|
PUSH_TIMEOUT = 90
|
||||||
RECONNECT_DELAY = 10
|
RECONNECT_DELAY = 10
|
||||||
|
|
||||||
|
|
@ -21,12 +20,7 @@ class XT211Coordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||||
"""Persistent TCP listener for XT211 DLMS push frames."""
|
"""Persistent TCP listener for XT211 DLMS push frames."""
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, host: str, port: int, name: str) -> None:
|
def __init__(self, hass: HomeAssistant, host: str, port: int, name: str) -> None:
|
||||||
super().__init__(
|
super().__init__(hass, _LOGGER, name=f"XT211 HAN ({host}:{port})", update_interval=None)
|
||||||
hass,
|
|
||||||
_LOGGER,
|
|
||||||
name=f"XT211 HAN ({host}:{port})",
|
|
||||||
update_interval=None,
|
|
||||||
)
|
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
self.device_name = name
|
self.device_name = name
|
||||||
|
|
@ -79,15 +73,11 @@ class XT211Coordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
await self._disconnect()
|
await self._disconnect()
|
||||||
|
|
||||||
await asyncio.sleep(RECONNECT_DELAY)
|
await asyncio.sleep(RECONNECT_DELAY)
|
||||||
|
|
||||||
async def _connect(self) -> None:
|
async def _connect(self) -> None:
|
||||||
_LOGGER.info("Connecting to XT211 adapter at %s:%d", self.host, self.port)
|
_LOGGER.info("Connecting to XT211 adapter at %s:%d", self.host, self.port)
|
||||||
self._reader, self._writer = await asyncio.wait_for(
|
self._reader, self._writer = await asyncio.wait_for(asyncio.open_connection(self.host, self.port), timeout=10)
|
||||||
asyncio.open_connection(self.host, self.port),
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
self._parser = DLMSParser()
|
self._parser = DLMSParser()
|
||||||
self._connected = True
|
self._connected = True
|
||||||
_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)
|
||||||
|
|
@ -105,50 +95,34 @@ class XT211Coordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||||
|
|
||||||
async def _receive_loop(self) -> None:
|
async def _receive_loop(self) -> None:
|
||||||
assert self._reader is not None
|
assert self._reader is not None
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
chunk = await asyncio.wait_for(self._reader.read(4096), timeout=PUSH_TIMEOUT)
|
chunk = await asyncio.wait_for(self._reader.read(4096), timeout=PUSH_TIMEOUT)
|
||||||
except asyncio.TimeoutError as exc:
|
except asyncio.TimeoutError as exc:
|
||||||
_LOGGER.warning("No data from XT211 for %d s – reconnecting", PUSH_TIMEOUT)
|
_LOGGER.warning("No data from XT211 for %d s – reconnecting", PUSH_TIMEOUT)
|
||||||
raise ConnectionError("Push timeout") from exc
|
raise ConnectionError("Push timeout") from exc
|
||||||
|
|
||||||
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())
|
_LOGGER.debug("XT211 RX %d bytes: %s", len(chunk), chunk.hex())
|
||||||
self._parser.feed(chunk)
|
self._parser.feed(chunk)
|
||||||
|
|
||||||
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
|
self._frames_received += 1
|
||||||
if result.success:
|
if result.success:
|
||||||
_LOGGER.debug(
|
_LOGGER.debug("XT211 frame #%d parsed OK: %d object(s)", self._frames_received, len(result.objects))
|
||||||
"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("XT211 frame #%d parse error: %s (raw: %s)", self._frames_received, result.error, result.raw_hex[:120])
|
||||||
"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:
|
async def _process_frame(self, objects: list[DLMSObject]) -> None:
|
||||||
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] = []
|
changed: list[str] = []
|
||||||
|
|
||||||
for obj in objects:
|
for obj in objects:
|
||||||
meta = OBIS_DESCRIPTIONS.get(obj.obis, {})
|
meta = OBIS_DESCRIPTIONS.get(obj.obis, {})
|
||||||
new_value = {
|
new_value = {
|
||||||
|
|
@ -161,11 +135,5 @@ class XT211Coordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||||
changed.append(obj.obis)
|
changed.append(obj.obis)
|
||||||
current[obj.obis] = new_value
|
current[obj.obis] = new_value
|
||||||
_LOGGER.debug("XT211 OBIS %s = %r %s", obj.obis, obj.value, new_value["unit"])
|
_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(
|
_LOGGER.debug("Coordinator updated with %d object(s), %d changed: %s", len(objects), len(changed), ", ".join(changed[:10]))
|
||||||
"Coordinator updated with %d object(s), %d changed: %s",
|
|
||||||
len(objects),
|
|
||||||
len(changed),
|
|
||||||
", ".join(changed[:10]),
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,7 @@ 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_TYPE_NULL = 0x00
|
DLMS_TYPE_NULL = 0x00
|
||||||
DLMS_TYPE_ARRAY = 0x01
|
DLMS_TYPE_ARRAY = 0x01
|
||||||
DLMS_TYPE_STRUCTURE = 0x02
|
DLMS_TYPE_STRUCTURE = 0x02
|
||||||
|
|
@ -32,7 +30,7 @@ DLMS_TYPE_FLOAT64 = 0x18
|
||||||
|
|
||||||
|
|
||||||
class NeedMoreData(Exception):
|
class NeedMoreData(Exception):
|
||||||
"""Raised when the parser needs more bytes to finish a frame."""
|
pass
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -52,8 +50,6 @@ class ParseResult:
|
||||||
|
|
||||||
|
|
||||||
class DLMSParser:
|
class DLMSParser:
|
||||||
"""Stateful parser for raw DLMS APDUs and HDLC-wrapped frames."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._buffer = bytearray()
|
self._buffer = bytearray()
|
||||||
|
|
||||||
|
|
@ -61,53 +57,42 @@ class DLMSParser:
|
||||||
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."""
|
|
||||||
if not self._buffer:
|
if not self._buffer:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if self._buffer[0] == HDLC_FLAG:
|
if self._buffer[0] == HDLC_FLAG:
|
||||||
return self._get_hdlc_frame()
|
return self._get_hdlc_frame()
|
||||||
|
|
||||||
start = self._find_apdu_start(self._buffer)
|
start = self._find_apdu_start(self._buffer)
|
||||||
if start == -1:
|
if start == -1:
|
||||||
_LOGGER.debug("Discarding %d bytes without known frame start", len(self._buffer))
|
_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:
|
if start > 0:
|
||||||
_LOGGER.debug("Discarding %d leading byte(s) before APDU", start)
|
_LOGGER.debug("Discarding %d leading byte(s) before APDU", start)
|
||||||
del self._buffer[:start]
|
del self._buffer[:start]
|
||||||
|
|
||||||
if self._buffer and self._buffer[0] == 0x0F:
|
if self._buffer and self._buffer[0] == 0x0F:
|
||||||
return self._get_raw_apdu_frame()
|
return self._get_raw_apdu_frame()
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _get_hdlc_frame(self) -> ParseResult | None:
|
def _get_hdlc_frame(self) -> ParseResult | None:
|
||||||
buf = self._buffer
|
buf = self._buffer
|
||||||
if len(buf) < 3:
|
if len(buf) < 3:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
frame_len = ((buf[1] & 0x07) << 8) | buf[2]
|
frame_len = ((buf[1] & 0x07) << 8) | buf[2]
|
||||||
total = frame_len + 2
|
total = frame_len + 2
|
||||||
if len(buf) < total:
|
if len(buf) < total:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
raw = bytes(buf[:total])
|
raw = bytes(buf[:total])
|
||||||
del buf[:total]
|
del buf[:total]
|
||||||
raw_hex = raw.hex()
|
raw_hex = raw.hex()
|
||||||
|
|
||||||
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")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
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 NeedMoreData:
|
except NeedMoreData:
|
||||||
# Should not happen for HDLC because total length is known.
|
|
||||||
return None
|
return None
|
||||||
except Exception as exc: # pragma: no cover - defensive logging
|
except Exception as exc:
|
||||||
_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))
|
||||||
|
|
||||||
|
|
@ -117,12 +102,11 @@ class DLMSParser:
|
||||||
result, consumed = self._parse_apdu_with_length(bytes(buf))
|
result, consumed = self._parse_apdu_with_length(bytes(buf))
|
||||||
except NeedMoreData:
|
except NeedMoreData:
|
||||||
return None
|
return None
|
||||||
except Exception as exc: # pragma: no cover - defensive logging
|
except Exception as exc:
|
||||||
raw_hex = bytes(buf).hex()
|
raw_hex = bytes(buf).hex()
|
||||||
_LOGGER.exception("Error parsing raw DLMS APDU")
|
_LOGGER.exception("Error parsing raw DLMS APDU")
|
||||||
del buf[:]
|
del buf[:]
|
||||||
return ParseResult(success=False, raw_hex=raw_hex, error=str(exc))
|
return ParseResult(success=False, raw_hex=raw_hex, error=str(exc))
|
||||||
|
|
||||||
raw = bytes(buf[:consumed])
|
raw = bytes(buf[:consumed])
|
||||||
del buf[:consumed]
|
del buf[:consumed]
|
||||||
result.raw_hex = raw.hex()
|
result.raw_hex = raw.hex()
|
||||||
|
|
@ -130,16 +114,14 @@ class DLMSParser:
|
||||||
|
|
||||||
def _parse_hdlc(self, raw: bytes) -> ParseResult:
|
def _parse_hdlc(self, raw: bytes) -> ParseResult:
|
||||||
pos = 1
|
pos = 1
|
||||||
pos += 2 # frame format
|
pos += 2
|
||||||
_, pos = self._read_hdlc_address(raw, pos)
|
_, pos = self._read_hdlc_address(raw, pos)
|
||||||
_, pos = self._read_hdlc_address(raw, pos)
|
_, pos = self._read_hdlc_address(raw, pos)
|
||||||
pos += 1 # control
|
pos += 1
|
||||||
pos += 2 # HCS
|
pos += 2
|
||||||
|
|
||||||
if pos + 3 > len(raw) - 3:
|
if pos + 3 > len(raw) - 3:
|
||||||
raise ValueError("Frame too short for LLC")
|
raise ValueError("Frame too short for LLC")
|
||||||
|
pos += 3
|
||||||
pos += 3 # LLC header
|
|
||||||
apdu = raw[pos:-3]
|
apdu = raw[pos:-3]
|
||||||
result, _ = self._parse_apdu_with_length(apdu)
|
result, _ = self._parse_apdu_with_length(apdu)
|
||||||
return result
|
return result
|
||||||
|
|
@ -164,15 +146,12 @@ class DLMSParser:
|
||||||
raise ValueError(f"Unexpected APDU tag 0x{apdu[0]:02X}")
|
raise ValueError(f"Unexpected APDU tag 0x{apdu[0]:02X}")
|
||||||
if len(apdu) < 6:
|
if len(apdu) < 6:
|
||||||
raise NeedMoreData
|
raise NeedMoreData
|
||||||
|
|
||||||
pos = 1
|
pos = 1
|
||||||
invoke_id = struct.unpack_from(">I", apdu, pos)[0]
|
invoke_id = struct.unpack_from(">I", apdu, pos)[0]
|
||||||
pos += 4
|
pos += 4
|
||||||
_LOGGER.debug("XT211 invoke_id=0x%08X", invoke_id)
|
_LOGGER.debug("XT211 invoke_id=0x%08X", invoke_id)
|
||||||
|
|
||||||
if pos >= len(apdu):
|
if pos >= len(apdu):
|
||||||
raise NeedMoreData
|
raise NeedMoreData
|
||||||
|
|
||||||
if apdu[pos] == DLMS_TYPE_OCTET_STRING:
|
if apdu[pos] == DLMS_TYPE_OCTET_STRING:
|
||||||
pos += 1
|
pos += 1
|
||||||
dt_len, pos = self._decode_length(apdu, pos)
|
dt_len, pos = self._decode_length(apdu, pos)
|
||||||
|
|
@ -180,7 +159,6 @@ class DLMSParser:
|
||||||
pos += dt_len
|
pos += dt_len
|
||||||
elif apdu[pos] == DLMS_TYPE_NULL:
|
elif apdu[pos] == DLMS_TYPE_NULL:
|
||||||
pos += 1
|
pos += 1
|
||||||
|
|
||||||
self._require(apdu, pos, 2)
|
self._require(apdu, pos, 2)
|
||||||
if apdu[pos] != DLMS_TYPE_STRUCTURE:
|
if apdu[pos] != DLMS_TYPE_STRUCTURE:
|
||||||
return ParseResult(success=True, objects=[]), pos
|
return ParseResult(success=True, objects=[]), pos
|
||||||
|
|
@ -188,7 +166,6 @@ class DLMSParser:
|
||||||
pos += 2
|
pos += 2
|
||||||
if structure_count < 2:
|
if structure_count < 2:
|
||||||
return ParseResult(success=True, objects=[]), pos
|
return ParseResult(success=True, objects=[]), pos
|
||||||
|
|
||||||
if pos >= len(apdu):
|
if pos >= len(apdu):
|
||||||
raise NeedMoreData
|
raise NeedMoreData
|
||||||
if apdu[pos] == DLMS_TYPE_ENUM:
|
if apdu[pos] == DLMS_TYPE_ENUM:
|
||||||
|
|
@ -196,20 +173,17 @@ class DLMSParser:
|
||||||
pos += 2
|
pos += 2
|
||||||
else:
|
else:
|
||||||
_, pos = self._decode_value(apdu, pos)
|
_, pos = self._decode_value(apdu, pos)
|
||||||
|
|
||||||
if pos >= len(apdu):
|
if pos >= len(apdu):
|
||||||
raise NeedMoreData
|
raise NeedMoreData
|
||||||
if apdu[pos] != DLMS_TYPE_ARRAY:
|
if apdu[pos] != DLMS_TYPE_ARRAY:
|
||||||
return ParseResult(success=True, objects=[]), pos
|
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)
|
||||||
objects: list[DLMSObject] = []
|
objects: list[DLMSObject] = []
|
||||||
for _ in range(array_count):
|
for _ in range(array_count):
|
||||||
obj, pos = self._parse_xt211_object(apdu, pos)
|
obj, pos = self._parse_xt211_object(apdu, pos)
|
||||||
if obj is not None:
|
if obj is not None:
|
||||||
objects.append(obj)
|
objects.append(obj)
|
||||||
|
|
||||||
return ParseResult(success=True, objects=objects), pos
|
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]:
|
||||||
|
|
@ -217,13 +191,9 @@ class DLMSParser:
|
||||||
if data[pos] != DLMS_TYPE_STRUCTURE:
|
if data[pos] != DLMS_TYPE_STRUCTURE:
|
||||||
raise ValueError(f"Expected object structure at {pos}, got 0x{data[pos]:02X}")
|
raise ValueError(f"Expected object structure at {pos}, got 0x{data[pos]:02X}")
|
||||||
pos += 1
|
pos += 1
|
||||||
|
|
||||||
count, pos = self._decode_length(data, pos)
|
count, pos = self._decode_length(data, pos)
|
||||||
if count < 1:
|
if count < 1:
|
||||||
raise ValueError(f"Unexpected object element count {count}")
|
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 < len(data) and data[pos] == 0x00:
|
||||||
if pos + 10 > len(data):
|
if pos + 10 > len(data):
|
||||||
raise NeedMoreData
|
raise NeedMoreData
|
||||||
|
|
@ -231,34 +201,17 @@ class DLMSParser:
|
||||||
pos += 2
|
pos += 2
|
||||||
obis_raw = bytes(data[pos:pos + 6])
|
obis_raw = bytes(data[pos:pos + 6])
|
||||||
pos += 6
|
pos += 6
|
||||||
_attr_idx = data[pos]
|
|
||||||
pos += 1
|
pos += 1
|
||||||
value, pos = self._decode_value(data, pos)
|
value, pos = self._decode_value(data, pos)
|
||||||
|
|
||||||
if isinstance(value, (bytes, bytearray)):
|
if isinstance(value, (bytes, bytearray)):
|
||||||
try:
|
try:
|
||||||
value = bytes(value).decode("ascii", errors="replace").strip("\x00")
|
value = bytes(value).decode("ascii", errors="replace").strip("\x00")
|
||||||
except Exception:
|
except Exception:
|
||||||
value = bytes(value).hex()
|
value = bytes(value).hex()
|
||||||
|
|
||||||
obis = self._format_obis(obis_raw)
|
obis = self._format_obis(obis_raw)
|
||||||
meta = OBIS_DESCRIPTIONS.get(obis, {})
|
meta = OBIS_DESCRIPTIONS.get(obis, {})
|
||||||
_LOGGER.debug(
|
_LOGGER.debug("Parsed XT211 object class_id=%s obis=%s value=%r unit=%s", class_id, obis, value, meta.get("unit", ""))
|
||||||
"Parsed XT211 object class_id=%s obis=%s value=%r unit=%s",
|
return DLMSObject(obis=obis, value=value, unit=meta.get("unit", ""), scaler=0), pos
|
||||||
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.
|
|
||||||
last_value: Any = None
|
last_value: Any = None
|
||||||
for _ in range(count):
|
for _ in range(count):
|
||||||
last_value, pos = self._decode_value(data, pos)
|
last_value, pos = self._decode_value(data, pos)
|
||||||
|
|
@ -269,7 +222,6 @@ class DLMSParser:
|
||||||
self._require(data, pos, 1)
|
self._require(data, pos, 1)
|
||||||
dtype = data[pos]
|
dtype = data[pos]
|
||||||
pos += 1
|
pos += 1
|
||||||
|
|
||||||
if dtype == DLMS_TYPE_NULL:
|
if dtype == DLMS_TYPE_NULL:
|
||||||
return None, pos
|
return None, pos
|
||||||
if dtype == DLMS_TYPE_BOOL:
|
if dtype == DLMS_TYPE_BOOL:
|
||||||
|
|
@ -320,7 +272,6 @@ class DLMSParser:
|
||||||
item, pos = self._decode_value(data, pos)
|
item, pos = self._decode_value(data, pos)
|
||||||
items.append(item)
|
items.append(item)
|
||||||
return items, pos
|
return items, pos
|
||||||
|
|
||||||
raise ValueError(f"Unknown DLMS type 0x{dtype:02X} at pos {pos - 1}")
|
raise ValueError(f"Unknown DLMS type 0x{dtype:02X} at pos {pos - 1}")
|
||||||
|
|
||||||
def _decode_length(self, data: bytes, pos: int) -> tuple[int, int]:
|
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}"
|
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: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.0.255": {"name": "Výrobní číslo", "unit": "", "class": "text"},
|
||||||
"0-0:96.1.1.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.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"},
|
||||||
"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"},
|
||||||
"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"}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
{
|
{
|
||||||
"domain": "xt211_han",
|
"domain": "xt211_han",
|
||||||
"name": "XT211 HAN (RS485 via Ethernet)",
|
"name": "XT211 HAN (RS485 via Ethernet)",
|
||||||
"version": "0.7.6",
|
"version": "0.9.0",
|
||||||
"documentation": "https://github.com/nero150/xt211-han-ha",
|
"documentation": "https://github.com/nero150/CEZ_rele_box",
|
||||||
"issue_tracker": "https://github.com/nero150/xt211-han-ha/issues",
|
"issue_tracker": "https://github.com/nero150/CEZ_rele_box/issues",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"codeowners": [
|
"codeowners": [
|
||||||
"@nero150"
|
"@nero150"
|
||||||
|
|
|
||||||
|
|
@ -10,63 +10,27 @@ 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 CONF_HAS_FVE, CONF_PHASES, CONF_RELAY_COUNT, CONF_TARIFFS, DOMAIN, PHASES_3, RELAYS_4, TARIFFS_2
|
||||||
CONF_HAS_FVE,
|
|
||||||
CONF_PHASES,
|
|
||||||
CONF_RELAY_COUNT,
|
|
||||||
CONF_TARIFFS,
|
|
||||||
DOMAIN,
|
|
||||||
PHASES_3,
|
|
||||||
RELAYS_4,
|
|
||||||
TARIFFS_2,
|
|
||||||
)
|
|
||||||
from .coordinator import XT211Coordinator
|
from .coordinator import XT211Coordinator
|
||||||
from .dlms_parser import OBIS_DESCRIPTIONS
|
from .dlms_parser import OBIS_DESCRIPTIONS
|
||||||
|
|
||||||
SENSOR_META: dict[str, dict] = {
|
SENSOR_META = {
|
||||||
"power": {
|
"power": {"device_class": SensorDeviceClass.POWER, "state_class": SensorStateClass.MEASUREMENT, "unit": UnitOfPower.WATT},
|
||||||
"device_class": SensorDeviceClass.POWER,
|
"energy": {"device_class": SensorDeviceClass.ENERGY, "state_class": SensorStateClass.TOTAL_INCREASING, "unit": UnitOfEnergy.KILO_WATT_HOUR},
|
||||||
"state_class": SensorStateClass.MEASUREMENT,
|
"sensor": {"device_class": None, "state_class": SensorStateClass.MEASUREMENT, "unit": None},
|
||||||
"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")
|
||||||
TEXT_OBIS = {
|
PRECREATED_TEXT_ENTITIES = {
|
||||||
"0-0:42.0.0.255",
|
"serial_number": {"name": "Výrobní číslo", "obises": SERIAL_OBIS, "entity_category": EntityCategory.DIAGNOSTIC},
|
||||||
"0-0:96.1.0.255",
|
"current_tariff": {"name": "Aktuální tarif", "obises": ("0-0:96.14.0.255",), "entity_category": EntityCategory.DIAGNOSTIC},
|
||||||
"0-0:96.1.1.255",
|
|
||||||
"0-0:96.14.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",
|
|
||||||
}
|
}
|
||||||
|
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:
|
def _device_info(entry: ConfigEntry) -> DeviceInfo:
|
||||||
return DeviceInfo(
|
return DeviceInfo(identifiers={(DOMAIN, entry.entry_id)}, name=entry.data.get(CONF_NAME, "XT211 HAN"), manufacturer="Sagemcom", model="XT211 AMM")
|
||||||
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]:
|
def build_enabled_obis(entry: ConfigEntry) -> set[str]:
|
||||||
|
|
@ -74,61 +38,30 @@ def build_enabled_obis(entry: ConfigEntry) -> set[str]:
|
||||||
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))
|
||||||
|
enabled_obis = {"0-0:17.0.0.255", "1-0:1.7.0.255", "1-0:1.8.0.255", *TEXT_OBIS}
|
||||||
enabled_obis: set[str] = {
|
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"}
|
||||||
"0-0:42.0.0.255",
|
enabled_obis.add("0-0:96.3.10.255")
|
||||||
"0-0:96.1.0.255",
|
|
||||||
"0-0:96.1.1.255",
|
|
||||||
"0-0:96.14.0.255",
|
|
||||||
"0-0:96.13.0.255",
|
|
||||||
"0-0:96.3.10.255",
|
|
||||||
"0-0:17.0.0.255",
|
|
||||||
"1-0:1.7.0.255",
|
|
||||||
"1-0:1.8.0.255",
|
|
||||||
}
|
|
||||||
|
|
||||||
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",
|
|
||||||
}
|
|
||||||
for idx in range(1, relay_count + 1):
|
for idx in range(1, relay_count + 1):
|
||||||
enabled_obis.add(relay_obis[idx])
|
enabled_obis.add(relay_obis[idx])
|
||||||
|
|
||||||
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"})
|
||||||
|
|
||||||
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")
|
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"})
|
||||||
|
|
||||||
for tariff in range(1, tariffs + 1):
|
for tariff in range(1, tariffs + 1):
|
||||||
enabled_obis.add(f"1-0:1.8.{tariff}.255")
|
enabled_obis.add(f"1-0:1.8.{tariff}.255")
|
||||||
|
|
||||||
return enabled_obis
|
return enabled_obis
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
|
||||||
hass: HomeAssistant,
|
|
||||||
entry: ConfigEntry,
|
|
||||||
async_add_entities: AddEntitiesCallback,
|
|
||||||
) -> None:
|
|
||||||
coordinator: XT211Coordinator = hass.data[DOMAIN][entry.entry_id]
|
coordinator: XT211Coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
enabled_obis = build_enabled_obis(entry)
|
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 = [
|
entities.extend(XT211AliasedTextSensorEntity(coordinator, entry, key, spec) for key, spec in PRECREATED_TEXT_ENTITIES.items())
|
||||||
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)
|
||||||
|
registered_obis = {entity._obis for entity in entities if hasattr(entity, "_obis")}
|
||||||
registered_obis = {entity._obis for entity in entities}
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _on_update() -> None:
|
def _on_update() -> None:
|
||||||
|
|
@ -138,6 +71,12 @@ async def async_setup_entry(
|
||||||
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 or obis in BINARY_OBIS:
|
if obis in registered_obis or obis not in enabled_obis or obis in BINARY_OBIS:
|
||||||
continue
|
continue
|
||||||
|
if obis in DYNAMIC_TEXT_OBIS:
|
||||||
|
registered_obis.add(obis)
|
||||||
|
new_entities.append(XT211DynamicTextSensorEntity(coordinator, entry, obis, data))
|
||||||
|
continue
|
||||||
|
if obis in TEXT_OBIS:
|
||||||
|
continue
|
||||||
registered_obis.add(obis)
|
registered_obis.add(obis)
|
||||||
new_entities.append(XT211SensorEntity(coordinator, entry, obis, data))
|
new_entities.append(XT211SensorEntity(coordinator, entry, obis, data))
|
||||||
if new_entities:
|
if new_entities:
|
||||||
|
|
@ -156,14 +95,11 @@ class XT211SensorEntity(CoordinatorEntity[XT211Coordinator], SensorEntity):
|
||||||
self._entry = entry
|
self._entry = entry
|
||||||
self._obis = obis
|
self._obis = obis
|
||||||
self._wh_to_kwh = sensor_type == "energy"
|
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 = None if self._text else sensor_meta["device_class"]
|
self._attr_device_class = sensor_meta["device_class"]
|
||||||
self._attr_state_class = None if self._text else sensor_meta["state_class"]
|
self._attr_state_class = sensor_meta["state_class"]
|
||||||
self._attr_native_unit_of_measurement = None if self._text else (sensor_meta["unit"] or meta.get("unit"))
|
self._attr_native_unit_of_measurement = sensor_meta["unit"] or meta.get("unit")
|
||||||
if self._text:
|
|
||||||
self._attr_entity_category = EntityCategory.DIAGNOSTIC
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device_info(self) -> DeviceInfo:
|
def device_info(self) -> DeviceInfo:
|
||||||
|
|
@ -175,8 +111,6 @@ class XT211SensorEntity(CoordinatorEntity[XT211Coordinator], SensorEntity):
|
||||||
if obj is None:
|
if obj is None:
|
||||||
return None
|
return None
|
||||||
value = obj.get("value")
|
value = obj.get("value")
|
||||||
if self._text:
|
|
||||||
return None if value is None else str(value)
|
|
||||||
try:
|
try:
|
||||||
number = float(value)
|
number = float(value)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
|
|
@ -188,3 +122,59 @@ class XT211SensorEntity(CoordinatorEntity[XT211Coordinator], SensorEntity):
|
||||||
@property
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
return self.coordinator.data is not None
|
return self.coordinator.data is not None
|
||||||
|
|
||||||
|
|
||||||
|
class XT211AliasedTextSensorEntity(CoordinatorEntity[XT211Coordinator], SensorEntity):
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
|
def __init__(self, coordinator: XT211Coordinator, entry: ConfigEntry, key: str, spec: dict) -> None:
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self._entry = entry
|
||||||
|
self._obises = tuple(spec["obises"])
|
||||||
|
self._attr_unique_id = f"{entry.entry_id}_{key}"
|
||||||
|
self._attr_name = spec["name"]
|
||||||
|
self._attr_entity_category = spec.get("entity_category")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self) -> DeviceInfo:
|
||||||
|
return _device_info(self._entry)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> str | None:
|
||||||
|
data = self.coordinator.data or {}
|
||||||
|
for obis in self._obises:
|
||||||
|
obj = data.get(obis)
|
||||||
|
if obj and obj.get("value") is not None:
|
||||||
|
return str(obj.get("value"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
return self.coordinator.data is not None
|
||||||
|
|
||||||
|
|
||||||
|
class XT211DynamicTextSensorEntity(CoordinatorEntity[XT211Coordinator], SensorEntity):
|
||||||
|
_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._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 native_value(self) -> str | None:
|
||||||
|
obj = (self.coordinator.data or {}).get(self._obis)
|
||||||
|
if obj is None or obj.get("value") is None:
|
||||||
|
return None
|
||||||
|
return str(obj.get("value"))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
return self.coordinator.data is not None
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
|
|
@ -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.
|
||||||
|
|
@ -0,0 +1,964 @@
|
||||||
|
%PDF-1.7
|
||||||
|
%ÐÕÑ™
|
||||||
|
2 0 obj
|
||||||
|
<<
|
||||||
|
/Lang (cs)
|
||||||
|
/MarkInfo <<
|
||||||
|
/Marked true
|
||||||
|
>>
|
||||||
|
/Metadata 4 0 R
|
||||||
|
/OutputIntents 5 0 R
|
||||||
|
/Pages 6 0 R
|
||||||
|
/StructTreeRoot 7 0 R
|
||||||
|
/Type /Catalog
|
||||||
|
/ViewerPreferences 8 0 R
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
4 0 obj
|
||||||
|
<<
|
||||||
|
/Length 7713
|
||||||
|
/Subtype /XML
|
||||||
|
/Type /Metadata
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
||||||
|
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 6.0.0">
|
||||||
|
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||||
|
<rdf:Description rdf:about=""
|
||||||
|
xmlns:pdf="http://ns.adobe.com/pdf/1.3/"
|
||||||
|
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
|
||||||
|
xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/"
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/"
|
||||||
|
xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#"
|
||||||
|
xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#"
|
||||||
|
xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/">
|
||||||
|
<pdf:Producer>Microsoft® Word pro Microsoft 365</pdf:Producer>
|
||||||
|
<xmp:CreatorTool>Microsoft® Word pro Microsoft 365</xmp:CreatorTool>
|
||||||
|
<xmp:CreateDate>2025-01-02T13:30:44+01:00</xmp:CreateDate>
|
||||||
|
<xmp:ModifyDate>2025-01-22T10:49:40+01:00</xmp:ModifyDate>
|
||||||
|
<xmpMM:DocumentID>uuid:8D5F8487-0C4D-4FA5-940A-F1F274327ABB</xmpMM:DocumentID>
|
||||||
|
<xmpMM:InstanceID>uuid:8D5F8487-0C4D-4FA5-940A-F1F274327ABB</xmpMM:InstanceID>
|
||||||
|
<dc:creator>
|
||||||
|
<rdf:Seq>
|
||||||
|
<rdf:li>Zlámal JiÅ™Ã</rdf:li>
|
||||||
|
</rdf:Seq>
|
||||||
|
</dc:creator>
|
||||||
|
<pdfaExtension:schemas>
|
||||||
|
<rdf:Bag>
|
||||||
|
<rdf:li rdf:parseType="Resource">
|
||||||
|
<pdfaSchema:namespaceURI>http://ns.adobe.com/pdf/1.3/</pdfaSchema:namespaceURI>
|
||||||
|
<pdfaSchema:prefix>pdf</pdfaSchema:prefix>
|
||||||
|
<pdfaSchema:schema>Adobe PDF Schema</pdfaSchema:schema>
|
||||||
|
<pdfaSchema:property>
|
||||||
|
<rdf:Seq>
|
||||||
|
<rdf:li rdf:parseType="Resource">
|
||||||
|
<pdfaProperty:name>Producer</pdfaProperty:name>
|
||||||
|
<pdfaProperty:category>external</pdfaProperty:category>
|
||||||
|
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
|
||||||
|
<pdfaProperty:description>The name of the tool that created the PDF document</pdfaProperty:description>
|
||||||
|
</rdf:li>
|
||||||
|
</rdf:Seq>
|
||||||
|
</pdfaSchema:property>
|
||||||
|
<pdfaSchema:valueType>
|
||||||
|
<rdf:Seq/>
|
||||||
|
</pdfaSchema:valueType>
|
||||||
|
</rdf:li>
|
||||||
|
<rdf:li rdf:parseType="Resource">
|
||||||
|
<pdfaSchema:namespaceURI>http://ns.adobe.com/xap/1.0/</pdfaSchema:namespaceURI>
|
||||||
|
<pdfaSchema:prefix>xmp</pdfaSchema:prefix>
|
||||||
|
<pdfaSchema:schema>XMP Basic schema</pdfaSchema:schema>
|
||||||
|
<pdfaSchema:property>
|
||||||
|
<rdf:Seq>
|
||||||
|
<rdf:li rdf:parseType="Resource">
|
||||||
|
<pdfaProperty:name>CreatorTool</pdfaProperty:name>
|
||||||
|
<pdfaProperty:category>external</pdfaProperty:category>
|
||||||
|
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
|
||||||
|
<pdfaProperty:description>The name of the first known tool used to create the resource</pdfaProperty:description>
|
||||||
|
</rdf:li>
|
||||||
|
<rdf:li rdf:parseType="Resource">
|
||||||
|
<pdfaProperty:name>CreateDate</pdfaProperty:name>
|
||||||
|
<pdfaProperty:category>external</pdfaProperty:category>
|
||||||
|
<pdfaProperty:valueType>Date</pdfaProperty:valueType>
|
||||||
|
<pdfaProperty:description>The date and time the resource was originally created</pdfaProperty:description>
|
||||||
|
</rdf:li>
|
||||||
|
<rdf:li rdf:parseType="Resource">
|
||||||
|
<pdfaProperty:name>ModifyDate</pdfaProperty:name>
|
||||||
|
<pdfaProperty:category>external</pdfaProperty:category>
|
||||||
|
<pdfaProperty:valueType>Date</pdfaProperty:valueType>
|
||||||
|
<pdfaProperty:description>The date and time the resource was last modified</pdfaProperty:description>
|
||||||
|
</rdf:li>
|
||||||
|
</rdf:Seq>
|
||||||
|
</pdfaSchema:property>
|
||||||
|
<pdfaSchema:valueType>
|
||||||
|
<rdf:Seq/>
|
||||||
|
</pdfaSchema:valueType>
|
||||||
|
</rdf:li>
|
||||||
|
<rdf:li rdf:parseType="Resource">
|
||||||
|
<pdfaSchema:namespaceURI>http://ns.adobe.com/xap/1.0/mm/</pdfaSchema:namespaceURI>
|
||||||
|
<pdfaSchema:prefix>xmpMM</pdfaSchema:prefix>
|
||||||
|
<pdfaSchema:schema>XMP Media Management schema</pdfaSchema:schema>
|
||||||
|
<pdfaSchema:property>
|
||||||
|
<rdf:Seq>
|
||||||
|
<rdf:li rdf:parseType="Resource">
|
||||||
|
<pdfaProperty:name>DocumentID</pdfaProperty:name>
|
||||||
|
<pdfaProperty:category>external</pdfaProperty:category>
|
||||||
|
<pdfaProperty:valueType>URI</pdfaProperty:valueType>
|
||||||
|
<pdfaProperty:description>The common identifier for all versions and renditions of a document</pdfaProperty:description>
|
||||||
|
</rdf:li>
|
||||||
|
<rdf:li rdf:parseType="Resource">
|
||||||
|
<pdfaProperty:name>InstanceID</pdfaProperty:name>
|
||||||
|
<pdfaProperty:category>external</pdfaProperty:category>
|
||||||
|
<pdfaProperty:valueType>URI</pdfaProperty:valueType>
|
||||||
|
<pdfaProperty:description>An identifier for a specific incarnation of a document, updated each time a file is saved</pdfaProperty:description>
|
||||||
|
</rdf:li>
|
||||||
|
</rdf:Seq>
|
||||||
|
</pdfaSchema:property>
|
||||||
|
<pdfaSchema:valueType>
|
||||||
|
<rdf:Seq/>
|
||||||
|
</pdfaSchema:valueType>
|
||||||
|
</rdf:li>
|
||||||
|
<rdf:li rdf:parseType="Resource">
|
||||||
|
<pdfaSchema:namespaceURI>http://purl.org/dc/elements/1.1/</pdfaSchema:namespaceURI>
|
||||||
|
<pdfaSchema:prefix>dc</pdfaSchema:prefix>
|
||||||
|
<pdfaSchema:schema>Dublin Core schema</pdfaSchema:schema>
|
||||||
|
<pdfaSchema:property>
|
||||||
|
<rdf:Seq>
|
||||||
|
<rdf:li rdf:parseType="Resource">
|
||||||
|
<pdfaProperty:name>creator</pdfaProperty:name>
|
||||||
|
<pdfaProperty:category>external</pdfaProperty:category>
|
||||||
|
<pdfaProperty:valueType>seq ProperName</pdfaProperty:valueType>
|
||||||
|
<pdfaProperty:description>The authors of the resource</pdfaProperty:description>
|
||||||
|
</rdf:li>
|
||||||
|
</rdf:Seq>
|
||||||
|
</pdfaSchema:property>
|
||||||
|
<pdfaSchema:valueType>
|
||||||
|
<rdf:Seq/>
|
||||||
|
</pdfaSchema:valueType>
|
||||||
|
</rdf:li>
|
||||||
|
</rdf:Bag>
|
||||||
|
</pdfaExtension:schemas>
|
||||||
|
<pdfaid:part>2</pdfaid:part>
|
||||||
|
<pdfaid:conformance>B</pdfaid:conformance>
|
||||||
|
</rdf:Description>
|
||||||
|
</rdf:RDF>
|
||||||
|
</x:xmpmeta>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<?xpacket end="w"?>
|
||||||
|
endstream
|
||||||
|
endobj
|
||||||
|
15 0 obj
|
||||||
|
<<
|
||||||
|
/Filter /FlateDecode
|
||||||
|
/Length 2599
|
||||||
|
/N 3
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
xœ<EFBFBD>SwXSw>÷ÞìÁJˆ€Œ°—l<E28094> | ||||||