diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5c9bf12 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: [main, redesign] + pull_request: + workflow_dispatch: + +jobs: + hassfest: + name: hassfest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: home-assistant/actions/hassfest@master + + hacs: + name: HACS validation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: hacs/action@main + with: + category: integration + # brands not yet submitted to home-assistant/brands (M3) + ignore: brands + + lint-test: + name: Lint & test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install tools + run: python -m pip install --upgrade ruff pytest + - name: Ruff + run: ruff check custom_components tests + - name: Pytest + run: pytest tests -q diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42ded18 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# macOS +.DS_Store + +# Claude Code session/local state +.claude/ + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..cf849ef --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,369 @@ +# Dimplex WPM — Home Assistant (HACS) — Projekt integracji + +> Status: **DRAFT do wspólnej iteracji**. Dokument roboczy redesignu repo +> `nb3rt/DimplexModbusHACS` (domena `dimplex_wpm`). Scala trzy źródła: +> lokalne YAML (`existing integration/`), `nb3rt/DimplexModbusHA` (mapa +> rejestrów), `nb3rt/DimplexModbusHACS` (szkielet komponentu). + +--- + +## 1. Cel i zakres + +Pełnoprawna integracja custom (HACS, docelowo zgłoszenie do HA core) dla pomp +ciepła Dimplex sterowanych kontrolerem **WPM/NWPM przez Modbus TCP**: + +- natywne encje (sensor / binary_sensor / number / select / climate), +- **natywny silnik estymacji** energii i ciepła (moc el. z Hz, COP, ciepło, + rozdział dom/instalacja, przepływ) — główny wyróżnik, +- natywne kWh gotowe pod Energy Dashboard, +- zapisy/sterowanie za bramką trybu zaawansowanego, +- gotowe dashboardy (i opcjonalnie karta Lovelace), +- testy, CI, brands, dokumentacja, quality scale. + +### Decyzje zatwierdzone (z rozmowy) +- Estymacja: **natywnie, pełny zakres**. +- Energia: **oba** (natywne kWh + udokumentowane helpery HA). +- Sterowanie: **SG Ready + nastawy (number) + climate**, wszystko za bramką + trybu zaawansowanego (read-only domyślnie). +- Modele: **LAK9 first**, architektura pod profile innych pomp; + **mapa rejestrów WPM identyczna dla wszystkich modeli**. + +--- + +## 2. Pozycjonowanie i licencja (wątek strategiczny — do decyzji osobno) + +- Istnieje `ay-kay/homeassistant-dimplex` — **chmura (Home Cloud API), domena + `dimplex`**. My robimy **lokalne Modbus, domena `dimplex_wpm`** → brak kolizji, + podejścia komplementarne. +- W `home-assistant/brands` brak `dimplex`/`dimplex_wpm` → do HACS-default i do + core trzeba osobno wysłać brand assets (PR do `home-assistant/brands`). + +### Licencjonowanie / open-core / add-on +Cel użytkownika: rozważyć licencjonowanie *części* rozwiązania. Kluczowe +ograniczenia, które determinują architekturę: + +1. **HACS-default oraz HA core wymagają kodu OSS.** Płatna/zamknięta warstwa + **nie może** żyć wewnątrz integracji zgłoszonej do HACS-default/core. Musi być + *osobną dystrybucją*. +2. **Pułapka GPLv3:** repo `nb3rt/DimplexModbusHA` jest na GPLv3 i ma **drugiego + kontrybutora** (dar3khudy — czujniki gazu/wilgotności). Reużycie tego kodu + wymusza GPLv3 na pochodnych i **nie da się go relicencjonować** bez zgody + wszystkich autorów. Lokalne YAML to Twój kod (możesz licencjonować dowolnie). + → Aby zachować swobodę licencyjną dla warstwy premium: **clean-room mapa + rejestrów z oficjalnej dokumentacji Dimplex** (fakty/adresy rejestrów nie są + chronione; chroniony jest konkretny kod), bez kopiowania kodu z GPLv3 repo. + +### Rekomendowany model: **open-core** +- **Core (OSS, np. Apache-2.0/MIT):** I/O Modbus, encje surowe (temperatury, + status/lock/fault, runtime'y, energia z rejestrów, coile), SG Ready, profile. + → to idzie do HACS i ewentualnie do core. +- **Premium „Analytics" (osobna dystrybucja, licencja wg uznania):** silnik + estymacji COP/ciepło/przepływ + zaawansowana energia + dashboardy pro. + Opcje techniczne: (a) osobny custom component z kluczem licencyjnym, + (b) HA add-on (kontener Docker — tylko HAOS/Supervised), (c) usługa cloud. + +> **Implikacja dla architektury (robimy ją tak od początku):** silnik estymacji +> jest **czysto odseparowanym modułem** (`estimation.py` + profile), zależnym +> tylko od danych coordinatora, bez sprzężeń z I/O. Dzięki temu wariant premium +> pozostaje możliwy bez przepisywania. **Sama decyzja licencyjna nie blokuje +> startu** — domyślnie budujemy całość jako OSS i ewentualnie wydzielamy później. + +--- + +## 3. Architektura wysokopoziomowa + +``` +config_flow / options_flow + │ (host, port, unit, scan, timeout, firmware H/J/L/M, + │ profil urządzenia, flagi modułów, tryb zaawansowany/zapis) + ▼ +DataUpdateCoordinator ── modbus_client (AsyncModbusTcpClient, batch, reconnect) + │ raw{addr:val} + ├── decode.py → temperatury/skale/znaki, mapy tekstowe (wersjonowane) + ├── estimation.py → moc el., COP, ciepło, α, przepływ (PROFIL) + ├── energy.py → Riemann (RestoreSensor, persist) → kWh + ▼ + coordinator.data = { raw, decoded, derived, energy, meta } + ▼ +platformy: sensor / binary_sensor / number / select / climate + ▼ +device registry: 1 hub + pod-urządzenia (HC1, HC2/3, DHW, Pool, Vent, Solar, + Source, Smart Grid, Analytics) +``` + +Zasady: +- **Jeden coordinator** odpytuje wszystko w paczkach (batch ranges), jeden + interwał bazowy (interwały „wolne" realizujemy decymacją — nie co cykl). +- **EntityDescription-driven**: encje deklaratywnie z dataclass (rozszerzony o + `register`, `register_type`, `scale`, `signed`, `value_fn`, `module`, + `entity_category`, `feature_flag`). +- **Profil urządzenia** dostarcza kalibrację + listę aktywnych modułów; mapa + rejestrów wspólna. + +--- + +## 4. Model profili urządzeń + +```python +@dataclass(frozen=True) +class DeviceProfile: + key: str # "lak9" + name: str # "Dimplex LAK 9S-TU" + power_lut: tuple[tuple[float, float], ...] # (Hz, W) — interpolacja + cop_table: dict[int, dict[int, float]] # {A:{W:COP}} EN14511 + heater_default_w: int # moc grzałki 2. źródła + k_dhw: float; k_defrost: float; k_defrost_loss: float + modules: frozenset[str] # {"hc1","hc2_3","dhw","pool","solar","vent","sg"} +``` + +- `profiles/lak9.py` — pełna kalibracja z lokalnego YAML (LUT 16 pkt, COP 4×3). +- `profiles/generic_wpm.py` — bez kalibracji estymacji (tylko odczyty), wszystkie + moduły opcjonalne. +- Wybór profilu w config_flow; **user-submitted** = PR z nowym plikiem profilu. +- Wartości `k_*`/`heater_w` z profilu = defaulty encji `number` (kalibrowalne). + +### 4.1 Capabilities (per instalacja, nie per model!) + +Profil = kalibracja modelu. **Capabilities = co ma KONKRETNA instalacja** — +osobna konfiguracja (config entry), bo dwie te same pompy mogą mieć różny sprzęt +pomiarowy. To kluczowe: **nie każda instalacja ma licznik prądu ani ciepłomierz**, +stąd estymacja jest pełnoprawnym źródłem, nie tylko awaryjnym. + +```python +@dataclass +class Capabilities: + has_electric_meter: bool # rejestr 5170 wiarygodny (M3.5+ + sprzęt) + has_heat_meter: bool # rejestry 5168 / 5096–5129 (Wärmemengenzähler) + has_flow_sensor: bool # zewn. encja przepływu (np. sensor.aquaro_*) + has_inverter_freq: bool # rejestr 114 (RE) dostępny → warunek LUT prądu + flow_sensor_entity: str|None +``` + +Ustalane: **deklaracja użytkownika** w config/options (pewne) + **auto-hint** +z próbnego odczytu przy setupie (5170/5168/114). Patrz macierz źródeł §6. + +--- + +## 5. Katalog encji (logika encji) + +Kategorie: **Primary** (domyślnie widoczne), **Diagnostic** +(`EntityCategory.DIAGNOSTIC`), **Config** (`EntityCategory.CONFIG`). +Pod-urządzenia w nawiasach. Encje modułowe tworzone tylko gdy profil/flaga włącza. + +### 5.0 Konwencje i drzewo urządzeń (ZATWIERDZONE) + +- **Drzewo: średnie.** Urządzenie główne **Controller** (`(DOMAIN, entry_id)`, + manufacturer Dimplex, model z profilu, sw_version z rej. 65–67) + pod-urządzenia + przez `via_device`, **tworzone tylko gdy capability/profil je aktywuje**: + `HC1`, `HC2/3`, `DHW`, `Pool`, `Ventilation`, `Solar`, `Source`, + oraz **Analytics** (wielkości pochodne: moc/COP/ciepło/przepływ/energia). +- **`has_entity_name = True`** — nazwy zależne od urządzenia (np. „Heating + circuit 1 · Flow temperature"). `unique_id = {entry_id}_{module}_{key}`. +- **Capabilities (§4.1) bramkują** tworzenie encji measured (np. encje z rej. + 5170/5168/5096–5129 tylko gdy `has_*_meter`); brak sprzętu → encje estimated. +- **Encje energii/mocy/przepływu/COP** mają atrybut `source: measured|estimated`. +- **Kalibracja = encje `number` (kategoria Config), zawsze dostępne** (zapis do + options HA, nie do pompy → NIE za bramką sterowania). **Zapis nastaw do pompy** + (number/select/climate na rejestrach R/W) — **tylko za bramką** `enable_control`. + +### 5.1 sensor (odczyt) +**Controller** +- outdoor_temperature (1), inverter_frequency (114), operating_mode (5015→tekst), + party_mode_hours (5016), holiday_days (5017) +- status_code (103) [diag] + **status_text** (mapa wersjonowana) +- lock_code (104) [diag] + **lock_text** +- fault_code (105) [diag] + **fault_text** +- sensor_error_code (106) [diag] + **sensor_error_text** +- controller_info [diag] (host/port/unit/firmware/profil w atrybutach) +- runtime_* (71–79) [diag, total_increasing, h] + +**HC1**: flow_temperature (5), return_temperature (2), return_setpoint (53), +room_temp_1/2 (11/12), room_humidity_1/2 (13/14) +**HC2/3**: hc2_return (9)†, hc3_return (10)†, hc2_setpoint (54), hc3_setpoint (55) +**DHW**: dhw_temperature (3), dhw_setpoint (58) +**Pool**: pool_setpoint (5051) … +**Source**: source_inlet (6), source_outlet (7), [gaz/skraplacz/parownik — †] +**Ventilation**: outdoor/supply/extract/exhaust air temp (120–123), fan speeds +(125/126), boost time (127), level (5034) +**Solar**: collector_temp (†22?), tank_temp (23) +**Smart Grid / EMS**: sg_ready_code (5167) [diag] + **sg_ready_text**, +sg_ready_state_inputs (z coili), pv_surplus (5182), heating_power (5168, kW), +electrical_power (5170, kW), energia z rejestrów 5096–5129 (kWardy kwartalne) + +**Analytics (silnik estymacji)** — patrz §6: +- compressor_power_est (W/kW), heater_power_est, pump_power_est, total_power_est +- cop_en14511 +- thermal_power_compressor, thermal_power_heater, thermal_power_defrost_loss, + thermal_power_loop, thermal_power_to_house, thermal_power_to_installation +- estimated_flow (m³/h) [+ wygładzony — poprawione EMA] +- deltaT, ddeltaT_dt +- **energy_*_kwh** (compressor/heater/total electric; to_house/installation/ + defrost thermal) — total_increasing, RestoreSensor + +### 5.2 binary_sensor +- fault_active, lock_active (device_class problem) +- SmartGrid input 1/2 (coil 3/4), utility_lockout (5), external_lockout (6) +- wyjścia (coile 41–71): compressor 1/2, primary pump, 2nd heat gen, pompy + M13/M14/M15/M20, mieszacze, DHW pump, pool pump, solar pump, general fault… + [diag] + +### 5.3 number +**(a) Kalibracja estymacji — zawsze dostępne** (kategoria Config, persist w +options, zapis do HA): k_dhw, k_defrost, k_defrost_loss, heater_2nd_power_w, +pump_main/floor_power_w, alpha_base, alpha_sensitivity, alpha_deadband. +**(b) Nastawy pompy — tylko za bramką `enable_control`** (zapis do rejestrów R/W +potwierdzonych w §7–§10): dhw_setpoint (5047), hc1 curve_offset (5036)/fixed_flow +(5037)/curve_end (5038)/hysteresis (47), pool_setpoint (5051), … z walidacją +min/max i dekoderem enum (5036/5086/5089). + +### 5.4 select (tylko gdy tryb zaawansowany) +- sg_ready_mode (5167) — Hardware/Yellow/Green/Red/Deep Green (jest) +- operating_mode (5015) — jeśli potwierdzony R/W + +### 5.5 climate (tylko gdy tryb zaawansowany; etap późniejszy) +- HC1 (current=return/flow, target=setpoint/offset), DHW (current=dhw_temp, + target=dhw_setpoint). Wymaga twardej walidacji zakresów z dokumentacji. + +> † = adresy/typy do potwierdzenia autorytatywną mapą rejestrów (konflikty +> między źródłami — patrz §11). M.in.: solar collector w lokalnym YAML miał +> błędnie `address: 10` (duplikat HC3); typy 8/9/10/98/107/108/109 (input vs +> holding) różnią się między lokalnym a starym repo. + +--- + +## 6. Pomiar i estymacja — macierz wyboru źródła + +Każda wielkość energetyczna ma **rozwiązanie źródła** zależne od capabilities +(§4.1): jeśli jest sprzęt pomiarowy — czytamy rejestr; jeśli nie — estymujemy. +Encja niesie atrybut `source: measured|estimated` i `availability` zależną od +capability. Estymacja to nasz wyróżnik dla instalacji bez liczników. + +| Wielkość | Źródło „measured" | Źródło „estimated" (fallback) | +|---|---|---| +| Moc elektryczna | rejestr **5170** (licznik) | LUT(Hz, status) — wymaga rej. 114 | +| Energia elektryczna | całka 5170 | całka mocy estymowanej | +| Moc cieplna (oddana) | rejestr **5168** (ciepłomierz) | **hydraulicznie** Q=V̇·cp·ΔT (gdy jest czujnik przepływu) **lub** P_el·COP | +| Energia cieplna | rejestry **5096–5129** (ciepłomierz) | całka mocy cieplnej estymowanej | +| Przepływ V̇ | zewn. **czujnik przepływu** | bilans Q/(cp·ΔT) z toru COP | +| COP (sprawność) | Q_measured / P_el | tabela EN14511(A,W) | + +Zależności (ważne dla logiki encji): +- tor **hydrauliczny** ciepła wymaga realnego przepływu + ΔT (rej. 5/2) — gdy jest + czujnik, jest dokładniejszy niż COP i wtedy COP raportujemy jako `Q/P_el`; +- tor **COP** ciepła wymaga mocy el. (5170 lub LUT) × COP(tabela) — działa bez + żadnego dodatkowego sprzętu; z niego back-derive’ujemy przepływ estymowany; +- estymacja mocy el. wymaga rej. 114 (RE); gdy go brak i brak licznika — degradacja + (np. on/off sprężarki × moc nominalna z profilu, oznaczone niską ufnością). + +### 6.1 Silnik estymacji (natywny) — port „sosu" z YAML + +Moduł `estimation.py` — czyste funkcje, wejście = zdekodowane dane + profil + +parametry; wyjście = wartości pochodne. Liczone w coordinatorze co cykl. + +1. **Moc el. sprężarki**: interpolacja LUT(Hz→W) z profilu; status-aware + (0 dla idle/off/lock/flow-monitoring); ×k_defrost (status 10), ×k_dhw (status 4). +2. **Moc grzałki**: heater_w gdy coil 2nd-heat-gen on, inaczej 0. +3. **Total** = sprężarka + grzałka (+ pompy opcjonalnie). +4. **COP EN14511**: interpolacja **2D biliniowa** z tabeli profilu, z clampem + A∈[−7,10], W∈[35,55]. **Poprawka buga nawiasowania `round()`** z YAML. +5. **Ciepło**: Q_comp = P_el·COP (status CO/CWU); Q_heater = moc grzałki 1:1; + strata defrostu = P_el·k_loss (status 10); Q_loop = comp+heater−defrost. +6. **α dom/instalacja**: heurystyka `α = base − sens·dΔT/dt` z deadbandem; + ΔT i dΔT/dt liczone w coordinatorze (przechowujemy poprzedni ΔT + timestamp, + okno ~5 min). Split: to_house = Q_loop·α, to_installation = reszta. +7. **Przepływ**: V̇ = Q_comp/(4180·ΔT)·3.6, tylko CO/CWU, ΔT>0.5 K, + clamp 0–3.8 m³/h. **EMA naprawione** (realne wygładzanie, nie kopia 1:1). + +Parametry kalibracji = encje `number` (Config). Mechanizm: wartości w +`entry.options`; zmiana number → zapis options → lekki recompute (bez pełnego +reloadu). Tabele LUT/COP z profilu (edycja przez profil/YAML; UI dla tabel — +later). + +--- + +## 7. Model bezpieczeństwa zapisu + +- **Domyślnie read-only.** Żadnych number/select/climate. +- Opcja **`enable_control`** (advanced) w options_flow → tworzy encje zapisu. + Opcjonalnie podflagi: `enable_setpoints`, `enable_sg_ready`, `enable_climate`. +- Ostrzeżenia w `strings.json` + dokumentacja (ryzyko zapisu do sterownika). +- Zapis tylko do rejestrów potwierdzonych jako R/W w dokumentacji; walidacja + zakresów min/max z profilu/dokumentacji przed `write_register`. +- (Nasza własna opcja, nie systemowy „advanced mode" usera HA — żeby działała + niezależnie od konta.) + +--- + +## 8. Energia i Energy Dashboard (oba) + +- **Natywne** sensory mocy (device_class POWER) + **natywne kWh** + (total_increasing, device_class ENERGY, `RestoreSensor` z persist last value + + last timestamp, całkowanie trapezowe w `energy.py`) → działają wprost w Energy + Dashboard (który sam robi rozbicie dzienne/mies.). +- **Udokumentowane** (opcjonalnie) helpery HA: `integration` + `utility_meter` + dla użytkowników chcących własnych cykli/raportów (nie reimplementujemy + kalendarzowych cykli w Pythonie). +- **Źródło per wielkość wg macierzy §6**: gdy jest ciepłomierz → energia cieplna + z rejestrów **5096–5129** liczona jako **suma grup cyfr** (`reg(9-12)·1e8 + + reg(5-8)·1e4 + reg(1-4)`, NIE kwartały) per kategoria (Heizen/WW/Schwimmbad/ + Umwelt); gdy brak → całka mocy cieplnej estymowanej. Analogicznie prąd: + 5170 albo całka estymaty. Encje energii zawsze `total_increasing`/`energy`, + niezależnie od źródła, więc Energy Dashboard działa tak samo. + +--- + +## 9. Dashboardy i karta + +- **Etap 1:** przeniesienie 4 dashboardów z `existing integration/` na nowe + entity_id (z poprawą błędów), jako importowalne YAML w `dashboards/`: + Overview/Status, Energy & Heat, History, Calibration/Parameters. +- **Etap 2 (opcjonalny):** dedykowana karta Lovelace (osobne repo HACS + „frontend") — np. karta przeglądowa pompy. Integracja backendu nie rejestruje + kart bezpośrednio. + +--- + +## 10. Jakość: testy, CI, brands, quality scale + +- **Testy** (pytest + pytest-homeassistant-custom-component): coordinator z + mockiem modbus, dekodery, **estymacja** (LUT/COP/flow — łatwe do testów + jednostkowych), config_flow. +- **CI** (`.github/workflows`): `hacs/action`, `hassfest`, `ruff`, `mypy`, + pytest + coverage. +- **brands**: PR do `home-assistant/brands` (icon 256×256 + logo). +- **Quality scale**: cel **Silver** na start (config_flow, obsługa awarii, + unavailable, dokumentacja), potem **Gold** (tłumaczenia, discovery jeśli + realne, pokrycie testami). +- **Ścieżka do HA core:** realnie najpierw dojrzałość w HACS. Uwaga: core bywa + ostrożny wobec heurystyk (COP/flow estimation) — dlatego separacja core + (odczyty) / analytics (estymacja) pomaga też tutaj. + +--- + +## 11. Otwarte kwestie / potrzebne wejścia od Ciebie + +1. **Autorytatywna mapa rejestrów** (oficjalny spec Dimplex NWPM Modbus TCP albo + odczyt z realnego LAK9) — do rozstrzygnięcia konfliktów: + - solar collector (lokalnie błędny `address: 10`), + - typy 8/9/10/98/107/108/109 (input vs holding) i ich znaczenie, + - które rejestry nastaw są faktycznie **R/W** (do number/select/climate), + - potwierdzenie skali/znaku dla mocy 5168/5170 i energii 5096–5129. +2. **Decyzja licencyjna** (open-core teraz vs wszystko OSS, premium później) — + wpływa tylko na to, czy od razu trzymamy clean-room separację (proponuję: tak). +3. **Zgoda kontrybutora GPLv3** (jeśli chcesz reużyć kod ze starego repo) — + albo idziemy clean-room. + +--- + +## 12. Roadmapa (proponowane etapy) + +- **M0 — Fundament**: clean-room mapa rejestrów (`registers.py`), profile + (`lak9`, `generic_wpm`), rozbudowa coordinatora o batch wszystkich zakresów + + `decode.py`. Encje sensor/binary_sensor (pełny odczyt), drzewo urządzeń. +- **M1 — Estymacja**: `estimation.py` (moc/COP/ciepło/α/flow) + `energy.py` + (kWh) + encje Analytics + number kalibracyjne. Testy jednostkowe estymacji. +- **M2 — Sterowanie**: bramka `enable_control`, select/number nastaw, walidacja. +- **M3 — UX & jakość**: dashboardy YAML, tłumaczenia (en/pl/de), strings, + CI (hacs/hassfest/ruff/mypy/pytest), brands, README/dokumentacja. +- **M4 — Premium/karta (opcjonalnie)**: wydzielenie analytics / karta Lovelace. +- **M5 — Zgłoszenia**: HACS-default, brands PR; przygotowanie pod core. diff --git a/README.md b/README.md index 78d530a..0b5ccb0 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,108 @@ -# Dimplex WPM – Home Assistant Modbus TCP integration - -Custom HACS integration for connecting Dimplex WPM / NWPM heat pump controllers over Modbus TCP. The integration batches Modbus reads through a dedicated async pymodbus client, exposes sensors and binary sensors via a `DataUpdateCoordinator`, builds a device tree (Controller → HC1 → DHW → Smart Grid), and allows writing SG Ready mode as a select entity. - -## Dimplex Modbus TCP documentation - -Reference specification for Dimplex heat pumps: https://dimplex.atlassian.net/wiki/spaces/DW/pages/3303571457/Modbus+TCP+Anbindung - -## Features (v0.1.0) - -- Config Flow and Options Flow (UI-first, stored in Config Entries). -- Periodic batch polling of key registers (temperatures, status/lock/fault codes, SG Ready). -- Human-friendly text sensors for status, lock, fault, and SG Ready. -- Diagnostic binary sensors for `fault_active` and `lock_active`. -- Controller info diagnostic sensor with host/port/unit, last update, and capabilities. -- SG Ready mode writable as a select (`Hardware`, `Yellow`, `Green`, `Red`, `Deep Green`) gated by the Options Flow (disabled by default). -- Optional toggles for write entities and future EMS/BMS features. - -### Entities - -| Type | Entity | Register | Notes | -| --- | --- | --- | --- | -| sensor | controller_info | — | Attributes: host/port/unit_id, last_update, failure counter, capabilities | -| sensor | outdoor_temperature | 1 (int16, 0.1°C) | Temperature °C | -| sensor | return_temperature | 2 (int16, 0.1°C) | Temperature °C | -| sensor | return_setpoint_temperature | 53 (int16, 0.1°C) | Temperature °C | -| sensor | flow_temperature | 5 (int16, 0.1°C) | Temperature °C | -| sensor | dhw_temperature | 3 (int16, 0.1°C) | Temperature °C | -| sensor | status_code / status | 103 | Diagnostic + mapped text | -| sensor | lock_code / lock | 104 | Diagnostic + mapped text | -| sensor | fault_code / fault | 105 | Diagnostic + mapped text | -| sensor | sensor_error_code / sensor_error | 106 | Diagnostic + mapped text | -| sensor | sg_ready_code / sg_ready_state | 5167 | Diagnostic code + text | -| binary_sensor | fault_active | derived | `True` when fault code ≠ 0 | -| binary_sensor | lock_active | derived | `True` when lock code ≠ 0 | -| select | sg_ready_mode | 5167 | Writable: Hardware / Yellow / Green / Red / Deep Green | +# Dimplex WPM — Home Assistant (Modbus TCP) + +Custom HACS integration for Dimplex **WPM / NWPM** heat-pump controllers over +Modbus TCP. It is driven by a single canonical register table, groups entities +under a device tree, and — crucially — ships a **native estimation engine** so +installations **without** an electricity meter or heat meter still get power, +COP, heat-output, flow and energy figures. + +> Reference spec: Dimplex NWPM Modbus TCP — +> https://dimplex.atlassian.net/wiki/spaces/DW/pages/3303571457/Modbus+TCP+Anbindung + +## Highlights + +- **Register-table driven** (`registers.py`): one source of truth → ~100 read + entities. Status/lock/fault/sensor-error register **addresses are firmware- + version-aware** (H / J / L / M). +- **Device profiles** (`profiles.py`): LAK9 calibration (Hz→W LUT, EN14511 COP + table) + a generic profile. Community models = one new profile file. +- **Capabilities** (per install): electric meter, heat meter, external flow + sensor, inverter frequency. Declared in the setup wizard with an auto-hint + probe of the device. +- **Measurement source matrix** — each power/heat/flow/COP/energy value is + `measured` (read from a meter register) when the hardware exists, otherwise + `estimated` by the engine. Entities carry a `source` attribute. +- **Native energy** (kWh, `total_increasing`, restored across restarts) ready + for the Energy Dashboard; measured heat-meter counters use the digit-group + combination from the spec. +- **Live calibration** of the estimation engine via `number` entities (write to + HA, not the pump). +- **Control behind a gate** (`enable_control`, off by default): setpoints + (`number`) and modes (`select`, incl. SG Ready) write to the pump, range- + validated. +- **Importable dashboards** (`dashboards/`), built-in cards only. + +## Device tree + +`Dimplex WPM` (controller) → `Heating circuit 1`, `Domestic hot water`, +`Heat source`, `Analytics`, and — when present — `Heating circuits 2/3`, +`Swimming pool`, `Ventilation`, `Solar`, `Passive cooling`. ## Installation (HACS custom repository) -1. In HACS → Integrations → ⋮ → **Custom repositories**, add this repo URL and select **Integration**. -2. Install “Dimplex WPM”. -3. Restart Home Assistant. -4. Go to Settings → Devices & Services → **Add Integration** → search for “Dimplex WPM”. +1. HACS → Integrations → ⋮ → **Custom repositories** → add this repo URL, type + **Integration**. +2. Install **Dimplex WPM**, restart Home Assistant. +3. Settings → Devices & Services → **Add Integration** → **Dimplex WPM**. ## Configuration -Config Flow fields: - -- **Host** (IP/DNS) -- **Port** (default `502`) -- **Unit ID** (default `1`) -- **Scan interval** (seconds, default `30`) -- **Timeout** (seconds, default `5`) -- **Software version** (`H`, `J`, `L`, `M`) -- **Register bank strategy**: `auto` (try input then holding), `holding`, or `input` - -Options Flow: - -- **Scan interval** override. -- **Enable write entities** (gate for SG Ready select, default **off**). -- **Enable EMS entities**, **BMS outdoor temp**, **external lock** (placeholders for upcoming releases). - -## How it works - -- A dedicated async `pymodbus` TCP client manages connection/reconnect and register reads/writes. -- `DataUpdateCoordinator` batches reads into contiguous ranges for efficiency. -- Entities are thin wrappers reading from `coordinator.data` (`raw` and `derived` dicts). -- SG Ready writes call `write_register` on register `5167`, mapping friendly strings to numeric codes. -- Register addresses match the Dimplex documentation (1-based); the integration applies the Modbus client offset automatically. - -## Development roadmap - -- v0.1.0 (this repo): MVP read + SG Ready write. -- v0.2.0: EMS/power registers, external lock, BMS outdoor temperature number entity. -- Additional status/lock/fault map coverage based on field feedback. - -## Dashboard example - -A sample Lovelace board is available in `dimplex_dashboard.yaml` demonstrating status and temperature graphs. Import it into a manual dashboard to start visualizing the entities. +**Step 1 (connection):** Host, Port (502), Unit ID (1), heat-pump **profile** +(LAK9 / generic), **software version** (H/J/L/M, default M), scan interval, +timeout. + +**Step 2 (modules & metering):** which optional modules exist (HC2/3, pool, +ventilation, solar, passive cooling) and which meters are present (electric / +heat / flow sensor / inverter frequency). Suggestions are pre-filled from a +device probe; without a meter the value is estimated. + +**Options** (re-configurable): scan interval, modules, capabilities, flow-sensor +entity, include reverse-engineered registers, and **Enable control / write +entities** (the write gate). + +## Removal + +Settings → Devices & Services → Dimplex WPM → ⋮ → **Delete**. All entities and +devices are removed with the config entry. To uninstall the code, remove the +integration from HACS (or delete `custom_components/dimplex_wpm/`) and restart. + +## Dashboards + +Import `dashboards/dimplex_wpm.yaml` (Settings → Dashboards → New dashboard → +Edit → raw configuration editor → paste). Five views: Overview, Heat & Energy, +History, Control (only useful with the write gate on), Diagnostics/Calibration. + +Entity ids follow the default-naming scheme documented in +[`spec/ENTITY_IDS.md`](spec/ENTITY_IDS.md) — if you renamed entities, adjust the +ids. The dashboards use only built-in cards; `apexcharts-card` is an optional +upgrade for the Energy view (see `spec/DASHBOARD_DESIGN.md`). + +For the Energy Dashboard, add **one** electrical-energy entity +(`sensor.analytics_electrical_energy_est`, or the measured equivalent with an +electric meter). The thermal (heat-delivered) sensors are not electricity — do +not add them as grid consumption. + +## Notes & limitations + +- **Setpoint scaling** for some writable registers is assumed whole-°C (matches + the working YAML) and should be **verified on a real device** before relying + on writes. The control gate is off by default and writes are range-validated. +- **Climate** thermostats for HC1 and DHW are available behind the control gate + (current temperature from a read sensor, target = the writable setpoint). +- Estimation requires the LAK9 profile (calibration) + the inverter-frequency + register; the generic profile yields read-only entities. + +## Status / design + +This is the redesign branch. Architecture and rationale live in +[`DESIGN.md`](DESIGN.md); the verified register map in +[`spec/REGISTERS.md`](spec/REGISTERS.md). CI runs hassfest, HACS validation, +ruff and pytest. + +**Quality scale** (self-assessment in +[`custom_components/dimplex_wpm/quality_scale.yaml`](custom_components/dimplex_wpm/quality_scale.yaml)): +most of Bronze and Silver is met (config flow, unload, error handling, +parallel-updates, diagnostics, entity translations, async client). Open items +before formally claiming a tier: HA-runtime test coverage (config flow + +coordinator), `entry.runtime_data` migration, and Gold polish (icon +translations, reconfigure flow, exception translations). diff --git a/custom_components/dimplex_wpm/__init__.py b/custom_components/dimplex_wpm/__init__.py index 066e747..2ec4faa 100644 --- a/custom_components/dimplex_wpm/__init__.py +++ b/custom_components/dimplex_wpm/__init__.py @@ -1,4 +1,4 @@ -"""Init file for Dimplex WPM integration.""" +"""Dimplex WPM integration.""" from __future__ import annotations @@ -9,57 +9,86 @@ from homeassistant.core import HomeAssistant from .const import ( - CONF_ENABLE_BMS_TEMP, - CONF_ENABLE_EMS, - CONF_ENABLE_EXTERNAL_LOCK, + CAPABILITY_CONF_MAP, CONF_ENABLE_WRITE_ENTITIES, - CONF_REGISTER_STRATEGY, + CONF_ENABLED_MODULES, + CONF_FLOW_SENSOR_ENTITY, + CONF_INCLUDE_RE_REGISTERS, + CONF_PROFILE, CONF_SCAN_INTERVAL, CONF_SOFTWARE_VERSION, CONF_TIMEOUT, CONF_UNIT_ID, DEFAULT_ENABLE_WRITE, + DEFAULT_PROFILE, DEFAULT_SCAN_INTERVAL, DEFAULT_SOFTWARE_VERSION, DEFAULT_TIMEOUT, DEFAULT_UNIT_ID, DOMAIN, + resolve_capabilities, + resolve_enabled_modules, ) from .coordinator import DimplexDataUpdateCoordinator from .modbus_client import DimplexModbusClient +from .profiles import get_profile +from .registers import CAP_INVERTER_FREQ, CORE_MODULES LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.SELECT] - - -async def async_setup(hass: HomeAssistant, config: dict) -> bool: - """Set up via configuration.yaml is not supported.""" - return True +PLATFORMS = [ + Platform.SENSOR, + Platform.BINARY_SENSOR, + Platform.NUMBER, + Platform.SELECT, + Platform.CLIMATE, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Dimplex WPM from a config entry.""" hass.data.setdefault(DOMAIN, {}) - host = entry.data[CONF_HOST] - port = entry.data.get(CONF_PORT, 502) - unit_id = entry.data.get(CONF_UNIT_ID, DEFAULT_UNIT_ID) - timeout = entry.data.get(CONF_TIMEOUT, DEFAULT_TIMEOUT) - scan_interval = entry.data.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - register_strategy = entry.data.get(CONF_REGISTER_STRATEGY, "auto") - software_version = entry.data.get(CONF_SOFTWARE_VERSION, DEFAULT_SOFTWARE_VERSION) + source = {**entry.data, **entry.options} + host = source[CONF_HOST] + port = source.get(CONF_PORT, 502) + unit_id = source.get(CONF_UNIT_ID, DEFAULT_UNIT_ID) + timeout = source.get(CONF_TIMEOUT, DEFAULT_TIMEOUT) + scan_interval = source.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + software_version = source.get(CONF_SOFTWARE_VERSION, DEFAULT_SOFTWARE_VERSION) + + profile = get_profile(source.get(CONF_PROFILE, DEFAULT_PROFILE)) + + if CONF_ENABLED_MODULES in source: + enabled_modules = resolve_enabled_modules(source) + else: + enabled_modules = frozenset(CORE_MODULES) | profile.default_modules + + if any(key in source for key in CAPABILITY_CONF_MAP): + capabilities = resolve_capabilities(source) + else: + capabilities = profile.default_capabilities + + include_re = source.get( + CONF_INCLUDE_RE_REGISTERS, CAP_INVERTER_FREQ in capabilities + ) + enable_control = entry.options.get(CONF_ENABLE_WRITE_ENTITIES, DEFAULT_ENABLE_WRITE) client = DimplexModbusClient(host, port, unit_id, timeout) coordinator = DimplexDataUpdateCoordinator( hass, client, scan_interval=scan_interval, - register_strategy=register_strategy, + software_version=software_version, + enabled_modules=enabled_modules, + capabilities=capabilities, + include_re=include_re, + profile=profile, + flow_sensor_entity=source.get(CONF_FLOW_SENSOR_ENTITY) or None, + enable_control=enable_control, host=host, port=port, unit_id=unit_id, - software_version=software_version, ) await coordinator.async_config_entry_first_refresh() @@ -71,18 +100,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "port": port, "unit_id": unit_id, "software_version": software_version, + "profile": profile.key, + "model": profile.name, CONF_ENABLE_WRITE_ENTITIES: entry.options.get( CONF_ENABLE_WRITE_ENTITIES, DEFAULT_ENABLE_WRITE ), - CONF_ENABLE_EMS: entry.options.get(CONF_ENABLE_EMS, False), - CONF_ENABLE_BMS_TEMP: entry.options.get(CONF_ENABLE_BMS_TEMP, False), - CONF_ENABLE_EXTERNAL_LOCK: entry.options.get( - CONF_ENABLE_EXTERNAL_LOCK, False - ), } entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -97,5 +122,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update by reloading entry.""" + """Reload the entry when options change.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/custom_components/dimplex_wpm/binary_sensor.py b/custom_components/dimplex_wpm/binary_sensor.py index 0b572bf..5aeabc7 100644 --- a/custom_components/dimplex_wpm/binary_sensor.py +++ b/custom_components/dimplex_wpm/binary_sensor.py @@ -1,30 +1,36 @@ -"""Binary sensors for Dimplex WPM.""" +"""Binary sensor platform — coils + derived problem flags.""" from __future__ import annotations from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, - BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MODULE_ROOT -from .device import build_device_info +from .entity import DimplexEntityMixin +from .registers import COIL, RegisterSpec -FAULT_DESCRIPTION = BinarySensorEntityDescription( - key="fault_active", - translation_key="fault_active", - device_class=BinarySensorDeviceClass.PROBLEM, -) +PARALLEL_UPDATES = 0 # read-only, coordinator-driven + +DEVICE_CLASS_MAP = { + "running": BinarySensorDeviceClass.RUNNING, + "problem": BinarySensorDeviceClass.PROBLEM, +} +ENTITY_CATEGORY_MAP = { + "diagnostic": EntityCategory.DIAGNOSTIC, + "config": EntityCategory.CONFIG, +} -LOCK_DESCRIPTION = BinarySensorEntityDescription( - key="lock_active", - translation_key="lock_active", - device_class=BinarySensorDeviceClass.PROBLEM, +# Derived (not register-backed) problem flags: (key, name). +DERIVED_PROBLEMS = ( + ("fault_active", "Fault active"), + ("lock_active", "Lock active"), ) @@ -33,42 +39,60 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up binary sensors from config entry.""" + """Create binary sensors from coils and derived flags.""" data = hass.data[DOMAIN][entry.entry_id] coordinator = data["coordinator"] - software_version = data.get("software_version") + host = data.get("host") + version = coordinator.software_version + model = data.get("model") + + entities: list[BinarySensorEntity] = [] + for spec in coordinator.specs: + if spec.obj == COIL: + entities.append( + DimplexCoilBinarySensor(coordinator, entry, spec, host=host, version=version, model=model) + ) + for key, name in DERIVED_PROBLEMS: + entities.append( + DimplexProblemBinarySensor(coordinator, entry, key, name, host=host, version=version, model=model) + ) - entities: list[BinarySensorEntity] = [ - DimplexBinarySensor(coordinator, entry, FAULT_DESCRIPTION, software_version), - DimplexBinarySensor(coordinator, entry, LOCK_DESCRIPTION, software_version), - ] async_add_entities(entities) -class DimplexBinarySensor(CoordinatorEntity, BinarySensorEntity): - """Representation of a Dimplex binary sensor.""" - - entity_description: BinarySensorEntityDescription - - def __init__( - self, - coordinator, - entry: ConfigEntry, - description, - software_version: str | None, - ) -> None: - super().__init__(coordinator) - self.entity_description = description - self._attr_has_entity_name = True - self._attr_translation_key = description.translation_key - self._attr_unique_id = f"{entry.entry_id}_{MODULE_ROOT}_{description.key}" - self._attr_device_info = build_device_info( - entry, MODULE_ROOT, software_version=software_version +class DimplexCoilBinarySensor(DimplexEntityMixin, CoordinatorEntity, BinarySensorEntity): + """A coil-backed binary sensor.""" + + def __init__(self, coordinator, entry, spec: RegisterSpec, *, host, version, model) -> None: + CoordinatorEntity.__init__(self, coordinator) + self._spec = spec + self._apply_common( + entry, key=spec.key, module=spec.module, name=spec.name, + host=host, software_version=version, model=model, + ) + if spec.device_class: + self._attr_device_class = DEVICE_CLASS_MAP.get(spec.device_class) + if spec.entity_category: + self._attr_entity_category = ENTITY_CATEGORY_MAP.get(spec.entity_category) + + @property + def is_on(self) -> bool | None: + return (self.coordinator.data or {}).get("values", {}).get(self._spec.key) + + +class DimplexProblemBinarySensor(DimplexEntityMixin, CoordinatorEntity, BinarySensorEntity): + """A derived problem flag (fault/lock active).""" + + _attr_device_class = BinarySensorDeviceClass.PROBLEM + + def __init__(self, coordinator, entry, key: str, name: str, *, host, version, model) -> None: + CoordinatorEntity.__init__(self, coordinator) + self._key = key + self._apply_common( + entry, key=key, module=MODULE_ROOT, name=name, + host=host, software_version=version, model=model, ) @property def is_on(self) -> bool | None: - data = self.coordinator.data - if not data: - return None - return data["derived"].get(self.entity_description.key) + return (self.coordinator.data or {}).get("values", {}).get(self._key) diff --git a/custom_components/dimplex_wpm/brand/icon.png b/custom_components/dimplex_wpm/brand/icon.png new file mode 100644 index 0000000..205d026 Binary files /dev/null and b/custom_components/dimplex_wpm/brand/icon.png differ diff --git a/custom_components/dimplex_wpm/brand/icon@2x.png b/custom_components/dimplex_wpm/brand/icon@2x.png new file mode 100644 index 0000000..b495977 Binary files /dev/null and b/custom_components/dimplex_wpm/brand/icon@2x.png differ diff --git a/custom_components/dimplex_wpm/brand/logo.png b/custom_components/dimplex_wpm/brand/logo.png new file mode 100644 index 0000000..10ece3f Binary files /dev/null and b/custom_components/dimplex_wpm/brand/logo.png differ diff --git a/custom_components/dimplex_wpm/brand/logo@2x.png b/custom_components/dimplex_wpm/brand/logo@2x.png new file mode 100644 index 0000000..b933ccb Binary files /dev/null and b/custom_components/dimplex_wpm/brand/logo@2x.png differ diff --git a/custom_components/dimplex_wpm/climate.py b/custom_components/dimplex_wpm/climate.py new file mode 100644 index 0000000..141d5d9 --- /dev/null +++ b/custom_components/dimplex_wpm/climate.py @@ -0,0 +1,135 @@ +"""Climate platform — HC1 and DHW thermostats (behind the control gate). + +Thin thermostats over existing pieces: current temperature from a coordinator +read value, target temperature from a writable setpoint register (reused from +the WriteSpec table, so encoding/range/read-back are shared with the number +platform). Only created when control/write entities are enabled. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from pymodbus.exceptions import ModbusException + +from .const import CONF_ENABLE_WRITE_ENTITIES, DEFAULT_ENABLE_WRITE, DOMAIN +from .entity import DimplexEntityMixin +from .registers import M_DHW, M_HC1 + +PARALLEL_UPDATES = 1 # serialize writes to the controller + + +@dataclass(frozen=True) +class ClimateDef: + key: str + name: str + module: str + target_write_key: str # WriteSpec.key providing the setpoint register + current_keys: tuple[str, ...] # coordinator value keys, first available wins + icon: str | None = None + + +CLIMATES: tuple[ClimateDef, ...] = ( + ClimateDef( + "thermostat", "Thermostat", M_HC1, "set_hc1_room_setpoint", + ("room_temperature_1", "return_temperature"), icon="mdi:home-thermometer", + ), + ClimateDef( + "thermostat", "Thermostat", M_DHW, "set_dhw_setpoint", + ("dhw_temperature",), icon="mdi:water-thermometer", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Create thermostats when control is enabled and the setpoint exists.""" + data = hass.data[DOMAIN][entry.entry_id] + coordinator = data["coordinator"] + if not data.get(CONF_ENABLE_WRITE_ENTITIES, DEFAULT_ENABLE_WRITE): + return + + write_specs = {ws.key: ws for ws in coordinator.write_specs} + host = data.get("host") + version = coordinator.software_version + model = data.get("model") + + entities = [] + for c in CLIMATES: + ws = write_specs.get(c.target_write_key) + if ws is None: + continue + entities.append( + DimplexClimate(coordinator, entry, c, ws, host=host, version=version, model=model) + ) + async_add_entities(entities) + + +class DimplexClimate(DimplexEntityMixin, CoordinatorEntity, ClimateEntity): + """A single-mode heating thermostat backed by a setpoint register.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_modes = [HVACMode.HEAT] + _attr_hvac_mode = HVACMode.HEAT + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + + def __init__(self, coordinator, entry, cdef: ClimateDef, ws, *, host, version, model) -> None: + CoordinatorEntity.__init__(self, coordinator) + self._cdef = cdef + self._ws = ws + self._apply_common( + entry, key=cdef.key, module=cdef.module, name=cdef.name, + host=host, software_version=version, model=model, + ) + self._attr_min_temp = ws.min_value + self._attr_max_temp = ws.max_value + self._attr_target_temperature_step = ws.step + if cdef.icon: + self._attr_icon = cdef.icon + + @property + def available(self) -> bool: + # Writable: operable while the coordinator is updating. + return self.coordinator.last_update_success + + @property + def current_temperature(self) -> float | None: + values = (self.coordinator.data or {}).get("values", {}) + for key in self._cdef.current_keys: + val = values.get(key) + if val is not None: + return val + return None + + @property + def target_temperature(self) -> float | None: + return (self.coordinator.data or {}).get("values", {}).get(self._ws.key) + + async def async_set_temperature(self, **kwargs: Any) -> None: + temp = kwargs.get(ATTR_TEMPERATURE) + if temp is None: + return + try: + await self.coordinator.write_register(self._ws.address, self._ws.to_raw(temp)) + except ModbusException as err: + raise HomeAssistantError(f"Failed to set {self._cdef.name}: {err}") from err + await self.coordinator.async_request_refresh() + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + # Only HEAT is supported; nothing to switch. + return diff --git a/custom_components/dimplex_wpm/config_flow.py b/custom_components/dimplex_wpm/config_flow.py index b09bb03..a07c176 100644 --- a/custom_components/dimplex_wpm/config_flow.py +++ b/custom_components/dimplex_wpm/config_flow.py @@ -2,34 +2,75 @@ from __future__ import annotations -import voluptuous as vol +from typing import Any from homeassistant import config_entries from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv +import voluptuous as vol -from .modbus_client import DimplexModbusClient from .const import ( - CONF_ENABLE_BMS_TEMP, - CONF_ENABLE_EMS, - CONF_ENABLE_EXTERNAL_LOCK, CONF_ENABLE_WRITE_ENTITIES, - CONF_REGISTER_STRATEGY, + CONF_ENABLED_MODULES, + CONF_FLOW_SENSOR_ENTITY, + CONF_HAS_ELECTRIC_METER, + CONF_HAS_FLOW_SENSOR, + CONF_HAS_HEAT_METER, + CONF_HAS_INVERTER_FREQ, + CONF_INCLUDE_RE_REGISTERS, + CONF_PROFILE, CONF_SCAN_INTERVAL, CONF_SOFTWARE_VERSION, CONF_TIMEOUT, CONF_UNIT_ID, DEFAULT_ENABLE_WRITE, DEFAULT_PORT, + DEFAULT_PROFILE, DEFAULT_SCAN_INTERVAL, DEFAULT_SOFTWARE_VERSION, DEFAULT_TIMEOUT, DEFAULT_UNIT_ID, DOMAIN, - REGISTER_STRATEGY_MAP, - REGISTER_STRATEGY_AUTO, + MODULE_NAME_MAP, + SELECTABLE_MODULES, SOFTWARE_VERSIONS, ) +from .modbus_client import DimplexModbusClient +from .profiles import PROFILES, get_profile + +REG_OUTDOOR = 1 +PROBE_REGISTERS = { + CONF_HAS_ELECTRIC_METER: 5170, + CONF_HAS_HEAT_METER: 5168, + CONF_HAS_INVERTER_FREQ: 114, +} + +PROFILE_CHOICES = {key: profile.name for key, profile in PROFILES.items()} +MODULE_CHOICES = {mod: MODULE_NAME_MAP.get(mod, mod) for mod in SELECTABLE_MODULES} + + +async def _validate_and_probe(user_input: dict) -> dict[str, bool]: + """Validate the connection (holding read) and probe capability hints.""" + client = DimplexModbusClient( + user_input[CONF_HOST], + user_input[CONF_PORT], + user_input[CONF_UNIT_ID], + user_input[CONF_TIMEOUT], + ) + hints: dict[str, bool] = {} + try: + await client.connect() + if await client.read_holding_registers(REG_OUTDOOR, 1) is None: + raise ConnectionError("No response from device") + for conf_key, reg in PROBE_REGISTERS.items(): + try: + hints[conf_key] = await client.read_holding_registers(reg, 1) is not None + except Exception: # noqa: BLE001 - a probe failure just means "absent" + hints[conf_key] = False + finally: + await client.close() + return hints class DimplexConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -37,98 +78,137 @@ class DimplexConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def _async_validate_input(self, user_input: dict) -> None: - client = DimplexModbusClient( - user_input[CONF_HOST], - user_input[CONF_PORT], - user_input[CONF_UNIT_ID], - user_input[CONF_TIMEOUT], - ) - try: - await client.connect() - registers = await client.read_input_registers(1, 1) - if registers is None: - raise ConnectionError("Unable to read from Modbus device") - finally: - await client.close() - - async def async_step_user(self, user_input=None): - """Handle the initial step.""" - errors = {} + def __init__(self) -> None: + self._data: dict[str, Any] = {} + self._hints: dict[str, bool] = {} + async def async_step_user(self, user_input: dict | None = None): + """Step 1: connection + profile + firmware.""" + errors: dict[str, str] = {} if user_input is not None: await self.async_set_unique_id( f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}_{user_input[CONF_UNIT_ID]}" ) self._abort_if_unique_id_configured() - return self.async_create_entry(title="Dimplex WPM", data=user_input) - - data_schema = vol.Schema( + try: + self._hints = await _validate_and_probe(user_input) + except Exception: # noqa: BLE001 + errors["base"] = "cannot_connect" + else: + self._data = dict(user_input) + return await self.async_step_features() + + schema = vol.Schema( { vol.Required(CONF_HOST): str, vol.Required(CONF_PORT, default=DEFAULT_PORT): int, vol.Required(CONF_UNIT_ID, default=DEFAULT_UNIT_ID): int, + vol.Required(CONF_PROFILE, default=DEFAULT_PROFILE): vol.In(PROFILE_CHOICES), + vol.Optional(CONF_SOFTWARE_VERSION, default=DEFAULT_SOFTWARE_VERSION): vol.In( + SOFTWARE_VERSIONS + ), vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): vol.All( int, vol.Range(min=5, max=300) ), vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.All( int, vol.Range(min=1, max=30) ), + } + ) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + + async def async_step_features(self, user_input: dict | None = None): + """Step 2: modules present + metering capabilities (auto-hinted).""" + if user_input is not None: + data = {**self._data, **user_input} + return self.async_create_entry(title="Dimplex WPM", data=data) + + profile = get_profile(self._data.get(CONF_PROFILE)) + schema = vol.Schema( + { vol.Optional( - CONF_SOFTWARE_VERSION, - default=DEFAULT_SOFTWARE_VERSION, - ): vol.In(SOFTWARE_VERSIONS), - vol.Optional(CONF_REGISTER_STRATEGY, default=REGISTER_STRATEGY_AUTO): vol.In( - REGISTER_STRATEGY_MAP - ), + CONF_ENABLED_MODULES, + default=sorted(profile.default_modules), + ): cv.multi_select(MODULE_CHOICES), + vol.Optional( + CONF_HAS_ELECTRIC_METER, + default=self._hints.get(CONF_HAS_ELECTRIC_METER, False), + ): bool, + vol.Optional( + CONF_HAS_HEAT_METER, + default=self._hints.get(CONF_HAS_HEAT_METER, False), + ): bool, + vol.Optional(CONF_HAS_FLOW_SENSOR, default=False): bool, + vol.Optional(CONF_FLOW_SENSOR_ENTITY, default=""): str, + vol.Optional( + CONF_HAS_INVERTER_FREQ, + default=self._hints.get(CONF_HAS_INVERTER_FREQ, True), + ): bool, + vol.Optional(CONF_INCLUDE_RE_REGISTERS, default=True): bool, } ) - return self.async_show_form(step_id="user", data_schema=data_schema, errors=errors) + return self.async_show_form(step_id="features", data_schema=schema) @staticmethod @callback def async_get_options_flow(config_entry): - return DimplexOptionsFlow(config_entry) + return DimplexOptionsFlow() class DimplexOptionsFlow(config_entries.OptionsFlow): - """Handle options for the integration.""" + """Options: tuning, modules, capabilities, and the control gate. - def __init__(self, config_entry): - self.config_entry = config_entry + ``self.config_entry`` is provided by the base class (HA 2024.11+); do not + assign it manually. + """ - async def async_step_init(self, user_input=None): - """Manage the options.""" + def _current(self, key, default): + return self.config_entry.options.get( + key, self.config_entry.data.get(key, default) + ) + + async def async_step_init(self, user_input: dict | None = None): if user_input is not None: return self.async_create_entry(title="", data=user_input) - data_schema = vol.Schema( + schema = vol.Schema( { vol.Optional( CONF_SCAN_INTERVAL, - default=self.config_entry.options.get( - CONF_SCAN_INTERVAL, self.config_entry.data.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - ), + default=self._current(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL), ): vol.All(int, vol.Range(min=5, max=300)), vol.Optional( - CONF_ENABLE_WRITE_ENTITIES, - default=self.config_entry.options.get( - CONF_ENABLE_WRITE_ENTITIES, DEFAULT_ENABLE_WRITE - ), + CONF_ENABLED_MODULES, + default=self._current(CONF_ENABLED_MODULES, []), + ): cv.multi_select(MODULE_CHOICES), + vol.Optional( + CONF_HAS_ELECTRIC_METER, + default=self._current(CONF_HAS_ELECTRIC_METER, False), ): bool, vol.Optional( - CONF_ENABLE_EMS, - default=self.config_entry.options.get(CONF_ENABLE_EMS, False), + CONF_HAS_HEAT_METER, + default=self._current(CONF_HAS_HEAT_METER, False), ): bool, vol.Optional( - CONF_ENABLE_BMS_TEMP, - default=self.config_entry.options.get(CONF_ENABLE_BMS_TEMP, False), + CONF_HAS_FLOW_SENSOR, + default=self._current(CONF_HAS_FLOW_SENSOR, False), ): bool, vol.Optional( - CONF_ENABLE_EXTERNAL_LOCK, - default=self.config_entry.options.get(CONF_ENABLE_EXTERNAL_LOCK, False), + CONF_FLOW_SENSOR_ENTITY, + default=self._current(CONF_FLOW_SENSOR_ENTITY, ""), + ): str, + vol.Optional( + CONF_HAS_INVERTER_FREQ, + default=self._current(CONF_HAS_INVERTER_FREQ, True), + ): bool, + vol.Optional( + CONF_INCLUDE_RE_REGISTERS, + default=self._current(CONF_INCLUDE_RE_REGISTERS, True), + ): bool, + vol.Optional( + CONF_ENABLE_WRITE_ENTITIES, + default=self._current(CONF_ENABLE_WRITE_ENTITIES, DEFAULT_ENABLE_WRITE), ): bool, } ) - - return self.async_show_form(step_id="init", data_schema=data_schema) + return self.async_show_form(step_id="init", data_schema=schema) diff --git a/custom_components/dimplex_wpm/const.py b/custom_components/dimplex_wpm/const.py index 5db306d..c6669d4 100644 --- a/custom_components/dimplex_wpm/const.py +++ b/custom_components/dimplex_wpm/const.py @@ -4,6 +4,31 @@ from typing import Final +from .registers import ( + CAP_ELECTRIC_METER, + CAP_FLOW_SENSOR, + CAP_HEAT_METER, + CAP_INVERTER_FREQ, + CORE_MODULES, + ENUM_FAULT, + ENUM_LOCK, + ENUM_OPERATING_MODE, + ENUM_SENSOR_ERROR, + ENUM_SG_READY, + ENUM_STATUS, + M_CONTROLLER, + M_COOLING, + M_DHW, + M_ENERGY, + M_HC1, + M_HC2_3, + M_POOL, + M_SOLAR, + M_SOURCE, + M_VENT, + OPTIONAL_MODULES, +) + DOMAIN: Final = "dimplex_wpm" DEFAULT_PORT: Final = 502 @@ -11,7 +36,9 @@ DEFAULT_SCAN_INTERVAL: Final = 30 DEFAULT_TIMEOUT: Final = 5 DEFAULT_ENABLE_WRITE: Final = False -DEFAULT_SOFTWARE_VERSION: Final = "H" +# Target firmware is L/M (modern). Status/lock/fault addresses + value maps differ +# per version; see registers.py and spec/REGISTERS.md. +DEFAULT_SOFTWARE_VERSION: Final = "M" # Dimplex documentation uses 1-based register numbers and the device expects 1-based addresses. REGISTER_OFFSET: Final = 0 @@ -22,21 +49,8 @@ CONF_SCAN_INTERVAL: Final = "scan_interval" CONF_TIMEOUT: Final = "timeout" CONF_SOFTWARE_VERSION: Final = "software_version" -CONF_REGISTER_STRATEGY: Final = "register_strategy" +# Master gate for control/write entities (M2). Calibration numbers are NOT gated. CONF_ENABLE_WRITE_ENTITIES: Final = "enable_write_entities" -CONF_ENABLE_EMS: Final = "enable_ems_entities" -CONF_ENABLE_BMS_TEMP: Final = "enable_bms_temp" -CONF_ENABLE_EXTERNAL_LOCK: Final = "enable_external_lock" - -REGISTER_STRATEGY_AUTO: Final = "auto" -REGISTER_STRATEGY_HOLDING: Final = "holding" -REGISTER_STRATEGY_INPUT: Final = "input" - -REGISTER_STRATEGY_MAP: Final = { - REGISTER_STRATEGY_AUTO: REGISTER_STRATEGY_AUTO, - REGISTER_STRATEGY_HOLDING: REGISTER_STRATEGY_HOLDING, - REGISTER_STRATEGY_INPUT: REGISTER_STRATEGY_INPUT, -} SOFTWARE_VERSIONS: Final = ["H", "J", "L", "M"] @@ -382,17 +396,87 @@ "M": SENSOR_ERROR_MAP_LM, } +OPERATING_MODE_MAP: Final = { + 0: "Summer", + 1: "Winter", + 2: "Holiday", + 3: "Party", + 4: "2nd heat generator", + 5: "Cooling", +} + + +def get_enum_map(enum_key: str, software_version: str) -> dict[int, str]: + """Resolve an enum key + firmware version to the right value→text map.""" + if enum_key == ENUM_STATUS: + return STATUS_MAP_BY_VERSION.get(software_version, STATUS_MAP_LM) + if enum_key == ENUM_LOCK: + return LOCK_MAP_BY_VERSION.get(software_version, LOCK_MAP_LM) + if enum_key == ENUM_FAULT: + return FAULT_MAP_BY_VERSION.get(software_version, FAULT_MAP_LM) + if enum_key == ENUM_SENSOR_ERROR: + return SENSOR_ERROR_MAP_BY_VERSION.get(software_version, SENSOR_ERROR_MAP_LM) + if enum_key == ENUM_SG_READY: + return SG_READY_MAP + if enum_key == ENUM_OPERATING_MODE: + return OPERATING_MODE_MAP + return {} + + DEVICE_MANUFACTURER: Final = "Dimplex" DEVICE_NAME: Final = "Dimplex WPM" -MODULE_ROOT: Final = "controller" -MODULE_HC1: Final = "hc1" -MODULE_DHW: Final = "dhw" -MODULE_SG: Final = "sg" +# Device-tree module keys come from registers.py (single source of truth). +MODULE_ROOT: Final = M_CONTROLLER MODULE_NAME_MAP: Final = { - MODULE_ROOT: "Dimplex WPM Controller", - MODULE_HC1: "Dimplex Heating Circuit 1", - MODULE_DHW: "Dimplex Domestic Hot Water", - MODULE_SG: "Dimplex Smart Grid", + M_CONTROLLER: "Controller", + M_HC1: "Heating circuit 1", + M_HC2_3: "Heating circuits 2/3", + M_DHW: "Domestic hot water", + M_POOL: "Swimming pool", + M_VENT: "Ventilation", + M_SOLAR: "Solar", + M_SOURCE: "Heat source", + M_COOLING: "Passive cooling", + M_ENERGY: "Analytics", +} + +# ----- profile / modules / capabilities (config entry) -------------------- +CONF_PROFILE: Final = "profile" +DEFAULT_PROFILE: Final = "lak9" + +CONF_ENABLED_MODULES: Final = "enabled_modules" + +CONF_HAS_ELECTRIC_METER: Final = "has_electric_meter" +CONF_HAS_HEAT_METER: Final = "has_heat_meter" +CONF_HAS_FLOW_SENSOR: Final = "has_flow_sensor" +CONF_FLOW_SENSOR_ENTITY: Final = "flow_sensor_entity" +CONF_HAS_INVERTER_FREQ: Final = "has_inverter_freq" +CONF_INCLUDE_RE_REGISTERS: Final = "include_re_registers" + +# Map config keys → capability tokens used by registers.py. +CAPABILITY_CONF_MAP: Final = { + CONF_HAS_ELECTRIC_METER: CAP_ELECTRIC_METER, + CONF_HAS_HEAT_METER: CAP_HEAT_METER, + CONF_HAS_FLOW_SENSOR: CAP_FLOW_SENSOR, + CONF_HAS_INVERTER_FREQ: CAP_INVERTER_FREQ, } + +# Optional modules a user can toggle (core modules are always on). +SELECTABLE_MODULES: Final = list(OPTIONAL_MODULES) + + +def resolve_capabilities(source: dict) -> frozenset[str]: + """Build the capability token set from a config/options dict.""" + return frozenset( + token + for conf_key, token in CAPABILITY_CONF_MAP.items() + if source.get(conf_key) + ) + + +def resolve_enabled_modules(source: dict) -> frozenset[str]: + """Core modules + the user-selected optional modules.""" + selected = set(source.get(CONF_ENABLED_MODULES) or []) + return frozenset(CORE_MODULES | (selected & set(SELECTABLE_MODULES))) diff --git a/custom_components/dimplex_wpm/coordinator.py b/custom_components/dimplex_wpm/coordinator.py index a29e39f..c5fe458 100644 --- a/custom_components/dimplex_wpm/coordinator.py +++ b/custom_components/dimplex_wpm/coordinator.py @@ -1,56 +1,40 @@ -"""Data update coordinator for the integration.""" +"""Data update coordinator — register-table driven.""" from __future__ import annotations -import logging from collections.abc import Callable from datetime import timedelta +import logging from typing import Any from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import ( - DEFAULT_SCAN_INTERVAL, - FAULT_MAP_BY_VERSION, - REG_FAULT_CODE, - LOCK_MAP_BY_VERSION, - REG_LOCK_CODE, - REG_DHW_TEMPERATURE, - REG_FLOW_TEMPERATURE, - REG_OUTDOOR_TEMPERATURE, - REG_RETURN_SETPOINT_TEMPERATURE, - REG_RETURN_TEMPERATURE, - REG_SG_READY_MODE, - SENSOR_ERROR_MAP_BY_VERSION, - REG_SENSOR_ERROR_CODE, - REG_STATUS_CODE, - SG_READY_MAP, - STATUS_MAP_BY_VERSION, -) +from . import estimation as est +from .const import DEFAULT_SCAN_INTERVAL, get_enum_map from .modbus_client import DimplexModbusClient +from .profiles import DeviceProfile +from .registers import ( + CAP_ELECTRIC_METER, + CAP_FLOW_SENSOR, + CAP_HEAT_METER, + CAP_INVERTER_FREQ, + COIL, + HOLDING, + active_energy_groups, + active_registers, + active_write_registers, + build_read_plan, + decode_value, + energy_total_kwh, +) LOGGER = logging.getLogger(__name__) -def _map_code(value: int, mapping: dict[int, str]) -> str: - """Return a mapped string or fallback.""" - if value in mapping: - return mapping[value] - return f"Unknown ({value})" - - -def _decode_temperature(value: int) -> float: - """Decode a signed temperature with 0.1 precision.""" - # Values are int16 with scale 0.1 - if value > 32767: - value -= 65536 - return round(value * 0.1, 1) - - class DimplexDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): - """Coordinate data fetching from the Modbus client.""" + """Coordinate Modbus reads and decode them into a flat ``values`` map.""" def __init__( self, @@ -58,11 +42,16 @@ def __init__( client: DimplexModbusClient, *, scan_interval: int = DEFAULT_SCAN_INTERVAL, - register_strategy: str = "holding", + software_version: str, + enabled_modules: frozenset[str], + capabilities: frozenset[str], + include_re: bool, + profile: DeviceProfile, + flow_sensor_entity: str | None = None, + enable_control: bool = False, host: str | None = None, port: int | None = None, unit_id: int | None = None, - software_version: str | None = None, ) -> None: super().__init__( hass, @@ -71,70 +60,104 @@ def __init__( update_interval=timedelta(seconds=scan_interval), ) self._client = client - self._register_strategy = register_strategy + self.software_version = software_version + self.enabled_modules = enabled_modules + self.capabilities = capabilities + self.profile = profile + self._flow_sensor_entity = flow_sensor_entity + + # Estimation is possible only with a power LUT and the inverter-frequency + # register; otherwise we fall back to measured registers where present. + self.estimation_possible = bool(profile.power_lut) and ( + CAP_INVERTER_FREQ in capabilities + ) + # Live-tunable calibration (mutated by the number platform). + self.tunables: dict[str, float | bool] = { + "k_dhw": profile.k_dhw, + "k_defrost": profile.k_defrost, + "k_defrost_loss": profile.k_defrost_loss, + "heater_w": float(profile.heater_default_w), + "pump_main_w": 0.0, + "pump_floor_w": 0.0, + "alpha_base": profile.alpha_base, + "alpha_sensitivity": profile.alpha_sensitivity, + "alpha_deadband": profile.alpha_deadband, + } + self._prev_delta_t: float | None = None + self._prev_delta_t_ts = None + self._flow_ema: float | None = None + + self.specs = active_registers( + version=software_version, + enabled_modules=enabled_modules, + capabilities=capabilities, + include_re=include_re, + ) + self.energy_groups = active_energy_groups( + enabled_modules=enabled_modules, capabilities=capabilities + ) + self.enable_control = enable_control + self.write_specs = ( + active_write_registers(enabled_modules=enabled_modules) if enable_control else [] + ) + extra_holding = {ws.address for ws in self.write_specs} + self._plan = build_read_plan( + self.specs, self.energy_groups, software_version, extra_holding + ) + self._connection_info = { "host": host, "port": port, "unit_id": unit_id, - "register_strategy": register_strategy, "software_version": software_version, + "profile": profile.key, } - self._software_version = software_version self._consecutive_failures = 0 async def _async_update_data(self) -> dict[str, Any]: - """Fetch data from Modbus and return structured payload.""" try: - raw = await self._read_registers() + holding, coils = await self._execute_plan() except Exception as err: self._consecutive_failures += 1 raise UpdateFailed(f"Error communicating with Modbus device: {err}") from err self._consecutive_failures = 0 - derived: dict[str, Any] = {} - if REG_OUTDOOR_TEMPERATURE in raw: - derived["outdoor_temperature"] = _decode_temperature( - raw[REG_OUTDOOR_TEMPERATURE] - ) - if REG_RETURN_TEMPERATURE in raw: - derived["return_temperature"] = _decode_temperature(raw[REG_RETURN_TEMPERATURE]) - if REG_RETURN_SETPOINT_TEMPERATURE in raw: - derived["return_setpoint_temperature"] = _decode_temperature( - raw[REG_RETURN_SETPOINT_TEMPERATURE] - ) - if REG_FLOW_TEMPERATURE in raw: - derived["flow_temperature"] = _decode_temperature(raw[REG_FLOW_TEMPERATURE]) - if REG_DHW_TEMPERATURE in raw: - derived["dhw_temperature"] = _decode_temperature(raw[REG_DHW_TEMPERATURE]) + values: dict[str, Any] = {"controller_info": "online"} - status_map = STATUS_MAP_BY_VERSION.get( - self._software_version, STATUS_MAP_BY_VERSION["H"] - ) - lock_map = LOCK_MAP_BY_VERSION.get( - self._software_version, LOCK_MAP_BY_VERSION["H"] - ) - fault_map = FAULT_MAP_BY_VERSION.get( - self._software_version, FAULT_MAP_BY_VERSION["H"] - ) - sensor_error_map = SENSOR_ERROR_MAP_BY_VERSION.get( - self._software_version, SENSOR_ERROR_MAP_BY_VERSION["H"] - ) + for spec in self.specs: + addr = spec.resolve_address(self.software_version) + if addr is None: + continue + if spec.obj == COIL: + if addr in coils: + values[spec.key] = bool(coils[addr]) + continue + if addr not in holding: + continue + raw = holding[addr] + if spec.enum: + mapping = get_enum_map(spec.enum, self.software_version) + values[spec.key] = mapping.get(raw, f"Unknown ({raw})") + else: + values[spec.key] = decode_value(spec, raw) - if REG_STATUS_CODE in raw: - derived["status_text"] = _map_code(raw[REG_STATUS_CODE], status_map) - if REG_SENSOR_ERROR_CODE in raw: - derived["sensor_error_text"] = _map_code( - raw[REG_SENSOR_ERROR_CODE], - sensor_error_map, - ) - if REG_SG_READY_MODE in raw: - derived["sg_ready_text"] = _map_code(raw[REG_SG_READY_MODE], SG_READY_MAP) - if REG_LOCK_CODE in raw: - derived["lock_text"] = _map_code(raw[REG_LOCK_CODE], lock_map) - derived["lock_active"] = raw[REG_LOCK_CODE] != 0 - if REG_FAULT_CODE in raw: - derived["fault_text"] = _map_code(raw[REG_FAULT_CODE], fault_map) - derived["fault_active"] = raw[REG_FAULT_CODE] != 0 + for group in self.energy_groups: + regs = [holding.get(r) for r in group.registers] + if all(r is not None for r in regs): + values[group.key] = energy_total_kwh(*regs) # type: ignore[arg-type] + + # Writable controls: decode current value (number=display, select=raw code). + for ws in self.write_specs: + if ws.address in holding: + values[ws.key] = ws.from_raw(holding[ws.address]) + + # Derived problem flags from the raw codes. + if "fault_code" in values: + values["fault_active"] = values["fault_code"] != 0 + if "lock_code" in values: + values["lock_active"] = values["lock_code"] != 0 + + self._add_estimation(values) meta = { "last_update": dt_util.utcnow().isoformat(), @@ -142,34 +165,127 @@ async def _async_update_data(self) -> dict[str, Any]: "consecutive_failures": self._consecutive_failures, **self._connection_info, } + return {"values": values, "raw_holding": holding, "raw_coils": coils, "meta": meta} + + def _add_estimation(self, values: dict[str, Any]) -> None: + """Compute derived power/COP/heat/flow + measured-vs-estimated selection.""" + flow = values.get("flow_temperature") + ret = values.get("return_temperature") + outdoor = values.get("outdoor_temperature") + status = values.get("status_code") + hz = values.get("inverter_frequency") + heater_on = bool(values.get("output_2nd_heat_generator")) + t = self.tunables + + # ΔT and its time derivative (K/min). + if flow is not None and ret is not None: + delta_t = round(flow - ret, 2) + values["delta_t"] = delta_t + now = dt_util.utcnow() + if self._prev_delta_t is not None and self._prev_delta_t_ts is not None: + minutes = (now - self._prev_delta_t_ts).total_seconds() / 60 + if minutes > 0: + values["ddelta_t_dt"] = round((delta_t - self._prev_delta_t) / minutes, 3) + self._prev_delta_t = delta_t + self._prev_delta_t_ts = now + + # COP from the EN14511 table (independent of metering). + cop = est.cop_en14511(outdoor, flow, self.profile.cop_table) + if cop: + values["cop_estimated"] = cop + + if self.estimation_possible and status is not None: + comp_w = est.compressor_power_w( + hz, status, self.profile.power_lut, t["k_dhw"], t["k_defrost"] + ) + heat_w = est.heater_power_w(heater_on, t["heater_w"]) + pumps_w = t["pump_main_w"] + t["pump_floor_w"] + total_w = est.total_power_w(comp_w, heat_w, pumps_w) + values["compressor_power_estimated"] = round(comp_w / 1000, 3) + values["heater_power_estimated"] = round(heat_w / 1000, 3) + values["total_power_estimated"] = round(total_w / 1000, 3) + + tc_w = est.thermal_power_compressor_w(comp_w, cop, status) + tdl_w = est.thermal_power_defrost_loss_w(comp_w, t["k_defrost_loss"], status) + loop_w = est.thermal_power_loop_w(tc_w, heat_w, tdl_w) + values["thermal_power_compressor"] = round(tc_w / 1000, 3) + values["thermal_power_heater"] = round(heat_w / 1000, 3) + values["thermal_power_defrost_loss"] = round(tdl_w / 1000, 3) + values["thermal_power_loop"] = round(loop_w / 1000, 3) + + alpha = est.alpha_house( + values.get("ddelta_t_dt", 0.0), + t["alpha_base"], t["alpha_sensitivity"], t["alpha_deadband"], + ) + house_w, inst_w = est.thermal_split(loop_w, alpha) + values["alpha_house"] = alpha + values["thermal_power_to_house"] = round(house_w / 1000, 3) + values["thermal_power_to_installation"] = round(inst_w / 1000, 3) + + # Flow: real sensor when present, else hydraulic estimate. + flow_rate = self._measured_flow() + if flow_rate is None: + flow_rate = est.estimated_flow_m3h(tc_w, values.get("delta_t", 0.0), status) + values["flow_rate"] = flow_rate + # EMA-smoothed flow for stabler charts (and stabler downstream use). + ema_alpha = 0.3 + self._flow_ema = ( + flow_rate + if self._flow_ema is None + else round(ema_alpha * flow_rate + (1 - ema_alpha) * self._flow_ema, 2) + ) + values["flow_rate_smoothed"] = self._flow_ema + + # Preferred (best) power sources + measured COP. + if CAP_ELECTRIC_METER in self.capabilities and values.get("electrical_power") is not None: + values["electrical_power_best"] = values["electrical_power"] + elif "total_power_estimated" in values: + values["electrical_power_best"] = values["total_power_estimated"] - return {"raw": raw, "derived": derived, "meta": meta} + if CAP_HEAT_METER in self.capabilities and values.get("heat_output_power") is not None: + values["heat_output_best"] = values["heat_output_power"] + elif "thermal_power_loop" in values: + values["heat_output_best"] = values["thermal_power_loop"] - async def _read_registers(self) -> dict[int, int]: - """Read registers according to configured strategy.""" - # Minimal set of contiguous ranges to reduce calls. - ranges = [ - (REG_OUTDOOR_TEMPERATURE, 6), # 1-6 includes temperatures - (REG_RETURN_SETPOINT_TEMPERATURE, 1), - (REG_STATUS_CODE, 4), # 103-106 codes - (REG_SG_READY_MODE, 1), - ] + elec = values.get("electrical_power") + heat = values.get("heat_output_power") + if heat is not None and elec: + values["cop_measured"] = round(heat / elec, 2) - strategy = "holding" if self._register_strategy == "holding" else "input" + def _measured_flow(self) -> float | None: + """Read an external flow-sensor entity (m³/h) if configured.""" + if not self._flow_sensor_entity or CAP_FLOW_SENSOR not in self.capabilities: + return None + state = self.hass.states.get(self._flow_sensor_entity) + if state is None or state.state in ("unknown", "unavailable", "", None): + return None + try: + return round(float(state.state), 2) + except (ValueError, TypeError): + return None - if self._register_strategy == "auto": - # Try input registers first, fall back to holding on failure. - try: - raw = await self._client.read_ranges(ranges, "input") - if raw: - return raw - except Exception as err: - LOGGER.debug("Input register read failed (%s), retrying as holding", err) - return await self._client.read_ranges(ranges, "holding") + async def _execute_plan(self) -> tuple[dict[int, int], dict[int, bool]]: + holding: dict[int, int] = {} + coils: dict[int, bool] = {} + for obj, start, count in self._plan: + if obj == HOLDING: + data = await self._client.read_holding_registers(start, count) + if data: + for offset, value in enumerate(data): + holding[start + offset] = value + elif obj == COIL: + bits = await self._client.read_coils(start, count) + if bits: + for offset, value in enumerate(bits): + coils[start + offset] = bool(value) + return holding, coils - return await self._client.read_ranges(ranges, strategy) + @property + def write_register(self) -> Callable[[int, int], Any]: + """Return helper for writing a holding register (used by M2 controls).""" + return self._client.write_register + # Backwards-compatible alias used by the SG Ready select. @property def write_sg_ready(self) -> Callable[[int, int], Any]: - """Return helper for writing SG Ready values.""" return self._client.write_register diff --git a/custom_components/dimplex_wpm/device.py b/custom_components/dimplex_wpm/device.py index dda2b6a..2744dd5 100644 --- a/custom_components/dimplex_wpm/device.py +++ b/custom_components/dimplex_wpm/device.py @@ -6,7 +6,13 @@ from homeassistant.config_entries import ConfigEntry -from .const import DEVICE_MANUFACTURER, DOMAIN, MODULE_NAME_MAP, MODULE_ROOT +from .const import ( + DEVICE_MANUFACTURER, + DEVICE_NAME, + DOMAIN, + MODULE_NAME_MAP, + MODULE_ROOT, +) def build_device_info( @@ -16,13 +22,22 @@ def build_device_info( host: str | None = None, configuration_url: str | None = None, software_version: str | None = None, + model: str | None = None, ) -> dict[str, Any]: - """Return device info for the requested module.""" + """Return device info for the requested module (medium device tree).""" base_identifier = (DOMAIN, entry.entry_id) - identifiers = {base_identifier} if module == MODULE_ROOT else {(DOMAIN, f"{entry.entry_id}_{module}")} - name = MODULE_NAME_MAP.get(module, MODULE_NAME_MAP[MODULE_ROOT]) - if module == MODULE_ROOT and host: - name = f"{name} ({host})" + if module == MODULE_ROOT: + identifiers = {base_identifier} + else: + identifiers = {(DOMAIN, f"{entry.entry_id}_{module}")} + + # Keep the host OUT of the device name: the name seeds entity_ids, so an IP + # in the name would bake the host into every entity_id (non-portable). + if module == MODULE_ROOT: + name = DEVICE_NAME + else: + name = MODULE_NAME_MAP.get(module, module) + device_info: dict[str, Any] = { "identifiers": identifiers, "manufacturer": DEVICE_MANUFACTURER, @@ -31,10 +46,12 @@ def build_device_info( if configuration_url: device_info["configuration_url"] = configuration_url - if module == MODULE_ROOT and software_version: - device_info["sw_version"] = software_version - - if module != MODULE_ROOT: + if module == MODULE_ROOT: + if software_version: + device_info["sw_version"] = software_version + if model: + device_info["model"] = model + else: device_info["via_device"] = base_identifier return device_info diff --git a/custom_components/dimplex_wpm/diagnostics.py b/custom_components/dimplex_wpm/diagnostics.py new file mode 100644 index 0000000..f2569e4 --- /dev/null +++ b/custom_components/dimplex_wpm/diagnostics.py @@ -0,0 +1,42 @@ +"""Diagnostics for the Dimplex WPM integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +TO_REDACT = {CONF_HOST, "host"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + coordinator = data["coordinator"] + return { + "entry": { + "data": async_redact_data(dict(entry.data), TO_REDACT), + "options": async_redact_data(dict(entry.options), TO_REDACT), + }, + "config": { + "profile": data.get("profile"), + "model": data.get("model"), + "software_version": coordinator.software_version, + "enabled_modules": sorted(coordinator.enabled_modules), + "capabilities": sorted(coordinator.capabilities), + "estimation_possible": coordinator.estimation_possible, + "enable_control": coordinator.enable_control, + "active_register_count": len(coordinator.specs), + "energy_groups": [g.key for g in coordinator.energy_groups], + "write_specs": [w.key for w in coordinator.write_specs], + "tunables": coordinator.tunables, + }, + "data": async_redact_data(coordinator.data or {}, TO_REDACT), + } diff --git a/custom_components/dimplex_wpm/entity.py b/custom_components/dimplex_wpm/entity.py new file mode 100644 index 0000000..81ead5d --- /dev/null +++ b/custom_components/dimplex_wpm/entity.py @@ -0,0 +1,55 @@ +"""Shared base for Dimplex WPM coordinator entities.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry + +from .device import build_device_info + + +class DimplexEntityMixin: + """Common device-info, unique-id and availability for all entities. + + Use as the first base, e.g. ``class DimplexSensor(DimplexEntityMixin, + CoordinatorEntity, SensorEntity)``. The platform __init__ must call + :meth:`_apply_common` after ``CoordinatorEntity.__init__``. + """ + + _attr_has_entity_name = True + _dimplex_key: str = "" + + def _apply_common( + self, + entry: ConfigEntry, + *, + key: str, + module: str, + name: str | None, + host: str | None = None, + software_version: str | None = None, + model: str | None = None, + ) -> None: + self._dimplex_key = key + self._attr_unique_id = f"{entry.entry_id}_{module}_{key}" + # Names come from translations (translation_key); the English string in + # strings.json/en.json equals the former _attr_name, so entity_ids stay + # stable and language-independent. ``name`` is kept for reference only. + self._attr_translation_key = key + configuration_url = f"http://{host}" if host else None + self._attr_device_info = build_device_info( + entry, + module, + host=host, + configuration_url=configuration_url, + software_version=software_version, + model=model, + ) + + @property + def available(self) -> bool: + """Entity is available when its value is present in coordinator data.""" + coordinator = self.coordinator # type: ignore[attr-defined] + if not coordinator.last_update_success: + return False + data = coordinator.data or {} + return self._dimplex_key in data.get("values", {}) diff --git a/custom_components/dimplex_wpm/estimation.py b/custom_components/dimplex_wpm/estimation.py new file mode 100644 index 0000000..584effe --- /dev/null +++ b/custom_components/dimplex_wpm/estimation.py @@ -0,0 +1,145 @@ +"""Estimation engine — power / COP / heat / flow. + +Pure functions (HA-free, unit-tested). Ported from the user's working YAML +estimator, with the COP 2D-interpolation rounding bug fixed. Used when a real +meter is absent (see the measurement-source matrix in DESIGN.md §6). + +Status codes are interpreted against the **L/M** firmware map (the calibrated +target). Other firmwares would need their own status semantics in the profile. +""" + +from __future__ import annotations + +# L/M status semantics (register 103). +STATUS_OFF_LOCK = frozenset({0, 1, 11, 30}) # off / idle / flow-monitoring / lock +STATUS_DEFROST = 10 +STATUS_DHW = 4 +STATUS_HEATING_DHW = frozenset({2, 4}) # compressor delivering usable heat + +# Water heat capacity for the hydraulic flow estimate. +_CP_WATER = 4180.0 # J/(kg·K) + + +def interp_lut(lut: tuple[tuple[float, float], ...], x: float) -> float: + """Piecewise-linear interpolation of an (x, y) LUT, clamped to its ends.""" + if not lut: + return 0.0 + if x <= lut[0][0]: + return float(lut[0][1]) + if x >= lut[-1][0]: + return float(lut[-1][1]) + for (x0, y0), (x1, y1) in zip(lut, lut[1:]): + if x0 <= x <= x1: + return y0 + (y1 - y0) * ((x - x0) / (x1 - x0)) + return float(lut[-1][1]) + + +def compressor_power_w( + hz: float | None, + status: int, + lut: tuple[tuple[float, float], ...], + k_dhw: float = 1.0, + k_defrost: float = 1.05, +) -> float: + """Estimate compressor electrical power (W) from inverter frequency.""" + if hz is None or hz <= 0: + return 0.0 + base = interp_lut(lut, hz) + if base <= 0: + return 0.0 + if status in STATUS_OFF_LOCK: + return 0.0 + if status == STATUS_DEFROST: + return round(base * k_defrost) + if status == STATUS_DHW: + return round(base * k_dhw) + return round(base) + + +def heater_power_w(on: bool, heater_w: float) -> float: + """2nd-source heater electrical power (W): fixed power when active.""" + return float(heater_w) if on else 0.0 + + +def total_power_w(compressor_w: float, heater_w: float, pumps_w: float = 0.0) -> float: + return compressor_w + heater_w + pumps_w + + +def cop_en14511( + t_out: float | None, + t_flow: float | None, + table: dict[int, dict[int, float]], +) -> float: + """Bilinear interpolation of an EN14511 COP table on (outdoor A, flow W).""" + if not table or t_out is None or t_flow is None: + return 0.0 + a_pts = sorted(table) + w_pts = sorted(next(iter(table.values()))) + a = min(max(t_out, a_pts[0]), a_pts[-1]) + w = min(max(t_flow, w_pts[0]), w_pts[-1]) + a0 = max(p for p in a_pts if p <= a) + a1 = min(p for p in a_pts if p >= a) + w0 = max(p for p in w_pts if p <= w) + w1 = min(p for p in w_pts if p >= w) + q11 = table[a0][w0] + q21 = table[a1][w0] + q12 = table[a0][w1] + q22 = table[a1][w1] + if a0 == a1 and w0 == w1: + return round(q11, 3) + if a0 == a1: + return round(q11 + (q12 - q11) * ((w - w0) / (w1 - w0)), 3) + if w0 == w1: + return round(q11 + (q21 - q11) * ((a - a0) / (a1 - a0)), 3) + # Full bilinear — whole expression rounded (the YAML bracketed round() wrong). + value = ( + q11 * (a1 - a) * (w1 - w) + + q21 * (a - a0) * (w1 - w) + + q12 * (a1 - a) * (w - w0) + + q22 * (a - a0) * (w - w0) + ) / ((a1 - a0) * (w1 - w0)) + return round(value, 3) + + +def thermal_power_compressor_w(p_el_w: float, cop: float, status: int) -> float: + """Compressor heat output (W) = electrical × COP, only when heating/DHW.""" + if status in STATUS_HEATING_DHW: + return round(p_el_w * cop) + return 0.0 + + +def thermal_power_defrost_loss_w(p_el_w: float, k_loss: float, status: int) -> float: + """Heat drawn from the system during defrost (W).""" + return round(p_el_w * k_loss) if status == STATUS_DEFROST else 0.0 + + +def thermal_power_loop_w(compressor_w: float, heater_w: float, defrost_loss_w: float) -> float: + return compressor_w + heater_w - defrost_loss_w + + +def alpha_house( + ddelta_t_dt: float, base: float, sensitivity: float, deadband: float, enabled: bool = True +) -> float: + """Fraction of loop heat delivered to the house (heuristic on dΔT/dt).""" + if not enabled: + return 1.0 + d = ddelta_t_dt + if -deadband < d < deadband: + d = 0.0 + return round(min(1.0, max(0.0, base - sensitivity * d)), 3) + + +def thermal_split(loop_w: float, alpha: float) -> tuple[float, float]: + """Split loop heat into (house, installation).""" + house = loop_w * alpha + return house, loop_w - house + + +def estimated_flow_m3h( + q_w: float, delta_t: float, status: int, clamp_max: float = 3.8 +) -> float: + """Hydraulic flow estimate from the energy balance V̇ = Q/(cp·ΔT)·3.6.""" + if status not in STATUS_HEATING_DHW or q_w <= 0 or delta_t <= 0.5: + return 0.0 + v = (q_w / (_CP_WATER * delta_t)) * 3.6 + return min(clamp_max, max(0.0, round(v, 2))) diff --git a/custom_components/dimplex_wpm/manifest.json b/custom_components/dimplex_wpm/manifest.json index 9a48832..3a6eea7 100644 --- a/custom_components/dimplex_wpm/manifest.json +++ b/custom_components/dimplex_wpm/manifest.json @@ -1,14 +1,16 @@ { "domain": "dimplex_wpm", "name": "Dimplex WPM", - "version": "0.1.0", + "codeowners": [ + "@nb3rt" + ], "config_flow": true, - "documentation": "https://github.com/DimplexModbusHACS/DimplexModbusHACS", + "documentation": "https://github.com/nb3rt/DimplexModbusHACS", + "integration_type": "hub", + "iot_class": "local_polling", + "issue_tracker": "https://github.com/nb3rt/DimplexModbusHACS/issues", "requirements": [ "pymodbus>=3.6.8" ], - "codeowners": [ - "@DimplexModbusHACS" - ], - "iot_class": "local_polling" + "version": "0.2.0" } diff --git a/custom_components/dimplex_wpm/modbus_client.py b/custom_components/dimplex_wpm/modbus_client.py index 641fca2..70ddf29 100644 --- a/custom_components/dimplex_wpm/modbus_client.py +++ b/custom_components/dimplex_wpm/modbus_client.py @@ -3,9 +3,10 @@ from __future__ import annotations import asyncio +from collections.abc import Callable, Iterable import inspect import logging -from typing import Any, Callable, Iterable, Optional +from typing import Any from pymodbus.client import AsyncModbusTcpClient from pymodbus.exceptions import ModbusException @@ -29,7 +30,7 @@ def __init__( self._port = port self._unit_id = unit_id self._timeout = timeout - self._client: Optional[AsyncModbusTcpClient] = None + self._client: AsyncModbusTcpClient | None = None self._lock = asyncio.Lock() async def connect(self) -> None: @@ -47,9 +48,12 @@ async def connect(self) -> None: LOGGER.debug("Connected to Modbus host %s:%s", self._host, self._port) async def close(self) -> None: - """Close the Modbus connection.""" + """Close the Modbus connection (pymodbus 3.x close() is synchronous).""" if self._client: - await self._client.close() + try: + self._client.close() + except Exception as err: + LOGGER.debug("Error closing Modbus connection: %s", err) LOGGER.debug("Closed Modbus connection") self._client = None @@ -70,12 +74,31 @@ async def read_input_registers( """Read input registers.""" return await self._read("read_input_registers", address, count) + async def read_coils(self, address: int, count: int) -> list[bool] | None: + """Read coils (digital outputs/inputs).""" + async with self._lock: + await self._ensure_connected() + try: + assert self._client is not None + result = await self._client.read_coils( + address=address, + count=count, + **self._unit_kwargs(self._client.read_coils), + ) + except ModbusException as err: + LOGGER.error("Modbus coil read failed: %s", err) + raise + if result.isError(): + LOGGER.warning("Modbus coil read error at %s: %s", address, result) + return None + return list(result.bits)[:count] + async def write_register(self, address: int, value: int) -> None: """Write a single holding register.""" address += REGISTER_OFFSET async with self._lock: - await self._ensure_connected() try: + await self._ensure_connected() assert self._client is not None result = await self._client.write_register( address, value, **self._unit_kwargs(self._client.write_register) @@ -83,6 +106,11 @@ async def write_register(self, address: int, value: int) -> None: except ModbusException as err: LOGGER.error("Modbus write failed: %s", err) raise + except (ConnectionError, OSError) as err: + # connect() raises builtin ConnectionError; map to ModbusException + # so entity write handlers surface a friendly HomeAssistantError. + LOGGER.error("Modbus write connection failed: %s", err) + raise ModbusException(f"Connection failed: {err}") from err if result.isError(): raise ModbusException(f"Write error: {result}") LOGGER.debug("Wrote register %s=%s", address, value) diff --git a/custom_components/dimplex_wpm/number.py b/custom_components/dimplex_wpm/number.py new file mode 100644 index 0000000..4dc9fbb --- /dev/null +++ b/custom_components/dimplex_wpm/number.py @@ -0,0 +1,173 @@ +"""Number platform — live calibration of the estimation engine. + +These write to Home Assistant only (not the heat pump), so they are NOT behind +the control/write gate. Values feed ``coordinator.tunables`` and are restored +across restarts. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberMode, + RestoreNumber, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from pymodbus.exceptions import ModbusException + +from .const import CONF_ENABLE_WRITE_ENTITIES, DEFAULT_ENABLE_WRITE, DOMAIN +from .entity import DimplexEntityMixin +from .registers import KIND_NUMBER, M_ENERGY, WriteSpec + +PARALLEL_UPDATES = 1 # serialize writes to the controller + +NUMBER_DEVICE_CLASS_MAP = {"temperature": NumberDeviceClass.TEMPERATURE} + + +@dataclass(frozen=True) +class CalibrationSpec: + key: str + tunable: str + name: str + min_value: float + max_value: float + step: float + unit: str | None = None + icon: str | None = None + + +CALIBRATION: tuple[CalibrationSpec, ...] = ( + CalibrationSpec("cal_k_dhw", "k_dhw", "Calibration: DHW power factor", 0.90, 1.20, 0.01, icon="mdi:tune"), + CalibrationSpec("cal_k_defrost", "k_defrost", "Calibration: defrost power factor", 0.90, 1.30, 0.01, icon="mdi:tune"), + CalibrationSpec("cal_k_defrost_loss", "k_defrost_loss", "Calibration: defrost heat-loss factor", 0.20, 4.00, 0.05, icon="mdi:snowflake-melt"), + CalibrationSpec("cal_heater_w", "heater_w", "Calibration: 2nd-source heater power", 0, 9000, 50, unit=UnitOfPower.WATT, icon="mdi:radiator"), + CalibrationSpec("cal_pump_main_w", "pump_main_w", "Calibration: main pump power", 0, 400, 5, unit=UnitOfPower.WATT, icon="mdi:pump"), + CalibrationSpec("cal_pump_floor_w", "pump_floor_w", "Calibration: floor pump power", 0, 400, 5, unit=UnitOfPower.WATT, icon="mdi:pump"), + CalibrationSpec("cal_alpha_base", "alpha_base", "Calibration: alpha base", 0.0, 1.0, 0.01, icon="mdi:home-percent"), + CalibrationSpec("cal_alpha_sensitivity", "alpha_sensitivity", "Calibration: alpha sensitivity", 0.0, 2.0, 0.05, icon="mdi:home-percent"), + CalibrationSpec("cal_alpha_deadband", "alpha_deadband", "Calibration: alpha deadband", 0.0, 0.20, 0.01, icon="mdi:home-percent"), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Create calibration numbers (only when estimation is active).""" + data = hass.data[DOMAIN][entry.entry_id] + coordinator = data["coordinator"] + host = data.get("host") + version = coordinator.software_version + model = data.get("model") + + entities: list[NumberEntity] = [] + + # Calibration numbers (always, when estimation runs; write to HA not the pump). + if coordinator.estimation_possible: + entities.extend( + DimplexCalibrationNumber(coordinator, entry, spec, host=host, version=version, model=model) + for spec in CALIBRATION + ) + + # Writable setpoints (gated by enable_control; write to the heat pump). + if data.get(CONF_ENABLE_WRITE_ENTITIES, DEFAULT_ENABLE_WRITE): + entities.extend( + DimplexWritableNumber(coordinator, entry, ws, host=host, version=version, model=model) + for ws in coordinator.write_specs + if ws.kind == KIND_NUMBER + ) + + async_add_entities(entities) + + +class DimplexCalibrationNumber(DimplexEntityMixin, CoordinatorEntity, RestoreNumber): + """A live-tunable estimation parameter, persisted across restarts.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_mode = NumberMode.BOX + + def __init__(self, coordinator, entry, spec: CalibrationSpec, *, host, version, model) -> None: + CoordinatorEntity.__init__(self, coordinator) + self._spec = spec + self._apply_common( + entry, key=spec.key, module=M_ENERGY, name=spec.name, + host=host, software_version=version, model=model, + ) + self._attr_native_min_value = spec.min_value + self._attr_native_max_value = spec.max_value + self._attr_native_step = spec.step + if spec.unit: + self._attr_native_unit_of_measurement = spec.unit + if spec.icon: + self._attr_icon = spec.icon + self._attr_native_value = float(coordinator.tunables[spec.tunable]) + + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + last = await self.async_get_last_number_data() + if last is not None and last.native_value is not None: + self._attr_native_value = float(last.native_value) + self.coordinator.tunables[self._spec.tunable] = self._attr_native_value + + @property + def available(self) -> bool: + # Calibration is local config; always settable. + return True + + async def async_set_native_value(self, value: float) -> None: + self._attr_native_value = value + self.coordinator.tunables[self._spec.tunable] = value + self.async_write_ha_state() + await self.coordinator.async_request_refresh() + + +class DimplexWritableNumber(DimplexEntityMixin, CoordinatorEntity, NumberEntity): + """A setpoint that writes a holding register on the heat pump (gated).""" + + _attr_mode = NumberMode.BOX + + def __init__(self, coordinator, entry, ws: WriteSpec, *, host, version, model) -> None: + CoordinatorEntity.__init__(self, coordinator) + self._ws = ws + self._apply_common( + entry, key=ws.key, module=ws.module, name=ws.name, + host=host, software_version=version, model=model, + ) + self._attr_native_min_value = ws.min_value + self._attr_native_max_value = ws.max_value + self._attr_native_step = ws.step + if ws.unit: + self._attr_native_unit_of_measurement = ws.unit + if ws.device_class: + self._attr_device_class = NUMBER_DEVICE_CLASS_MAP.get(ws.device_class) + if ws.icon: + self._attr_icon = ws.icon + + @property + def available(self) -> bool: + # Writable: keep operable even if the read-back register is missing. + return self.coordinator.last_update_success + + @property + def native_value(self) -> Any: + return (self.coordinator.data or {}).get("values", {}).get(self._ws.key) + + async def async_set_native_value(self, value: float) -> None: + raw = self._ws.to_raw(value) + try: + await self.coordinator.write_register(self._ws.address, raw) + except ModbusException as err: + raise HomeAssistantError( + f"Failed to write {self._ws.name}: {err}" + ) from err + await self.coordinator.async_request_refresh() diff --git a/custom_components/dimplex_wpm/profiles.py b/custom_components/dimplex_wpm/profiles.py new file mode 100644 index 0000000..aed9e8a --- /dev/null +++ b/custom_components/dimplex_wpm/profiles.py @@ -0,0 +1,74 @@ +"""Device profiles — per-model calibration & defaults. + +The WPM register map is identical across models; profiles only carry +model-specific calibration (Hz→W power LUT, EN14511 COP table, heater power) +and per-model defaults. Community contributions add a new profile here without +touching the register map. HA-free / pure data. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from .registers import CAP_INVERTER_FREQ + + +@dataclass(frozen=True) +class DeviceProfile: + """Model calibration + sensible defaults for one heat-pump model.""" + + key: str + name: str # shown as the device model + # Estimation calibration (consumed by the M1 estimation engine): + power_lut: tuple[tuple[float, float], ...] = () # (Hz, W) interpolation points + cop_table: dict[int, dict[int, float]] = field(default_factory=dict) # {A:{W:COP}} + heater_default_w: int = 6000 + k_dhw: float = 1.00 + k_defrost: float = 1.05 + k_defrost_loss: float = 2.35 + alpha_base: float = 0.85 + alpha_sensitivity: float = 0.40 + alpha_deadband: float = 0.03 + # Defaults applied at config time (user can override): + default_modules: frozenset[str] = frozenset() + default_capabilities: frozenset[str] = frozenset({CAP_INVERTER_FREQ}) + + +# Calibration for the Dimplex LAK 9S-TU (reverse-engineered + EN14511 datasheet), +# ported from the user's working YAML estimator. +LAK9 = DeviceProfile( + key="lak9", + name="Dimplex LAK 9S-TU", + power_lut=( + (32, 1050), (35, 1110), (39, 1190), (41, 1290), + (45, 1340), (49, 1500), (53, 1570), (57, 1720), + (61, 1850), (63, 1940), (65, 2010), (67, 2050), + (71, 2200), (75, 2320), (79, 2450), (83, 2550), + ), + cop_table={ + -7: {35: 2.40, 45: 2.24, 55: 1.72}, + 2: {35: 3.60, 45: 2.96, 55: 2.44}, + 7: {35: 4.80, 45: 3.30, 55: 2.86}, + 10: {35: 5.10, 45: 3.57, 55: 2.98}, + }, + heater_default_w=6000, +) + +# Generic fallback — no calibration (estimation degrades to on/off heuristics). +GENERIC_WPM = DeviceProfile( + key="generic_wpm", + name="Dimplex WPM (generic)", + default_capabilities=frozenset(), +) + +PROFILES: dict[str, DeviceProfile] = { + LAK9.key: LAK9, + GENERIC_WPM.key: GENERIC_WPM, +} + + +def get_profile(key: str | None) -> DeviceProfile: + """Return the profile for ``key`` (falls back to the generic profile).""" + if key and key in PROFILES: + return PROFILES[key] + return GENERIC_WPM diff --git a/custom_components/dimplex_wpm/quality_scale.yaml b/custom_components/dimplex_wpm/quality_scale.yaml new file mode 100644 index 0000000..46206aa --- /dev/null +++ b/custom_components/dimplex_wpm/quality_scale.yaml @@ -0,0 +1,99 @@ +# Integration Quality Scale tracking for dimplex_wpm. +# Honest self-assessment of the redesign. Bronze is largely met; the remaining +# Bronze/Silver gaps are HA-runtime test coverage and entry.runtime_data. +rules: + # ---------------- Bronze ---------------- + action-setup: + status: exempt + comment: Integration provides no service actions. + appropriate-polling: done + brands: done # custom_components/dimplex_wpm/brand/{icon,logo}{,@2x}.png + common-modules: done # coordinator.py, entity.py + config-flow: done + config-flow-test-coverage: + status: todo + comment: Pure-logic tests exist; HA-based config-flow tests pending. + dependency-transparency: done # pymodbus, pinned, OSS + docs-actions: + status: exempt + comment: No service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done # CoordinatorEntity + entity-unique-id: done + has-entity-name: done + runtime-data: + status: todo + comment: Uses hass.data[DOMAIN][entry_id]; migrate to entry.runtime_data. + test-before-configure: done # config flow reads a holding register + test-before-setup: done # async_config_entry_first_refresh + unique-config-entry: done # unique_id host:port_unit + + # ---------------- Silver ---------------- + action-exceptions: done # writes raise HomeAssistantError + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done # UpdateFailed → unavailable; availability checks + integration-owner: done # codeowners @nb3rt + log-when-unavailable: done # coordinator logs on UpdateFailed + parallel-updates: done # PARALLEL_UPDATES set on every platform + reauthentication-flow: + status: exempt + comment: Modbus TCP has no authentication. + test-coverage: + status: todo + comment: Pure estimation/register tests done; HA entity/coordinator tests pending. + + # ---------------- Gold ---------------- + devices: done # medium device tree + diagnostics: done # diagnostics.py (host redacted) + discovery: + status: exempt + comment: Modbus TCP devices have no standard discovery; host entered manually. + discovery-update-info: + status: exempt + comment: No discovery. + docs-data-update: done + docs-examples: done # dashboards/ + docs-known-limitations: done + docs-supported-devices: done # profiles + README + docs-supported-functions: done + docs-troubleshooting: + status: todo + comment: Add a troubleshooting section. + docs-use-cases: done + dynamic-devices: + status: exempt + comment: Device set is fixed per config (modules/capabilities). + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: todo + comment: Consider disabling noisy diagnostic coils by default. + entity-translations: done # en/pl/de entity names + exception-translations: + status: todo + comment: HomeAssistantError messages are not yet translated. + icon-translations: + status: todo + comment: Uses _attr_icon; migrate to icons.json. + reconfiguration-flow: + status: todo + comment: Options flow exists; add a full reconfigure (host/port) flow. + repair-issues: + status: exempt + comment: No repairable issues are raised yet. + stale-devices: + status: exempt + comment: No dynamic device removal. + + # ---------------- Platinum ---------------- + async-dependency: done # AsyncModbusTcpClient + inject-websession: + status: exempt + comment: Modbus, not HTTP. + strict-typing: + status: todo + comment: Partial type hints; full strict typing + py.typed pending. diff --git a/custom_components/dimplex_wpm/registers.py b/custom_components/dimplex_wpm/registers.py new file mode 100644 index 0000000..9aa9dfd --- /dev/null +++ b/custom_components/dimplex_wpm/registers.py @@ -0,0 +1,544 @@ +"""Canonical Dimplex WPM register table — single source of truth. + +Pure data + helpers, intentionally FREE of Home Assistant imports so it can be +unit-tested stand-alone. Platform files map the string-typed metadata +(device_class / state_class / entity_category / unit) onto HA enums. + +Derived from ``spec/REGISTERS.md`` (adversarially verified against the official +Dimplex NWPM Modbus TCP wiki). Key facts encoded here: + +* Analog values are **holding** registers (FC03); digital are **coils** (FC01). + There is no FC04 (input registers) on this device. +* Status / lock / fault / sensor-error register *addresses* are firmware-version + dependent (see ``address`` dicts). Target firmware is L/M. +* Energy registers 5096-5129 are **digit groups of one counter**, not quarters + (see :class:`EnergyGroup`). +* Real power/energy registers exist only on metered installs → gated by + ``capability`` so estimation can take over when absent (M1). +""" + +from __future__ import annotations + +from dataclasses import dataclass + +# ----- object types ------------------------------------------------------- +HOLDING = "holding" +COIL = "coil" + +# ----- device-tree modules (medium tree) ---------------------------------- +M_CONTROLLER = "controller" +M_HC1 = "hc1" +M_HC2_3 = "hc2_3" +M_DHW = "dhw" +M_POOL = "pool" +M_VENT = "vent" +M_SOLAR = "solar" +M_SOURCE = "source" +M_COOLING = "cooling" +M_ENERGY = "energy" # "Analytics" device (measured power/energy now; estimates in M1) + +ALL_MODULES = ( + M_CONTROLLER, M_HC1, M_HC2_3, M_DHW, M_POOL, M_VENT, M_SOLAR, + M_SOURCE, M_COOLING, M_ENERGY, +) +# Modules that are always present (cannot be toggled off). +CORE_MODULES = frozenset({M_CONTROLLER, M_HC1, M_DHW, M_SOURCE, M_ENERGY}) +# Optional modules gated by profile/installation. +OPTIONAL_MODULES = (M_HC2_3, M_POOL, M_VENT, M_SOLAR, M_COOLING) + +# ----- capabilities (per installation) ------------------------------------ +CAP_ELECTRIC_METER = "electric_meter" +CAP_HEAT_METER = "heat_meter" +CAP_FLOW_SENSOR = "flow_sensor" +CAP_INVERTER_FREQ = "inverter_freq" + +# ----- enum map keys (resolved in coordinator against const maps) --------- +ENUM_STATUS = "status" +ENUM_LOCK = "lock" +ENUM_FAULT = "fault" +ENUM_SENSOR_ERROR = "sensor_error" +ENUM_SG_READY = "sg_ready" +ENUM_OPERATING_MODE = "operating_mode" + +# Max registers/coils per Modbus transaction (conservative; spec allows 125). +MAX_READ_CHUNK = 100 +# Split a cluster when the address gap exceeds this (keeps reads tight). +CLUSTER_GAP = 8 + + +@dataclass(frozen=True) +class RegisterSpec: + """One readable datapoint → one entity.""" + + key: str + address: int | dict # int, or {version: addr|None} for version-dependent regs + obj: str = HOLDING + signed: bool = False + scale: float = 1.0 + unit: str | None = None + device_class: str | None = None + state_class: str | None = None + entity_category: str | None = None # "diagnostic" | "config" | None + module: str = M_CONTROLLER + enum: str | None = None + module_flag: str | None = None # optional module that must be enabled + capability: str | None = None # capability that must be present + re_only: bool = False # reverse-engineered (not in official spec) + icon: str | None = None + name: str | None = None + + def resolve_address(self, version: str) -> int | None: + """Return the register address for ``version`` (None if N/A).""" + if isinstance(self.address, dict): + return self.address.get(version) + return self.address + + +@dataclass(frozen=True) +class EnergyGroup: + """A cumulative kWh counter split across three digit-group registers. + + ``total = reg_9_12 * 1e8 + reg_5_8 * 1e4 + reg_1_4`` (kWh). + """ + + key: str + reg_1_4: int + reg_5_8: int + reg_9_12: int + name: str + module: str = M_ENERGY + capability: str = CAP_HEAT_METER + + @property + def registers(self) -> tuple[int, int, int]: + return (self.reg_1_4, self.reg_5_8, self.reg_9_12) + + +# Temperature defaults (most analog temps are int16, 0.1 °C, measurement). +def _temp(key, address, module, name, *, category=None, flag=None, cap=None, re=False): + return RegisterSpec( + key=key, address=address, obj=HOLDING, signed=True, scale=0.1, + unit="°C", device_class="temperature", state_class="measurement", + entity_category=category, module=module, name=name, module_flag=flag, + capability=cap, re_only=re, + ) + + +REGISTERS: tuple[RegisterSpec, ...] = ( + # ===== Controller: ambient, mode, diagnostics ===== + _temp("outdoor_temperature", 1, M_CONTROLLER, "Outdoor temperature"), + RegisterSpec( + "inverter_frequency", 114, scale=0.1, unit="Hz", device_class="frequency", + state_class="measurement", entity_category="diagnostic", + module=M_CONTROLLER, name="Inverter frequency", re_only=True, + capability=CAP_INVERTER_FREQ, icon="mdi:sine-wave", + ), + RegisterSpec( + "operating_mode", 5015, module=M_CONTROLLER, enum=ENUM_OPERATING_MODE, + name="Operating mode", icon="mdi:tune", + ), + RegisterSpec( + "party_hours", 5016, unit="h", state_class="measurement", + entity_category="diagnostic", module=M_CONTROLLER, name="Party hours", + icon="mdi:party-popper", + ), + RegisterSpec( + "holiday_days", 5017, unit="d", state_class="measurement", + entity_category="diagnostic", module=M_CONTROLLER, name="Holiday days", + icon="mdi:beach", + ), + # status / lock / fault / sensor-error — version-dependent ADDRESSES + RegisterSpec( + "status_code", {"L": 103, "M": 103, "J": 43, "H": 14}, + entity_category="diagnostic", module=M_CONTROLLER, name="Status code", + ), + RegisterSpec( + "status_text", {"L": 103, "M": 103, "J": 43, "H": 14}, + module=M_CONTROLLER, enum=ENUM_STATUS, name="Status", icon="mdi:heat-pump", + ), + RegisterSpec( + "lock_code", {"L": 104, "M": 104, "J": 59, "H": 94}, + entity_category="diagnostic", module=M_CONTROLLER, name="Lock code", + ), + RegisterSpec( + "lock_text", {"L": 104, "M": 104, "J": 59, "H": 94}, + module=M_CONTROLLER, enum=ENUM_LOCK, name="Lock", icon="mdi:lock-alert", + ), + RegisterSpec( + "fault_code", {"L": 105, "M": 105, "J": 42, "H": 13}, + entity_category="diagnostic", module=M_CONTROLLER, name="Fault code", + ), + RegisterSpec( + "fault_text", {"L": 105, "M": 105, "J": 42, "H": 13}, + module=M_CONTROLLER, enum=ENUM_FAULT, name="Fault", icon="mdi:alert-circle", + ), + RegisterSpec( + "sensor_error_code", {"L": 106, "M": 106, "J": None, "H": None}, + entity_category="diagnostic", module=M_CONTROLLER, name="Sensor error code", + ), + RegisterSpec( + "sensor_error_text", {"L": 106, "M": 106, "J": None, "H": None}, + module=M_CONTROLLER, enum=ENUM_SENSOR_ERROR, name="Sensor error", + icon="mdi:thermometer-alert", + ), + # ===== Runtimes (diagnostic, hours, total_increasing) ===== + RegisterSpec("runtime_compressor_1", 72, unit="h", state_class="total_increasing", + entity_category="diagnostic", module=M_CONTROLLER, name="Compressor 1 runtime", icon="mdi:timer-cog"), + RegisterSpec("runtime_compressor_2", 73, unit="h", state_class="total_increasing", + entity_category="diagnostic", module=M_CONTROLLER, name="Compressor 2 runtime", icon="mdi:timer-cog"), + RegisterSpec("runtime_primary_pump", 74, unit="h", state_class="total_increasing", + entity_category="diagnostic", module=M_CONTROLLER, name="Primary pump / fan runtime", icon="mdi:timer-cog"), + RegisterSpec("runtime_2nd_heat_generator", 75, unit="h", state_class="total_increasing", + entity_category="diagnostic", module=M_CONTROLLER, name="2nd heat generator runtime", icon="mdi:timer-cog"), + RegisterSpec("runtime_heating_pump_m13", 76, unit="h", state_class="total_increasing", + entity_category="diagnostic", module=M_HC1, name="Heating pump M13 runtime", icon="mdi:timer-cog"), + RegisterSpec("runtime_dhw_pump", 77, unit="h", state_class="total_increasing", + entity_category="diagnostic", module=M_DHW, name="DHW pump runtime", icon="mdi:timer-cog"), + RegisterSpec("runtime_immersion_heater", 78, unit="h", state_class="total_increasing", + entity_category="diagnostic", module=M_CONTROLLER, name="Immersion heater runtime", icon="mdi:timer-cog"), + RegisterSpec("runtime_pool_pump", 79, unit="h", state_class="total_increasing", + entity_category="diagnostic", module=M_POOL, module_flag=M_POOL, name="Pool pump runtime", icon="mdi:timer-cog"), + RegisterSpec("runtime_aux_circulation_pump", 71, unit="h", state_class="total_increasing", + entity_category="diagnostic", module=M_CONTROLLER, name="Auxiliary circulation pump runtime", icon="mdi:timer-cog"), + + # ===== HC1 ===== + _temp("flow_temperature", 5, M_HC1, "Flow temperature"), + _temp("return_temperature", 2, M_HC1, "Return temperature"), + _temp("return_setpoint_temperature", 53, M_HC1, "Return setpoint temperature", category="diagnostic"), + _temp("room_temperature_1", 11, M_HC1, "Room temperature 1"), + _temp("room_temperature_2", 12, M_HC1, "Room temperature 2"), + RegisterSpec("room_humidity_1", 13, signed=True, scale=0.1, unit="%", + device_class="humidity", state_class="measurement", module=M_HC1, name="Room humidity 1"), + RegisterSpec("room_humidity_2", 14, signed=True, scale=0.1, unit="%", + device_class="humidity", state_class="measurement", module=M_HC1, name="Room humidity 2"), + + # ===== HC2/3 (optional) ===== + _temp("hc2_temperature", 9, M_HC2_3, "HC2 temperature", flag=M_HC2_3), + _temp("hc2_setpoint_temperature", 54, M_HC2_3, "HC2 setpoint temperature", category="diagnostic", flag=M_HC2_3), + _temp("hc3_temperature", 10, M_HC2_3, "HC3 temperature", flag=M_HC2_3), + _temp("hc3_setpoint_temperature", 55, M_HC2_3, "HC3 setpoint temperature", category="diagnostic", flag=M_HC2_3), + + # ===== DHW ===== + _temp("dhw_temperature", 3, M_DHW, "DHW temperature"), + _temp("dhw_setpoint_temperature", 58, M_DHW, "DHW setpoint temperature", category="diagnostic"), + + # ===== Source ===== + _temp("source_inlet_temperature", 6, M_SOURCE, "Source inlet temperature"), + _temp("source_outlet_temperature", 7, M_SOURCE, "Source outlet temperature"), + + # ===== Passive cooling (optional) ===== + _temp("cooling_flow_temperature", 19, M_COOLING, "Flow temperature", flag=M_COOLING), + _temp("cooling_return_temperature", 20, M_COOLING, "Return temperature", flag=M_COOLING), + _temp("cooling_primary_return_temperature", 21, M_COOLING, "Primary return temperature", flag=M_COOLING), + + # ===== Solar (optional) — collector shares reg 10 with HC3 by config ===== + _temp("solar_collector_temperature", 10, M_SOLAR, "Collector temperature", flag=M_SOLAR, re=False), + _temp("solar_tank_temperature", 23, M_SOLAR, "Tank temperature", flag=M_SOLAR), + + # ===== Ventilation (optional) ===== + _temp("vent_outdoor_air_temperature", 120, M_VENT, "Outdoor air temperature", flag=M_VENT), + _temp("vent_supply_air_temperature", 121, M_VENT, "Supply air temperature", flag=M_VENT), + _temp("vent_extract_air_temperature", 122, M_VENT, "Extract air temperature", flag=M_VENT), + _temp("vent_exhaust_air_temperature", 123, M_VENT, "Exhaust air temperature", flag=M_VENT), + RegisterSpec("vent_supply_fan_speed", 125, signed=True, unit="rpm", state_class="measurement", + module=M_VENT, module_flag=M_VENT, name="Supply fan speed", icon="mdi:fan"), + RegisterSpec("vent_extract_fan_speed", 126, signed=True, unit="rpm", state_class="measurement", + module=M_VENT, module_flag=M_VENT, name="Extract fan speed", icon="mdi:fan"), + RegisterSpec("vent_level", 5034, state_class="measurement", entity_category="diagnostic", + module=M_VENT, module_flag=M_VENT, name="Fan level", icon="mdi:fan-chevron-up"), + + # ===== Smart Grid (read; control select added in M2) ===== + RegisterSpec("sg_ready_code", 5167, entity_category="diagnostic", module=M_CONTROLLER, name="SG Ready code"), + RegisterSpec("sg_ready_text", 5167, module=M_CONTROLLER, enum=ENUM_SG_READY, + name="SG Ready state", icon="mdi:solar-power"), + + # ===== Energy management: measured powers (metered installs only) ===== + RegisterSpec("heat_output_power", 5168, signed=True, scale=0.01, unit="kW", + device_class="power", state_class="measurement", module=M_ENERGY, + capability=CAP_HEAT_METER, name="Heat output power"), + RegisterSpec("electrical_power", 5170, signed=True, scale=0.01, unit="kW", + device_class="power", state_class="measurement", module=M_ENERGY, + capability=CAP_ELECTRIC_METER, name="Electrical power (meter)"), + RegisterSpec("pv_surplus", 5182, signed=True, scale=0.01, unit="kW", + device_class="power", state_class="measurement", module=M_ENERGY, + entity_category="diagnostic", capability=CAP_ELECTRIC_METER, + name="PV surplus", icon="mdi:solar-power-variant"), + + # ===== Coils — digital inputs (diagnostic) ===== + RegisterSpec("smartgrid_input_1", 3, obj=COIL, entity_category="diagnostic", + module=M_CONTROLLER, name="SmartGrid input 1"), + RegisterSpec("smartgrid_input_2", 4, obj=COIL, entity_category="diagnostic", + module=M_CONTROLLER, name="SmartGrid input 2"), + RegisterSpec("utility_lockout", 5, obj=COIL, device_class="lock", + entity_category="diagnostic", module=M_CONTROLLER, name="Utility (EVU) lockout"), + RegisterSpec("external_lockout", 6, obj=COIL, device_class="lock", + entity_category="diagnostic", module=M_CONTROLLER, name="External lockout"), + + # ===== Coils — digital outputs (diagnostic, running indicators) ===== + RegisterSpec("output_compressor_1", 41, obj=COIL, device_class="running", + entity_category="diagnostic", module=M_CONTROLLER, name="Compressor 1"), + RegisterSpec("output_compressor_2", 42, obj=COIL, device_class="running", + entity_category="diagnostic", module=M_CONTROLLER, name="Compressor 2"), + RegisterSpec("output_primary_pump", 43, obj=COIL, device_class="running", + entity_category="diagnostic", module=M_CONTROLLER, name="Primary pump / fan"), + RegisterSpec("output_2nd_heat_generator", 44, obj=COIL, device_class="running", + entity_category="diagnostic", module=M_CONTROLLER, name="2nd heat generator"), + RegisterSpec("output_heating_pump_m13", 45, obj=COIL, device_class="running", + entity_category="diagnostic", module=M_HC1, name="Heating pump M13"), + RegisterSpec("output_dhw_pump", 46, obj=COIL, device_class="running", + entity_category="diagnostic", module=M_DHW, name="DHW pump"), + RegisterSpec("output_mixer_m21_open", 47, obj=COIL, entity_category="diagnostic", + module=M_HC2_3, module_flag=M_HC2_3, name="Mixer M21 open"), + RegisterSpec("output_mixer_m21_close", 48, obj=COIL, entity_category="diagnostic", + module=M_HC2_3, module_flag=M_HC2_3, name="Mixer M21 close"), + RegisterSpec("output_aux_circulation_pump", 49, obj=COIL, device_class="running", + entity_category="diagnostic", module=M_CONTROLLER, name="Auxiliary circulation pump"), + RegisterSpec("output_immersion_heater", 50, obj=COIL, device_class="running", + entity_category="diagnostic", module=M_CONTROLLER, name="Immersion heater"), + RegisterSpec("output_heating_pump_m15", 51, obj=COIL, device_class="running", + entity_category="diagnostic", module=M_HC2_3, module_flag=M_HC2_3, name="Heating pump M15"), + RegisterSpec("output_mixer_m22_open", 52, obj=COIL, entity_category="diagnostic", + module=M_HC2_3, module_flag=M_HC2_3, name="Mixer M22 open"), + RegisterSpec("output_mixer_m22_close", 53, obj=COIL, entity_category="diagnostic", + module=M_HC2_3, module_flag=M_HC2_3, name="Mixer M22 close"), + RegisterSpec("output_pool_pump", 56, obj=COIL, device_class="running", + entity_category="diagnostic", module=M_POOL, module_flag=M_POOL, name="Pool pump"), + RegisterSpec("output_general_fault", 57, obj=COIL, device_class="problem", + entity_category="diagnostic", module=M_CONTROLLER, name="General fault output"), + RegisterSpec("output_heating_pump_m14", 59, obj=COIL, device_class="running", + entity_category="diagnostic", module=M_CONTROLLER, name="Heating pump M14"), + RegisterSpec("output_cooling_pump", 60, obj=COIL, device_class="running", + entity_category="diagnostic", module=M_COOLING, module_flag=M_COOLING, name="Cooling pump"), + RegisterSpec("output_heating_pump_m20", 61, obj=COIL, device_class="running", + entity_category="diagnostic", module=M_CONTROLLER, name="Heating pump M20"), + RegisterSpec("output_heat_cool_changeover", 66, obj=COIL, entity_category="diagnostic", + module=M_COOLING, module_flag=M_COOLING, name="Heat/cool changeover"), + RegisterSpec("output_primary_cooling_pump", 68, obj=COIL, device_class="running", + entity_category="diagnostic", module=M_COOLING, module_flag=M_COOLING, name="Primary cooling pump"), + RegisterSpec("output_solar_pump", 71, obj=COIL, device_class="running", + entity_category="diagnostic", module=M_SOLAR, module_flag=M_SOLAR, name="Solar pump"), +) + + +ENERGY_GROUPS: tuple[EnergyGroup, ...] = ( + EnergyGroup("energy_heating", 5096, 5097, 5098, "Heating energy (meter)"), + EnergyGroup("energy_dhw", 5099, 5100, 5101, "DHW energy (meter)"), + EnergyGroup("energy_pool", 5102, 5103, 5104, "Pool energy (meter)", module=M_POOL), + EnergyGroup("energy_environment", 5127, 5128, 5129, "Environmental energy (meter)"), +) + + +def is_spec_active( + spec: RegisterSpec, + *, + version: str, + enabled_modules: frozenset[str], + capabilities: frozenset[str], + include_re: bool, +) -> bool: + """Return True if ``spec`` should produce an entity for this config.""" + if spec.resolve_address(version) is None: + return False + if spec.re_only and not include_re: + return False + if spec.module_flag and spec.module_flag not in enabled_modules: + return False + if spec.capability and spec.capability not in capabilities: + return False + return True + + +def active_registers( + *, + version: str, + enabled_modules: frozenset[str], + capabilities: frozenset[str], + include_re: bool, +) -> list[RegisterSpec]: + """Return register specs active for the given configuration.""" + return [ + s for s in REGISTERS + if is_spec_active( + s, version=version, enabled_modules=enabled_modules, + capabilities=capabilities, include_re=include_re, + ) + ] + + +def active_energy_groups( + *, enabled_modules: frozenset[str], capabilities: frozenset[str], +) -> list[EnergyGroup]: + """Return energy groups readable for the given configuration.""" + groups = [] + for g in ENERGY_GROUPS: + if g.capability not in capabilities: + continue + if g.module in OPTIONAL_MODULES and g.module not in enabled_modules: + continue + groups.append(g) + return groups + + +def build_read_plan( + specs: list[RegisterSpec], + energy_groups: list[EnergyGroup], + version: str, + extra_holding: set[int] | None = None, +) -> list[tuple[str, int, int]]: + """Cluster the needed addresses into ``(obj, start, count)`` read chunks. + + Addresses are grouped per object type, sorted, then split on gaps larger + than :data:`CLUSTER_GAP` and capped at :data:`MAX_READ_CHUNK`. + ``extra_holding`` adds writable-control addresses so their current values + are read back. + """ + holding: set[int] = set(extra_holding or ()) + coils: set[int] = set() + for s in specs: + addr = s.resolve_address(version) + if addr is None: + continue + (coils if s.obj == COIL else holding).add(addr) + for g in energy_groups: + holding.update(g.registers) + + plan: list[tuple[str, int, int]] = [] + for obj, addresses in ((HOLDING, holding), (COIL, coils)): + for start, count in _cluster(sorted(addresses)): + plan.append((obj, start, count)) + return plan + + +def _cluster(addresses: list[int]) -> list[tuple[int, int]]: + """Turn a sorted address list into (start, count) chunks.""" + chunks: list[tuple[int, int]] = [] + if not addresses: + return chunks + start = prev = addresses[0] + for addr in addresses[1:]: + if addr - prev > CLUSTER_GAP or (addr - start + 1) > MAX_READ_CHUNK: + chunks.append((start, prev - start + 1)) + start = addr + prev = addr + chunks.append((start, prev - start + 1)) + return chunks + + +def decode_value(spec: RegisterSpec, raw: int) -> float | int: + """Decode a raw holding register value per the spec (sign + scale).""" + value = raw + if spec.signed and value > 0x7FFF: + value -= 0x10000 + if spec.scale == 1.0: + return value + scaled = value * spec.scale + # 0.1-scaled values → 1 decimal; 0.01 → 2 decimals. + decimals = 1 if spec.scale >= 0.1 else 2 + return round(scaled, decimals) + + +def energy_total_kwh(reg_1_4: int, reg_5_8: int, reg_9_12: int) -> int: + """Combine the three digit-group registers into total kWh.""" + return reg_9_12 * 100_000_000 + reg_5_8 * 10_000 + reg_1_4 + + +# ========================================================================== +# Writable controls (M2) — behind the enable_control gate. +# +# NOTE: exact raw encoding of setpoint registers is not fully confirmed without +# a real device. Direct registers are assumed integer in their unit; enum-coded +# registers use the documented offset mappings below. All writes are range +# clamped. Verify against hardware before trusting writes. +# ========================================================================== + +KIND_NUMBER = "number" +KIND_SELECT = "select" + +# Enum-coded register encoders: (encode display->raw, decode raw->display). +_ENCODERS = { + # 5036/5086 Parallelverschiebung: raw 0..38 ↔ −19..+19 K (K = raw − 19) + "offset19": (lambda v: int(round(v)) + 19, lambda r: r - 19), + # 5089 cooling room setpoint: raw 0..30 ↔ 15.0..30.0 °C (°C = 15 + 0.5·raw) + "cool_setpoint": (lambda v: int(round((v - 15.0) / 0.5)), lambda r: round(15.0 + r * 0.5, 1)), +} + + +@dataclass(frozen=True) +class WriteSpec: + """A writable control (number or select) backed by a holding register.""" + + key: str + address: int + name: str + module: str + kind: str = KIND_NUMBER + # number: + min_value: float = 0.0 + max_value: float = 0.0 + step: float = 1.0 + unit: str | None = None + device_class: str | None = None + encode: str | None = None # None => raw int == display; else key into _ENCODERS + # select: + options_map: dict[int, str] | None = None + # gating: + module_flag: str | None = None + icon: str | None = None + + def to_raw(self, display: float) -> int: + """Clamp the display value to range and encode it to a raw register value.""" + clamped = min(self.max_value, max(self.min_value, display)) + if self.encode: + return _ENCODERS[self.encode][0](clamped) + return int(round(clamped)) + + def from_raw(self, raw: int) -> float | int: + """Decode a raw register value to the display value.""" + if self.encode: + return _ENCODERS[self.encode][1](raw) + return raw + + +WRITE_REGISTERS: tuple[WriteSpec, ...] = ( + # DHW + WriteSpec("set_dhw_setpoint", 5047, "DHW setpoint", M_DHW, min_value=10, max_value=85, + unit="°C", device_class="temperature", icon="mdi:water-thermometer"), + WriteSpec("set_dhw_setpoint_min", 5145, "DHW setpoint minimum", M_DHW, min_value=10, max_value=85, + unit="°C", device_class="temperature"), + WriteSpec("set_dhw_setpoint_max", 5048, "DHW setpoint maximum", M_DHW, min_value=10, max_value=85, + unit="°C", device_class="temperature"), + # HC1 + # NOTE: setpoint scaling assumed whole-°C (matches the working YAML which read + # these as plain uint16 °C). Verify on device before trusting writes. + WriteSpec("set_hc1_room_setpoint", 46, "HC1 room setpoint", M_HC1, min_value=15, max_value=30, + step=1, unit="°C", device_class="temperature", icon="mdi:home-thermometer"), + WriteSpec("set_hc1_fixed_flow", 5037, "HC1 fixed flow setpoint", M_HC1, min_value=18, max_value=60, + unit="°C", device_class="temperature"), + WriteSpec("set_hc1_curve_end", 5038, "HC1 heating curve end", M_HC1, min_value=20, max_value=70, + unit="°C", device_class="temperature"), + WriteSpec("set_hc1_curve_offset", 5036, "HC1 curve offset", M_HC1, min_value=-19, max_value=19, + step=1, unit="K", encode="offset19", icon="mdi:tune-variant"), + # Pool (optional) + WriteSpec("set_pool_setpoint", 5051, "Pool setpoint", M_POOL, min_value=5, max_value=60, + unit="°C", device_class="temperature", module_flag=M_POOL, icon="mdi:pool-thermometer"), + # HC2/3 cooling room setpoint (enum-coded; uses the cool_setpoint encoder) + WriteSpec("set_hc23_cooling_setpoint", 5089, "HC2/3 cooling room setpoint", M_HC2_3, + min_value=15, max_value=30, step=0.5, unit="°C", encode="cool_setpoint", + device_class="temperature", module_flag=M_HC2_3, icon="mdi:snowflake-thermometer"), + # Operating mode (select) + WriteSpec("set_operating_mode", 5015, "Operating mode", M_CONTROLLER, kind=KIND_SELECT, + options_map={0: "Summer", 1: "Winter", 2: "Holiday", 3: "Party", + 4: "2nd heat generator", 5: "Cooling"}, icon="mdi:tune"), +) + + +def active_write_registers( + *, enabled_modules: frozenset[str], include_all_modules: bool = False +) -> list[WriteSpec]: + """Return writable controls active for the enabled modules.""" + out = [] + for ws in WRITE_REGISTERS: + if ws.module_flag and not include_all_modules and ws.module_flag not in enabled_modules: + continue + out.append(ws) + return out diff --git a/custom_components/dimplex_wpm/select.py b/custom_components/dimplex_wpm/select.py index 89826ab..2c763e9 100644 --- a/custom_components/dimplex_wpm/select.py +++ b/custom_components/dimplex_wpm/select.py @@ -2,25 +2,27 @@ from __future__ import annotations -from pymodbus.exceptions import ModbusException - from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from pymodbus.exceptions import ModbusException from .const import ( CONF_ENABLE_WRITE_ENTITIES, DEFAULT_ENABLE_WRITE, DOMAIN, - MODULE_SG, + MODULE_ROOT, REG_SG_READY_MODE, - SG_READY_MAP, SG_READY_REVERSE, ) from .device import build_device_info +from .entity import DimplexEntityMixin +from .registers import KIND_SELECT, WriteSpec + +PARALLEL_UPDATES = 1 # serialize writes to the controller async def async_setup_entry( @@ -33,10 +35,24 @@ async def async_setup_entry( coordinator = data["coordinator"] allow_write = data.get(CONF_ENABLE_WRITE_ENTITIES, DEFAULT_ENABLE_WRITE) - if allow_write: - async_add_entities( - [DimplexSGReadySelect(coordinator, entry, allow_write)], + if not allow_write: + return + + host = data.get("host") + version = coordinator.software_version + model = data.get("model") + + entities: list[SelectEntity] = [ + DimplexSGReadySelect( + coordinator, entry, allow_write, host=host, software_version=version, model=model ) + ] + entities.extend( + DimplexWriteSelect(coordinator, entry, ws, host=host, version=version, model=model) + for ws in coordinator.write_specs + if ws.kind == KIND_SELECT + ) + async_add_entities(entities) class DimplexSGReadySelect(CoordinatorEntity, SelectEntity): @@ -45,18 +61,37 @@ class DimplexSGReadySelect(CoordinatorEntity, SelectEntity): _attr_has_entity_name = True _attr_translation_key = "sg_ready_mode" - def __init__(self, coordinator, entry: ConfigEntry, allow_write: bool) -> None: + def __init__( + self, + coordinator, + entry: ConfigEntry, + allow_write: bool, + *, + host: str | None = None, + software_version: str | None = None, + model: str | None = None, + ) -> None: super().__init__(coordinator) self._entry = entry self._allow_write = allow_write - self._attr_unique_id = f"{entry.entry_id}_{MODULE_SG}_sg_ready_mode" - self._attr_device_info = build_device_info(entry, MODULE_SG) + self._attr_unique_id = f"{entry.entry_id}_{MODULE_ROOT}_sg_ready_mode" + configuration_url = f"http://{host}" if host else None + self._attr_device_info = build_device_info( + entry, + MODULE_ROOT, + host=host, + configuration_url=configuration_url, + software_version=software_version, + model=model, + ) @property def current_option(self) -> str | None: if not self.coordinator.data: return None - return self.coordinator.data["derived"].get("sg_ready_text") + value = self.coordinator.data.get("values", {}).get("sg_ready_text") + # Guard against unmapped device codes ("Unknown (x)") not in options. + return value if value in SG_READY_REVERSE else None @property def options(self) -> list[str]: @@ -78,3 +113,40 @@ async def async_select_option(self, option: str) -> None: await self.coordinator.async_request_refresh() except ModbusException as err: raise HomeAssistantError(f"Failed to write SG Ready value: {err}") from err + + +class DimplexWriteSelect(DimplexEntityMixin, CoordinatorEntity, SelectEntity): + """A holding-register select backed by a WriteSpec options_map (gated).""" + + def __init__(self, coordinator, entry: ConfigEntry, ws: WriteSpec, *, host, version, model) -> None: + CoordinatorEntity.__init__(self, coordinator) + self._ws = ws + self._reverse = {label: code for code, label in (ws.options_map or {}).items()} + self._apply_common( + entry, key=ws.key, module=ws.module, name=ws.name, + host=host, software_version=version, model=model, + ) + self._attr_options = list((ws.options_map or {}).values()) + if ws.icon: + self._attr_icon = ws.icon + + @property + def available(self) -> bool: + # Writable: keep operable even if the read-back register is missing. + return self.coordinator.last_update_success + + @property + def current_option(self) -> str | None: + raw = (self.coordinator.data or {}).get("values", {}).get(self._ws.key) + if raw is None: + return None + return (self._ws.options_map or {}).get(raw) + + async def async_select_option(self, option: str) -> None: + if option not in self._reverse: + raise HomeAssistantError(f"Invalid option {option}") + try: + await self.coordinator.write_register(self._ws.address, self._reverse[option]) + except ModbusException as err: + raise HomeAssistantError(f"Failed to write {self._ws.name}: {err}") from err + await self.coordinator.async_request_refresh() diff --git a/custom_components/dimplex_wpm/sensor.py b/custom_components/dimplex_wpm/sensor.py index eb6945d..4cf264d 100644 --- a/custom_components/dimplex_wpm/sensor.py +++ b/custom_components/dimplex_wpm/sensor.py @@ -1,194 +1,76 @@ -"""Sensor entities for Dimplex WPM.""" +"""Sensor platform — entities generated from the register table.""" from __future__ import annotations -import logging from dataclasses import dataclass -from typing import Any, Callable +from typing import Any from homeassistant.components.sensor import ( + RestoreSensor, SensorDeviceClass, SensorEntity, - SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, UnitOfTemperature -from homeassistant.core import HomeAssistant +from homeassistant.const import EntityCategory, UnitOfEnergy +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util -from .const import ( - CONF_ENABLE_BMS_TEMP, - CONF_ENABLE_EMS, - CONF_ENABLE_EXTERNAL_LOCK, - CONF_ENABLE_WRITE_ENTITIES, - DOMAIN, - MODULE_DHW, - MODULE_HC1, - MODULE_ROOT, - MODULE_SG, - DEFAULT_ENABLE_WRITE, - REG_DHW_TEMPERATURE, - REG_FAULT_CODE, - REG_FLOW_TEMPERATURE, - REG_LOCK_CODE, - REG_OUTDOOR_TEMPERATURE, - REG_RETURN_SETPOINT_TEMPERATURE, - REG_RETURN_TEMPERATURE, - REG_SENSOR_ERROR_CODE, - REG_SG_READY_MODE, - REG_STATUS_CODE, +from .const import DOMAIN, MODULE_ROOT +from .entity import DimplexEntityMixin +from .registers import ( + CAP_ELECTRIC_METER, + CAP_FLOW_SENSOR, + CAP_HEAT_METER, + HOLDING, + M_ENERGY, + EnergyGroup, + RegisterSpec, ) -from .device import build_device_info - -LOGGER = logging.getLogger(__name__) - - -@dataclass -class DimplexSensorEntityDescription(SensorEntityDescription): - """Describes Dimplex sensor entity.""" - - value_fn: Callable[[dict[str, Any]], Any] | None = None - attrs_fn: Callable[[dict[str, Any], dict[str, Any]], dict[str, Any] | None] | None = None - register: int | None = None - module: str = MODULE_ROOT - - -SENSOR_DESCRIPTIONS: tuple[DimplexSensorEntityDescription, ...] = ( - DimplexSensorEntityDescription( - key="controller_info", - translation_key="controller_info", - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: "online" if data else None, - attrs_fn=lambda data, entry_data: { - "host": entry_data.get("host"), - "port": entry_data.get("port"), - "unit_id": entry_data.get("unit_id"), - "software_version": entry_data.get("software_version"), - "register_strategy": data.get("meta", {}).get("register_strategy"), - "last_update": data.get("meta", {}).get("last_update"), - "update_success": data.get("meta", {}).get("update_success"), - "consecutive_failures": data.get("meta", {}).get("consecutive_failures"), - "capabilities": { - "sg_ready_write": entry_data.get("enable_write", False), - "ems_entities": entry_data.get("enable_ems", False), - "bms_temp": entry_data.get("enable_bms_temp", False), - "external_lock": entry_data.get("enable_external_lock", False), - }, - }, - ), - DimplexSensorEntityDescription( - key="outdoor_temperature", - translation_key="outdoor_temperature", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data["derived"].get("outdoor_temperature"), - ), - DimplexSensorEntityDescription( - key="return_temperature", - translation_key="return_temperature", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data["derived"].get("return_temperature"), - module=MODULE_HC1, - ), - DimplexSensorEntityDescription( - key="return_setpoint_temperature", - translation_key="return_setpoint_temperature", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data["derived"].get("return_setpoint_temperature"), - module=MODULE_HC1, - ), - DimplexSensorEntityDescription( - key="flow_temperature", - translation_key="flow_temperature", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data["derived"].get("flow_temperature"), - module=MODULE_HC1, - ), - DimplexSensorEntityDescription( - key="dhw_temperature", - translation_key="dhw_temperature", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data["derived"].get("dhw_temperature"), - module=MODULE_DHW, - ), - DimplexSensorEntityDescription( - key="status_code", - translation_key="status_code", - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data["raw"].get(REG_STATUS_CODE), - register=REG_STATUS_CODE, - ), - DimplexSensorEntityDescription( - key="status_text", - translation_key="status_text", - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data["derived"].get("status_text"), - ), - DimplexSensorEntityDescription( - key="lock_code", - translation_key="lock_code", - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data["raw"].get(REG_LOCK_CODE), - register=REG_LOCK_CODE, - ), - DimplexSensorEntityDescription( - key="lock_text", - translation_key="lock_text", - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data["derived"].get("lock_text"), - ), - DimplexSensorEntityDescription( - key="fault_code", - translation_key="fault_code", - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data["raw"].get(REG_FAULT_CODE), - register=REG_FAULT_CODE, - ), - DimplexSensorEntityDescription( - key="fault_text", - translation_key="fault_text", - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data["derived"].get("fault_text"), - ), - DimplexSensorEntityDescription( - key="sensor_error_code", - translation_key="sensor_error_code", - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data["raw"].get(REG_SENSOR_ERROR_CODE), - register=REG_SENSOR_ERROR_CODE, - ), - DimplexSensorEntityDescription( - key="sensor_error_text", - translation_key="sensor_error_text", - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data["derived"].get("sensor_error_text"), - ), - DimplexSensorEntityDescription( - key="sg_ready_code", - translation_key="sg_ready_code", - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data["raw"].get(REG_SG_READY_MODE), - register=REG_SG_READY_MODE, - module=MODULE_SG, - ), - DimplexSensorEntityDescription( - key="sg_ready_text", - translation_key="sg_ready_text", - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data["derived"].get("sg_ready_text"), - module=MODULE_SG, - ), + +PARALLEL_UPDATES = 0 # read-only, coordinator-driven + +DEVICE_CLASS_MAP = { + "temperature": SensorDeviceClass.TEMPERATURE, + "power": SensorDeviceClass.POWER, + "energy": SensorDeviceClass.ENERGY, + "humidity": SensorDeviceClass.HUMIDITY, + "frequency": SensorDeviceClass.FREQUENCY, +} +STATE_CLASS_MAP = { + "measurement": SensorStateClass.MEASUREMENT, + "total_increasing": SensorStateClass.TOTAL_INCREASING, + "total": SensorStateClass.TOTAL, +} +ENTITY_CATEGORY_MAP = { + "diagnostic": EntityCategory.DIAGNOSTIC, + "config": EntityCategory.CONFIG, +} + + +@dataclass(frozen=True) +class ComputedSpec: + key: str + name: str + unit: str | None = None + device_class: str | None = None + icon: str | None = None + + +# Estimated power/heat quantities (created only when estimation is possible). +ESTIMATED_COMPUTED: tuple[ComputedSpec, ...] = ( + ComputedSpec("compressor_power_estimated", "Compressor power (est.)", "kW", "power", "mdi:gauge"), + ComputedSpec("heater_power_estimated", "Heater power (est.)", "kW", "power", "mdi:radiator"), + ComputedSpec("total_power_estimated", "Total electrical power (est.)", "kW", "power", "mdi:flash"), + ComputedSpec("thermal_power_compressor", "Compressor heat output (est.)", "kW", "power", "mdi:fire"), + ComputedSpec("thermal_power_heater", "Heater heat output (est.)", "kW", "power", "mdi:fire"), + ComputedSpec("thermal_power_defrost_loss", "Defrost heat loss (est.)", "kW", "power", "mdi:snowflake-melt"), + ComputedSpec("thermal_power_loop", "Loop heat output (est.)", "kW", "power", "mdi:fire"), + ComputedSpec("thermal_power_to_house", "Heat to house (est.)", "kW", "power", "mdi:home-thermometer"), + ComputedSpec("thermal_power_to_installation", "Heat to installation (est.)", "kW", "power", "mdi:pipe-valve"), + ComputedSpec("alpha_house", "House heat fraction", None, None, "mdi:home-percent"), ) @@ -197,78 +79,253 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up sensors from config entry.""" + """Create sensor entities from the coordinator's active register set.""" data = hass.data[DOMAIN][entry.entry_id] coordinator = data["coordinator"] + host = data.get("host") + version = coordinator.software_version + model = data.get("model") + + entities: list[SensorEntity] = [ + DimplexControllerInfo(coordinator, entry, host=host, version=version, model=model) + ] + for spec in coordinator.specs: + if spec.obj == HOLDING: + entities.append( + DimplexSensor(coordinator, entry, spec, host=host, version=version, model=model) + ) + for group in coordinator.energy_groups: + entities.append( + DimplexEnergySensor(coordinator, entry, group, host=host, version=version, model=model) + ) + + # ----- Analytics: computed (measured vs estimated) ----- + caps = coordinator.capabilities + + def computed(key, name, unit, dc, icon, source): + entities.append( + DimplexComputedSensor( + coordinator, entry, key, name, unit, dc, icon, source, + host=host, version=version, model=model, + ) + ) + + computed("delta_t", "Temperature difference", "K", None, "mdi:delta", "measured") + if coordinator.estimation_possible: + computed("ddelta_t_dt", "Temperature difference rate", "K/min", None, "mdi:delta", "estimated") + for cs in ESTIMATED_COMPUTED: + computed(cs.key, cs.name, cs.unit, cs.device_class, cs.icon, "estimated") + flow_src = "measured" if CAP_FLOW_SENSOR in caps else "estimated" + computed("flow_rate", "Flow rate", "m³/h", None, "mdi:water-pump", flow_src) + computed("flow_rate_smoothed", "Flow rate (smoothed)", "m³/h", None, "mdi:water", flow_src) + if coordinator.profile.cop_table: + computed("cop_estimated", "COP (est.)", None, None, "mdi:chart-bell-curve", "estimated") + if CAP_HEAT_METER in caps and CAP_ELECTRIC_METER in caps: + computed("cop_measured", "COP (measured)", None, None, "mdi:chart-bell-curve", "measured") + if CAP_ELECTRIC_METER in caps or coordinator.estimation_possible: + computed( + "electrical_power_best", "Electrical power", "kW", "power", "mdi:flash", + "measured" if CAP_ELECTRIC_METER in caps else "estimated", + ) + if CAP_HEAT_METER in caps or coordinator.estimation_possible: + computed( + "heat_output_best", "Heat output", "kW", "power", "mdi:fire", + "measured" if CAP_HEAT_METER in caps else "estimated", + ) - register_usage: dict[int, str] = {} - entities: list[SensorEntity] = [] - integration_flags = { - "host": data.get("host"), - "port": data.get("port"), - "unit_id": data.get("unit_id"), - "software_version": data.get("software_version"), - "enable_write": data.get(CONF_ENABLE_WRITE_ENTITIES, DEFAULT_ENABLE_WRITE), - "enable_ems": data.get(CONF_ENABLE_EMS, False), - "enable_bms_temp": data.get(CONF_ENABLE_BMS_TEMP, False), - "enable_external_lock": data.get(CONF_ENABLE_EXTERNAL_LOCK, False), - } - - for description in SENSOR_DESCRIPTIONS: - if description.register is not None: - if description.register in register_usage: - LOGGER.warning( - "Skipping duplicate register %s for %s (already used by %s)", - description.register, - description.key, - register_usage[description.register], - ) - continue - register_usage[description.register] = description.key - entities.append(DimplexSensor(coordinator, entry, description, integration_flags)) + # ----- Analytics: integrated energy (kWh) ----- + def energy(key, source_key, name, source): + entities.append( + DimplexIntegrationSensor( + coordinator, entry, key, source_key, name, source, + host=host, version=version, model=model, + ) + ) + + if CAP_ELECTRIC_METER in caps or coordinator.estimation_possible: + elec_measured = CAP_ELECTRIC_METER in caps + energy( + "electrical_energy_kwh", "electrical_power_best", + f"Electrical energy ({'meter' if elec_measured else 'est.'})", + "measured" if elec_measured else "estimated", + ) + # Estimated heat energy only when there is NO heat meter (the measured + # digit-group energy_* sensors already cover that case → avoid double counting). + if coordinator.estimation_possible and CAP_HEAT_METER not in caps: + energy("heat_energy_kwh", "heat_output_best", "Heat energy (est.)", "estimated") + if coordinator.estimation_possible: + energy("heat_energy_to_house_kwh", "thermal_power_to_house", "Heat energy to house (est.)", "estimated") + energy( + "heat_energy_to_installation_kwh", "thermal_power_to_installation", + "Heat energy to installation (est.)", "estimated", + ) async_add_entities(entities) -class DimplexSensor(CoordinatorEntity, SensorEntity): - """Representation of a Dimplex sensor.""" +class DimplexSensor(DimplexEntityMixin, CoordinatorEntity, SensorEntity): + """A register-backed sensor.""" + + def __init__(self, coordinator, entry, spec: RegisterSpec, *, host, version, model) -> None: + CoordinatorEntity.__init__(self, coordinator) + self._spec = spec + self._apply_common( + entry, key=spec.key, module=spec.module, name=spec.name, + host=host, software_version=version, model=model, + ) + if spec.device_class: + self._attr_device_class = DEVICE_CLASS_MAP.get(spec.device_class) + if spec.state_class: + self._attr_state_class = STATE_CLASS_MAP.get(spec.state_class) + if spec.entity_category: + self._attr_entity_category = ENTITY_CATEGORY_MAP.get(spec.entity_category) + if spec.unit: + self._attr_native_unit_of_measurement = spec.unit + if spec.icon: + self._attr_icon = spec.icon - entity_description: DimplexSensorEntityDescription + @property + def native_value(self) -> Any: + return (self.coordinator.data or {}).get("values", {}).get(self._spec.key) + + +class DimplexEnergySensor(DimplexEntityMixin, CoordinatorEntity, SensorEntity): + """A combined digit-group energy counter (kWh).""" + + _attr_device_class = SensorDeviceClass.ENERGY + _attr_state_class = SensorStateClass.TOTAL_INCREASING + _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR + _attr_suggested_display_precision = 3 + + def __init__(self, coordinator, entry, group: EnergyGroup, *, host, version, model) -> None: + CoordinatorEntity.__init__(self, coordinator) + self._group = group + self._apply_common( + entry, key=group.key, module=group.module, name=group.name, + host=host, software_version=version, model=model, + ) + self._attr_extra_state_attributes = {"source": "measured"} + + @property + def native_value(self) -> Any: + return (self.coordinator.data or {}).get("values", {}).get(self._group.key) + + +class DimplexControllerInfo(DimplexEntityMixin, CoordinatorEntity, SensorEntity): + """Diagnostic sensor exposing connection/config metadata.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_icon = "mdi:heat-pump" + + def __init__(self, coordinator, entry, *, host, version, model) -> None: + CoordinatorEntity.__init__(self, coordinator) + self._meta_extra = { + "host": host, + "software_version": version, + "model": model, + } + self._apply_common( + entry, key="controller_info", module=MODULE_ROOT, name="Controller info", + host=host, software_version=version, model=model, + ) + + @property + def native_value(self) -> Any: + return (self.coordinator.data or {}).get("values", {}).get("controller_info") + + @property + def extra_state_attributes(self) -> dict[str, Any]: + meta = (self.coordinator.data or {}).get("meta", {}) + return { + **self._meta_extra, + "port": meta.get("port"), + "unit_id": meta.get("unit_id"), + "profile": meta.get("profile"), + "last_update": meta.get("last_update"), + "last_update_success": self.coordinator.last_update_success, + "estimation_possible": self.coordinator.estimation_possible, + "enabled_modules": sorted(self.coordinator.enabled_modules), + "capabilities": sorted(self.coordinator.capabilities), + } + + +class DimplexComputedSensor(DimplexEntityMixin, CoordinatorEntity, SensorEntity): + """A derived/estimated value read from the coordinator's computed values.""" + + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__( + self, coordinator, entry, key, name, unit, device_class, icon, source, + *, host, version, model, + ) -> None: + CoordinatorEntity.__init__(self, coordinator) + self._key = key + self._apply_common( + entry, key=key, module=M_ENERGY, name=name, + host=host, software_version=version, model=model, + ) + if device_class: + self._attr_device_class = DEVICE_CLASS_MAP.get(device_class) + if unit: + self._attr_native_unit_of_measurement = unit + if icon: + self._attr_icon = icon + self._attr_extra_state_attributes = {"source": source} + + @property + def native_value(self) -> Any: + return (self.coordinator.data or {}).get("values", {}).get(self._key) + + +class DimplexIntegrationSensor(DimplexEntityMixin, CoordinatorEntity, RestoreSensor): + """Trapezoidal Riemann integration of a kW power value into kWh.""" + + _attr_device_class = SensorDeviceClass.ENERGY + _attr_state_class = SensorStateClass.TOTAL_INCREASING + _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR + _attr_suggested_display_precision = 3 def __init__( - self, - coordinator, - entry: ConfigEntry, - description: DimplexSensorEntityDescription, - integration_flags: dict[str, Any], + self, coordinator, entry, key, source_key, name, source, + *, host, version, model, ) -> None: - super().__init__(coordinator) - self.entity_description = description - self._attr_has_entity_name = True - self._attr_translation_key = description.translation_key - self._integration_flags = integration_flags - configuration_url = None - if integration_flags.get("host"): - configuration_url = f"http://{integration_flags['host']}" - self._attr_device_info = build_device_info( - entry, - description.module, - host=integration_flags.get("host"), - configuration_url=configuration_url, - software_version=integration_flags.get("software_version"), + CoordinatorEntity.__init__(self, coordinator) + self._source_key = source_key + self._energy = 0.0 + self._last_power: float | None = None + self._last_ts = None + self._apply_common( + entry, key=key, module=M_ENERGY, name=name, + host=host, software_version=version, model=model, ) - self._attr_unique_id = f"{entry.entry_id}_{description.module}_{description.key}" + self._attr_extra_state_attributes = {"source": source, "integrates": source_key} + + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + last = await self.async_get_last_sensor_data() + if last is not None and last.native_value is not None: + try: + self._energy = float(last.native_value) + except (ValueError, TypeError): + pass + + @callback + def _handle_coordinator_update(self) -> None: + power = (self.coordinator.data or {}).get("values", {}).get(self._source_key) + now = dt_util.utcnow() + if power is not None: + if self._last_power is not None and self._last_ts is not None: + dt_h = (now - self._last_ts).total_seconds() / 3600 + if dt_h > 0: + self._energy += (self._last_power + power) / 2 * dt_h + self._last_power = power + self._last_ts = now + super()._handle_coordinator_update() @property - def native_value(self): - data = self.coordinator.data - if not data or not self.entity_description.value_fn: - return None - return self.entity_description.value_fn(data) + def native_value(self) -> float: + return round(self._energy, 3) @property - def extra_state_attributes(self) -> dict[str, Any] | None: - data = self.coordinator.data or {} - if not self.entity_description.attrs_fn: - return None - return self.entity_description.attrs_fn(data, self._integration_flags) + def available(self) -> bool: + return self.coordinator.last_update_success diff --git a/custom_components/dimplex_wpm/strings.json b/custom_components/dimplex_wpm/strings.json index 52b4f42..2f82777 100644 --- a/custom_components/dimplex_wpm/strings.json +++ b/custom_components/dimplex_wpm/strings.json @@ -3,70 +3,115 @@ "config": { "step": { "user": { - "title": "Dimplex WPM", - "description": "Connect to your Dimplex WPM/NWPM controller.", + "title": "Connect to Dimplex WPM", + "description": "Connect to your Dimplex WPM/NWPM controller over Modbus TCP.", "data": { "host": "Host", "port": "Port", "unit_id": "Unit ID", + "profile": "Heat-pump model (profile)", + "software_version": "WPM software version (H/J/L/M)", "scan_interval": "Scan interval (seconds)", - "timeout": "Timeout (seconds)", - "software_version": "Software version (H/J/L/M)", - "register_strategy": "Register bank strategy" + "timeout": "Timeout (seconds)" + } + }, + "features": { + "title": "Installed modules & metering", + "description": "Select the modules your installation has and which meters are present. Suggestions are pre-filled from a probe of the device. Without a meter, the corresponding value is estimated.", + "data": { + "enabled_modules": "Optional modules present", + "has_electric_meter": "Electrical power meter present (register 5170)", + "has_heat_meter": "Heat meter present (registers 5168/5096-5129)", + "has_flow_sensor": "External flow sensor present", + "flow_sensor_entity": "Flow sensor entity (optional)", + "has_inverter_freq": "Inverter frequency available (register 114)", + "include_re_registers": "Include reverse-engineered registers" } } }, "error": { - "cannot_connect": "Unable to connect to device" + "cannot_connect": "Unable to connect to the device" }, "abort": { - "already_configured": "Device is already configured" + "already_configured": "This device is already configured" } }, "options": { "step": { "init": { + "title": "Dimplex WPM options", "data": { "scan_interval": "Scan interval (seconds)", - "enable_write_entities": "Enable write entities (creates SG Ready mode entity)", - "enable_ems_entities": "Enable EMS entities", - "enable_bms_temp": "Enable BMS outdoor temperature entity", - "enable_external_lock": "Enable external lock entity" + "enabled_modules": "Optional modules present", + "has_electric_meter": "Electrical power meter present (register 5170)", + "has_heat_meter": "Heat meter present (registers 5168/5096-5129)", + "has_flow_sensor": "External flow sensor present", + "flow_sensor_entity": "Flow sensor entity (optional)", + "has_inverter_freq": "Inverter frequency available (register 114)", + "include_re_registers": "Include reverse-engineered registers", + "enable_write_entities": "Enable control / write entities (advanced)" } } } }, "entity": { "sensor": { + "alpha_house": { + "name": "House heat fraction" + }, + "compressor_power_estimated": { + "name": "Compressor power (est.)" + }, "controller_info": { "name": "Controller info" }, - "outdoor_temperature": { - "name": "Outdoor temperature" + "cooling_flow_temperature": { + "name": "Flow temperature" }, - "return_temperature": { + "cooling_primary_return_temperature": { + "name": "Primary return temperature" + }, + "cooling_return_temperature": { "name": "Return temperature" }, - "return_setpoint_temperature": { - "name": "Return setpoint temperature" + "cop_estimated": { + "name": "COP (est.)" }, - "flow_temperature": { - "name": "Flow temperature" + "cop_measured": { + "name": "COP (measured)" + }, + "ddelta_t_dt": { + "name": "Temperature difference rate" + }, + "delta_t": { + "name": "Temperature difference" + }, + "dhw_setpoint_temperature": { + "name": "DHW setpoint temperature" }, "dhw_temperature": { "name": "DHW temperature" }, - "status_code": { - "name": "Status code" + "electrical_energy_kwh": { + "name": "Electrical energy" }, - "status_text": { - "name": "Status" + "electrical_power": { + "name": "Electrical power (meter)" }, - "lock_code": { - "name": "Lock code" + "electrical_power_best": { + "name": "Electrical power" }, - "lock_text": { - "name": "Lock" + "energy_dhw": { + "name": "DHW energy (meter)" + }, + "energy_environment": { + "name": "Environmental energy (meter)" + }, + "energy_heating": { + "name": "Heating energy (meter)" + }, + "energy_pool": { + "name": "Pool energy (meter)" }, "fault_code": { "name": "Fault code" @@ -74,6 +119,114 @@ "fault_text": { "name": "Fault" }, + "flow_rate": { + "name": "Flow rate" + }, + "flow_rate_smoothed": { + "name": "Flow rate (smoothed)" + }, + "flow_temperature": { + "name": "Flow temperature" + }, + "hc2_setpoint_temperature": { + "name": "HC2 setpoint temperature" + }, + "hc2_temperature": { + "name": "HC2 temperature" + }, + "hc3_setpoint_temperature": { + "name": "HC3 setpoint temperature" + }, + "hc3_temperature": { + "name": "HC3 temperature" + }, + "heat_energy_kwh": { + "name": "Heat energy" + }, + "heat_energy_to_house_kwh": { + "name": "Heat energy to house" + }, + "heat_energy_to_installation_kwh": { + "name": "Heat energy to installation" + }, + "heat_output_best": { + "name": "Heat output" + }, + "heat_output_power": { + "name": "Heat output power" + }, + "heater_power_estimated": { + "name": "Heater power (est.)" + }, + "holiday_days": { + "name": "Holiday days" + }, + "inverter_frequency": { + "name": "Inverter frequency" + }, + "lock_code": { + "name": "Lock code" + }, + "lock_text": { + "name": "Lock" + }, + "operating_mode": { + "name": "Operating mode" + }, + "outdoor_temperature": { + "name": "Outdoor temperature" + }, + "party_hours": { + "name": "Party hours" + }, + "pv_surplus": { + "name": "PV surplus" + }, + "return_setpoint_temperature": { + "name": "Return setpoint temperature" + }, + "return_temperature": { + "name": "Return temperature" + }, + "room_humidity_1": { + "name": "Room humidity 1" + }, + "room_humidity_2": { + "name": "Room humidity 2" + }, + "room_temperature_1": { + "name": "Room temperature 1" + }, + "room_temperature_2": { + "name": "Room temperature 2" + }, + "runtime_2nd_heat_generator": { + "name": "2nd heat generator runtime" + }, + "runtime_aux_circulation_pump": { + "name": "Auxiliary circulation pump runtime" + }, + "runtime_compressor_1": { + "name": "Compressor 1 runtime" + }, + "runtime_compressor_2": { + "name": "Compressor 2 runtime" + }, + "runtime_dhw_pump": { + "name": "DHW pump runtime" + }, + "runtime_heating_pump_m13": { + "name": "Heating pump M13 runtime" + }, + "runtime_immersion_heater": { + "name": "Immersion heater runtime" + }, + "runtime_pool_pump": { + "name": "Pool pump runtime" + }, + "runtime_primary_pump": { + "name": "Primary pump / fan runtime" + }, "sensor_error_code": { "name": "Sensor error code" }, @@ -85,20 +238,219 @@ }, "sg_ready_text": { "name": "SG Ready state" + }, + "solar_collector_temperature": { + "name": "Collector temperature" + }, + "solar_tank_temperature": { + "name": "Tank temperature" + }, + "source_inlet_temperature": { + "name": "Source inlet temperature" + }, + "source_outlet_temperature": { + "name": "Source outlet temperature" + }, + "status_code": { + "name": "Status code" + }, + "status_text": { + "name": "Status" + }, + "thermal_power_compressor": { + "name": "Compressor heat output (est.)" + }, + "thermal_power_defrost_loss": { + "name": "Defrost heat loss (est.)" + }, + "thermal_power_heater": { + "name": "Heater heat output (est.)" + }, + "thermal_power_loop": { + "name": "Loop heat output (est.)" + }, + "thermal_power_to_house": { + "name": "Heat to house (est.)" + }, + "thermal_power_to_installation": { + "name": "Heat to installation (est.)" + }, + "total_power_estimated": { + "name": "Total electrical power (est.)" + }, + "vent_exhaust_air_temperature": { + "name": "Exhaust air temperature" + }, + "vent_extract_air_temperature": { + "name": "Extract air temperature" + }, + "vent_extract_fan_speed": { + "name": "Extract fan speed" + }, + "vent_level": { + "name": "Fan level" + }, + "vent_outdoor_air_temperature": { + "name": "Outdoor air temperature" + }, + "vent_supply_air_temperature": { + "name": "Supply air temperature" + }, + "vent_supply_fan_speed": { + "name": "Supply fan speed" } }, "binary_sensor": { + "external_lockout": { + "name": "External lockout" + }, "fault_active": { "name": "Fault active" }, "lock_active": { "name": "Lock active" + }, + "output_2nd_heat_generator": { + "name": "2nd heat generator" + }, + "output_aux_circulation_pump": { + "name": "Auxiliary circulation pump" + }, + "output_compressor_1": { + "name": "Compressor 1" + }, + "output_compressor_2": { + "name": "Compressor 2" + }, + "output_cooling_pump": { + "name": "Cooling pump" + }, + "output_dhw_pump": { + "name": "DHW pump" + }, + "output_general_fault": { + "name": "General fault output" + }, + "output_heat_cool_changeover": { + "name": "Heat/cool changeover" + }, + "output_heating_pump_m13": { + "name": "Heating pump M13" + }, + "output_heating_pump_m14": { + "name": "Heating pump M14" + }, + "output_heating_pump_m15": { + "name": "Heating pump M15" + }, + "output_heating_pump_m20": { + "name": "Heating pump M20" + }, + "output_immersion_heater": { + "name": "Immersion heater" + }, + "output_mixer_m21_close": { + "name": "Mixer M21 close" + }, + "output_mixer_m21_open": { + "name": "Mixer M21 open" + }, + "output_mixer_m22_close": { + "name": "Mixer M22 close" + }, + "output_mixer_m22_open": { + "name": "Mixer M22 open" + }, + "output_pool_pump": { + "name": "Pool pump" + }, + "output_primary_cooling_pump": { + "name": "Primary cooling pump" + }, + "output_primary_pump": { + "name": "Primary pump / fan" + }, + "output_solar_pump": { + "name": "Solar pump" + }, + "smartgrid_input_1": { + "name": "SmartGrid input 1" + }, + "smartgrid_input_2": { + "name": "SmartGrid input 2" + }, + "utility_lockout": { + "name": "Utility (EVU) lockout" + } + }, + "number": { + "cal_alpha_base": { + "name": "Calibration: alpha base" + }, + "cal_alpha_deadband": { + "name": "Calibration: alpha deadband" + }, + "cal_alpha_sensitivity": { + "name": "Calibration: alpha sensitivity" + }, + "cal_heater_w": { + "name": "Calibration: 2nd-source heater power" + }, + "cal_k_defrost": { + "name": "Calibration: defrost power factor" + }, + "cal_k_defrost_loss": { + "name": "Calibration: defrost heat-loss factor" + }, + "cal_k_dhw": { + "name": "Calibration: DHW power factor" + }, + "cal_pump_floor_w": { + "name": "Calibration: floor pump power" + }, + "cal_pump_main_w": { + "name": "Calibration: main pump power" + }, + "set_dhw_setpoint": { + "name": "DHW setpoint" + }, + "set_dhw_setpoint_max": { + "name": "DHW setpoint maximum" + }, + "set_dhw_setpoint_min": { + "name": "DHW setpoint minimum" + }, + "set_hc1_curve_end": { + "name": "HC1 heating curve end" + }, + "set_hc1_curve_offset": { + "name": "HC1 curve offset" + }, + "set_hc1_fixed_flow": { + "name": "HC1 fixed flow setpoint" + }, + "set_hc1_room_setpoint": { + "name": "HC1 room setpoint" + }, + "set_hc23_cooling_setpoint": { + "name": "HC2/3 cooling room setpoint" + }, + "set_pool_setpoint": { + "name": "Pool setpoint" } }, "select": { + "set_operating_mode": { + "name": "Operating mode" + }, "sg_ready_mode": { "name": "SG Ready mode" } + }, + "climate": { + "thermostat": { + "name": "Thermostat" + } } } } diff --git a/custom_components/dimplex_wpm/translations/de.json b/custom_components/dimplex_wpm/translations/de.json new file mode 100644 index 0000000..bdfa1af --- /dev/null +++ b/custom_components/dimplex_wpm/translations/de.json @@ -0,0 +1,456 @@ +{ + "title": "Dimplex WPM", + "config": { + "step": { + "user": { + "title": "Verbindung mit Dimplex WPM", + "description": "Verbinden Sie sich mit Ihrem Dimplex WPM/NWPM-Regler über Modbus TCP.", + "data": { + "host": "Host", + "port": "Port", + "unit_id": "Geräte-ID (Unit ID)", + "profile": "Wärmepumpen-Modell (Profil)", + "software_version": "WPM-Softwareversion (H/J/L/M)", + "scan_interval": "Abfrageintervall (s)", + "timeout": "Zeitlimit (s)" + } + }, + "features": { + "title": "Installierte Module & Zähler", + "description": "Wählen Sie die in Ihrer Anlage vorhandenen Module und Zähler. Vorschläge stammen aus einer Geräteabfrage. Ohne Zähler wird der Wert geschätzt.", + "data": { + "enabled_modules": "Vorhandene optionale Module", + "has_electric_meter": "Stromzähler vorhanden (Register 5170)", + "has_heat_meter": "Wärmemengenzähler vorhanden (Register 5168/5096-5129)", + "has_flow_sensor": "Externer Durchflusssensor vorhanden", + "flow_sensor_entity": "Durchflusssensor-Entität (optional)", + "has_inverter_freq": "Inverterfrequenz verfügbar (Register 114)", + "include_re_registers": "Reverse-Engineering-Register einbeziehen" + } + } + }, + "error": { + "cannot_connect": "Verbindung zum Gerät nicht möglich" + }, + "abort": { + "already_configured": "Dieses Gerät ist bereits konfiguriert" + } + }, + "options": { + "step": { + "init": { + "title": "Dimplex WPM Optionen", + "data": { + "scan_interval": "Abfrageintervall (s)", + "enabled_modules": "Vorhandene optionale Module", + "has_electric_meter": "Stromzähler vorhanden (Register 5170)", + "has_heat_meter": "Wärmemengenzähler vorhanden (Register 5168/5096-5129)", + "has_flow_sensor": "Externer Durchflusssensor vorhanden", + "flow_sensor_entity": "Durchflusssensor-Entität (optional)", + "has_inverter_freq": "Inverterfrequenz verfügbar (Register 114)", + "include_re_registers": "Reverse-Engineering-Register einbeziehen", + "enable_write_entities": "Steuer-/Schreibentitäten aktivieren (erweitert)" + } + } + } + }, + "entity": { + "sensor": { + "alpha_house": { + "name": "Wärmeanteil Haus" + }, + "compressor_power_estimated": { + "name": "Verdichterleistung (geschätzt)" + }, + "controller_info": { + "name": "Reglerinformation" + }, + "cooling_flow_temperature": { + "name": "Vorlauftemperatur passive Kühlung" + }, + "cooling_primary_return_temperature": { + "name": "Primär-Rücklauftemperatur Kühlung" + }, + "cooling_return_temperature": { + "name": "Rücklauftemperatur passive Kühlung" + }, + "cop_estimated": { + "name": "COP (geschätzt)" + }, + "cop_measured": { + "name": "COP (gemessen)" + }, + "ddelta_t_dt": { + "name": "Änderungsrate Temperaturdifferenz" + }, + "delta_t": { + "name": "Temperaturdifferenz" + }, + "dhw_setpoint_temperature": { + "name": "Warmwasser-Sollwert" + }, + "dhw_temperature": { + "name": "Warmwassertemperatur" + }, + "electrical_energy_kwh": { + "name": "Elektrische Energie" + }, + "electrical_power": { + "name": "Elektrische Leistung (Zähler)" + }, + "electrical_power_best": { + "name": "Elektrische Leistung" + }, + "energy_dhw": { + "name": "Warmwasserenergie (Zähler)" + }, + "energy_environment": { + "name": "Umweltenergie (Zähler)" + }, + "energy_heating": { + "name": "Heizenergie (Zähler)" + }, + "energy_pool": { + "name": "Poolenergie (Zähler)" + }, + "fault_code": { + "name": "Fehlercode" + }, + "fault_text": { + "name": "Fehler" + }, + "flow_rate": { + "name": "Durchfluss" + }, + "flow_rate_smoothed": { + "name": "Durchfluss (geglättet)" + }, + "flow_temperature": { + "name": "Vorlauftemperatur" + }, + "hc2_setpoint_temperature": { + "name": "Heizkreis 2 Sollwert" + }, + "hc2_temperature": { + "name": "Heizkreis 2 Temperatur" + }, + "hc3_setpoint_temperature": { + "name": "Heizkreis 3 Sollwert" + }, + "hc3_temperature": { + "name": "Heizkreis 3 Temperatur" + }, + "heat_energy_kwh": { + "name": "Wärmeenergie" + }, + "heat_energy_to_house_kwh": { + "name": "Wärmeenergie ins Haus" + }, + "heat_energy_to_installation_kwh": { + "name": "Wärmeenergie in die Anlage" + }, + "heat_output_best": { + "name": "Wärmeleistung" + }, + "heat_output_power": { + "name": "Wärmeleistung" + }, + "heater_power_estimated": { + "name": "Heizstableistung (geschätzt)" + }, + "holiday_days": { + "name": "Urlaubstage" + }, + "inverter_frequency": { + "name": "Inverterfrequenz" + }, + "lock_code": { + "name": "Sperrcode" + }, + "lock_text": { + "name": "Sperre" + }, + "operating_mode": { + "name": "Betriebsmodus" + }, + "outdoor_temperature": { + "name": "Außentemperatur" + }, + "party_hours": { + "name": "Party-Stunden" + }, + "pv_surplus": { + "name": "PV-Überschuss" + }, + "return_setpoint_temperature": { + "name": "Rücklauf-Sollwert" + }, + "return_temperature": { + "name": "Rücklauftemperatur" + }, + "room_humidity_1": { + "name": "Raumfeuchte 1" + }, + "room_humidity_2": { + "name": "Raumfeuchte 2" + }, + "room_temperature_1": { + "name": "Raumtemperatur 1" + }, + "room_temperature_2": { + "name": "Raumtemperatur 2" + }, + "runtime_2nd_heat_generator": { + "name": "Laufzeit 2. Wärmeerzeuger" + }, + "runtime_aux_circulation_pump": { + "name": "Laufzeit Zusatzumwälzpumpe" + }, + "runtime_compressor_1": { + "name": "Laufzeit Verdichter 1" + }, + "runtime_compressor_2": { + "name": "Laufzeit Verdichter 2" + }, + "runtime_dhw_pump": { + "name": "Laufzeit Warmwasserpumpe" + }, + "runtime_heating_pump_m13": { + "name": "Laufzeit Heizungspumpe M13" + }, + "runtime_immersion_heater": { + "name": "Laufzeit Tauchheizkörper" + }, + "runtime_pool_pump": { + "name": "Laufzeit Poolpumpe" + }, + "runtime_primary_pump": { + "name": "Laufzeit Primärpumpe / Ventilator" + }, + "sensor_error_code": { + "name": "Sensorfehlercode" + }, + "sensor_error_text": { + "name": "Sensorfehler" + }, + "sg_ready_code": { + "name": "SG Ready Code" + }, + "sg_ready_text": { + "name": "SG Ready Status" + }, + "solar_collector_temperature": { + "name": "Solarkollektortemperatur" + }, + "solar_tank_temperature": { + "name": "Solarspeichertemperatur" + }, + "source_inlet_temperature": { + "name": "Quelleneintrittstemperatur" + }, + "source_outlet_temperature": { + "name": "Quellenaustrittstemperatur" + }, + "status_code": { + "name": "Statuscode" + }, + "status_text": { + "name": "Status" + }, + "thermal_power_compressor": { + "name": "Wärmeleistung Verdichter (geschätzt)" + }, + "thermal_power_defrost_loss": { + "name": "Abtau-Wärmeverlust (geschätzt)" + }, + "thermal_power_heater": { + "name": "Wärmeleistung Heizstab (geschätzt)" + }, + "thermal_power_loop": { + "name": "Wärmeleistung Kreis (geschätzt)" + }, + "thermal_power_to_house": { + "name": "Wärme ins Haus (geschätzt)" + }, + "thermal_power_to_installation": { + "name": "Wärme in die Anlage (geschätzt)" + }, + "total_power_estimated": { + "name": "Gesamte elektrische Leistung (geschätzt)" + }, + "vent_exhaust_air_temperature": { + "name": "Fortlufttemperatur" + }, + "vent_extract_air_temperature": { + "name": "Ablufttemperatur" + }, + "vent_extract_fan_speed": { + "name": "Abluftventilatordrehzahl" + }, + "vent_level": { + "name": "Lüftungsstufe" + }, + "vent_outdoor_air_temperature": { + "name": "Außenlufttemperatur" + }, + "vent_supply_air_temperature": { + "name": "Zulufttemperatur" + }, + "vent_supply_fan_speed": { + "name": "Zuluftventilatordrehzahl" + } + }, + "binary_sensor": { + "external_lockout": { + "name": "Externe Sperre" + }, + "fault_active": { + "name": "Fehler aktiv" + }, + "lock_active": { + "name": "Sperre aktiv" + }, + "output_2nd_heat_generator": { + "name": "2. Wärmeerzeuger" + }, + "output_aux_circulation_pump": { + "name": "Zusatzumwälzpumpe" + }, + "output_compressor_1": { + "name": "Verdichter 1" + }, + "output_compressor_2": { + "name": "Verdichter 2" + }, + "output_cooling_pump": { + "name": "Kühlpumpe" + }, + "output_dhw_pump": { + "name": "Warmwasserpumpe" + }, + "output_general_fault": { + "name": "Sammelstörungsausgang" + }, + "output_heat_cool_changeover": { + "name": "Umschaltung Heizen/Kühlen" + }, + "output_heating_pump_m13": { + "name": "Heizungspumpe M13" + }, + "output_heating_pump_m14": { + "name": "Heizungspumpe M14" + }, + "output_heating_pump_m15": { + "name": "Heizungspumpe M15" + }, + "output_heating_pump_m20": { + "name": "Heizungspumpe M20" + }, + "output_immersion_heater": { + "name": "Tauchheizkörper" + }, + "output_mixer_m21_close": { + "name": "Mischer M21 zu" + }, + "output_mixer_m21_open": { + "name": "Mischer M21 auf" + }, + "output_mixer_m22_close": { + "name": "Mischer M22 zu" + }, + "output_mixer_m22_open": { + "name": "Mischer M22 auf" + }, + "output_pool_pump": { + "name": "Poolpumpe" + }, + "output_primary_cooling_pump": { + "name": "Primär-Kühlpumpe" + }, + "output_primary_pump": { + "name": "Primärpumpe / Ventilator" + }, + "output_solar_pump": { + "name": "Solarpumpe" + }, + "smartgrid_input_1": { + "name": "SmartGrid Eingang 1" + }, + "smartgrid_input_2": { + "name": "SmartGrid Eingang 2" + }, + "utility_lockout": { + "name": "EVU-Sperre" + } + }, + "number": { + "cal_alpha_base": { + "name": "Kalibrierung: Alpha-Basis" + }, + "cal_alpha_deadband": { + "name": "Kalibrierung: Alpha-Totband" + }, + "cal_alpha_sensitivity": { + "name": "Kalibrierung: Alpha-Empfindlichkeit" + }, + "cal_heater_w": { + "name": "Kalibrierung: Leistung 2. Wärmeerzeuger" + }, + "cal_k_defrost": { + "name": "Kalibrierung: Abtau-Leistungsfaktor" + }, + "cal_k_defrost_loss": { + "name": "Kalibrierung: Abtau-Wärmeverlustfaktor" + }, + "cal_k_dhw": { + "name": "Kalibrierung: Warmwasser-Leistungsfaktor" + }, + "cal_pump_floor_w": { + "name": "Kalibrierung: Leistung Fußbodenpumpe" + }, + "cal_pump_main_w": { + "name": "Kalibrierung: Leistung Hauptpumpe" + }, + "set_dhw_setpoint": { + "name": "Warmwasser-Sollwert" + }, + "set_dhw_setpoint_max": { + "name": "Warmwasser-Sollwert Maximum" + }, + "set_dhw_setpoint_min": { + "name": "Warmwasser-Sollwert Minimum" + }, + "set_hc1_curve_end": { + "name": "Heizkreis 1 Heizkurven-Endpunkt" + }, + "set_hc1_curve_offset": { + "name": "Heizkreis 1 Kurvenverschiebung" + }, + "set_hc1_fixed_flow": { + "name": "Heizkreis 1 Festwert-Vorlauf-Sollwert" + }, + "set_hc1_room_setpoint": { + "name": "Heizkreis 1 Raum-Sollwert" + }, + "set_hc23_cooling_setpoint": { + "name": "Heizkreis 2/3 Kühl-Raum-Sollwert" + }, + "set_pool_setpoint": { + "name": "Pool-Sollwert" + } + }, + "select": { + "set_operating_mode": { + "name": "Betriebsmodus" + }, + "sg_ready_mode": { + "name": "SG Ready Modus" + } + }, + "climate": { + "thermostat": { + "name": "Thermostat" + } + } + } +} diff --git a/custom_components/dimplex_wpm/translations/en.json b/custom_components/dimplex_wpm/translations/en.json index f36eca5..2f82777 100644 --- a/custom_components/dimplex_wpm/translations/en.json +++ b/custom_components/dimplex_wpm/translations/en.json @@ -3,70 +3,115 @@ "config": { "step": { "user": { - "title": "Dimplex WPM", - "description": "Connect to your Dimplex WPM/NWPM controller.", + "title": "Connect to Dimplex WPM", + "description": "Connect to your Dimplex WPM/NWPM controller over Modbus TCP.", "data": { "host": "Host", "port": "Port", "unit_id": "Unit ID", + "profile": "Heat-pump model (profile)", + "software_version": "WPM software version (H/J/L/M)", "scan_interval": "Scan interval (seconds)", - "timeout": "Timeout (seconds)", - "software_version": "Software version (H/J/L/M)", - "register_strategy": "Register bank strategy" + "timeout": "Timeout (seconds)" + } + }, + "features": { + "title": "Installed modules & metering", + "description": "Select the modules your installation has and which meters are present. Suggestions are pre-filled from a probe of the device. Without a meter, the corresponding value is estimated.", + "data": { + "enabled_modules": "Optional modules present", + "has_electric_meter": "Electrical power meter present (register 5170)", + "has_heat_meter": "Heat meter present (registers 5168/5096-5129)", + "has_flow_sensor": "External flow sensor present", + "flow_sensor_entity": "Flow sensor entity (optional)", + "has_inverter_freq": "Inverter frequency available (register 114)", + "include_re_registers": "Include reverse-engineered registers" } } }, "error": { - "cannot_connect": "Unable to connect to device" + "cannot_connect": "Unable to connect to the device" }, "abort": { - "already_configured": "Device is already configured" + "already_configured": "This device is already configured" } }, "options": { "step": { "init": { + "title": "Dimplex WPM options", "data": { "scan_interval": "Scan interval (seconds)", - "enable_write_entities": "Enable write entities", - "enable_ems_entities": "Enable EMS entities", - "enable_bms_temp": "Enable BMS outdoor temperature entity", - "enable_external_lock": "Enable external lock entity" + "enabled_modules": "Optional modules present", + "has_electric_meter": "Electrical power meter present (register 5170)", + "has_heat_meter": "Heat meter present (registers 5168/5096-5129)", + "has_flow_sensor": "External flow sensor present", + "flow_sensor_entity": "Flow sensor entity (optional)", + "has_inverter_freq": "Inverter frequency available (register 114)", + "include_re_registers": "Include reverse-engineered registers", + "enable_write_entities": "Enable control / write entities (advanced)" } } } }, "entity": { "sensor": { + "alpha_house": { + "name": "House heat fraction" + }, + "compressor_power_estimated": { + "name": "Compressor power (est.)" + }, "controller_info": { "name": "Controller info" }, - "outdoor_temperature": { - "name": "Outdoor temperature" + "cooling_flow_temperature": { + "name": "Flow temperature" }, - "return_temperature": { + "cooling_primary_return_temperature": { + "name": "Primary return temperature" + }, + "cooling_return_temperature": { "name": "Return temperature" }, - "return_setpoint_temperature": { - "name": "Return setpoint temperature" + "cop_estimated": { + "name": "COP (est.)" }, - "flow_temperature": { - "name": "Flow temperature" + "cop_measured": { + "name": "COP (measured)" + }, + "ddelta_t_dt": { + "name": "Temperature difference rate" + }, + "delta_t": { + "name": "Temperature difference" + }, + "dhw_setpoint_temperature": { + "name": "DHW setpoint temperature" }, "dhw_temperature": { "name": "DHW temperature" }, - "status_code": { - "name": "Status code" + "electrical_energy_kwh": { + "name": "Electrical energy" }, - "status_text": { - "name": "Status" + "electrical_power": { + "name": "Electrical power (meter)" }, - "lock_code": { - "name": "Lock code" + "electrical_power_best": { + "name": "Electrical power" }, - "lock_text": { - "name": "Lock" + "energy_dhw": { + "name": "DHW energy (meter)" + }, + "energy_environment": { + "name": "Environmental energy (meter)" + }, + "energy_heating": { + "name": "Heating energy (meter)" + }, + "energy_pool": { + "name": "Pool energy (meter)" }, "fault_code": { "name": "Fault code" @@ -74,6 +119,114 @@ "fault_text": { "name": "Fault" }, + "flow_rate": { + "name": "Flow rate" + }, + "flow_rate_smoothed": { + "name": "Flow rate (smoothed)" + }, + "flow_temperature": { + "name": "Flow temperature" + }, + "hc2_setpoint_temperature": { + "name": "HC2 setpoint temperature" + }, + "hc2_temperature": { + "name": "HC2 temperature" + }, + "hc3_setpoint_temperature": { + "name": "HC3 setpoint temperature" + }, + "hc3_temperature": { + "name": "HC3 temperature" + }, + "heat_energy_kwh": { + "name": "Heat energy" + }, + "heat_energy_to_house_kwh": { + "name": "Heat energy to house" + }, + "heat_energy_to_installation_kwh": { + "name": "Heat energy to installation" + }, + "heat_output_best": { + "name": "Heat output" + }, + "heat_output_power": { + "name": "Heat output power" + }, + "heater_power_estimated": { + "name": "Heater power (est.)" + }, + "holiday_days": { + "name": "Holiday days" + }, + "inverter_frequency": { + "name": "Inverter frequency" + }, + "lock_code": { + "name": "Lock code" + }, + "lock_text": { + "name": "Lock" + }, + "operating_mode": { + "name": "Operating mode" + }, + "outdoor_temperature": { + "name": "Outdoor temperature" + }, + "party_hours": { + "name": "Party hours" + }, + "pv_surplus": { + "name": "PV surplus" + }, + "return_setpoint_temperature": { + "name": "Return setpoint temperature" + }, + "return_temperature": { + "name": "Return temperature" + }, + "room_humidity_1": { + "name": "Room humidity 1" + }, + "room_humidity_2": { + "name": "Room humidity 2" + }, + "room_temperature_1": { + "name": "Room temperature 1" + }, + "room_temperature_2": { + "name": "Room temperature 2" + }, + "runtime_2nd_heat_generator": { + "name": "2nd heat generator runtime" + }, + "runtime_aux_circulation_pump": { + "name": "Auxiliary circulation pump runtime" + }, + "runtime_compressor_1": { + "name": "Compressor 1 runtime" + }, + "runtime_compressor_2": { + "name": "Compressor 2 runtime" + }, + "runtime_dhw_pump": { + "name": "DHW pump runtime" + }, + "runtime_heating_pump_m13": { + "name": "Heating pump M13 runtime" + }, + "runtime_immersion_heater": { + "name": "Immersion heater runtime" + }, + "runtime_pool_pump": { + "name": "Pool pump runtime" + }, + "runtime_primary_pump": { + "name": "Primary pump / fan runtime" + }, "sensor_error_code": { "name": "Sensor error code" }, @@ -85,20 +238,219 @@ }, "sg_ready_text": { "name": "SG Ready state" + }, + "solar_collector_temperature": { + "name": "Collector temperature" + }, + "solar_tank_temperature": { + "name": "Tank temperature" + }, + "source_inlet_temperature": { + "name": "Source inlet temperature" + }, + "source_outlet_temperature": { + "name": "Source outlet temperature" + }, + "status_code": { + "name": "Status code" + }, + "status_text": { + "name": "Status" + }, + "thermal_power_compressor": { + "name": "Compressor heat output (est.)" + }, + "thermal_power_defrost_loss": { + "name": "Defrost heat loss (est.)" + }, + "thermal_power_heater": { + "name": "Heater heat output (est.)" + }, + "thermal_power_loop": { + "name": "Loop heat output (est.)" + }, + "thermal_power_to_house": { + "name": "Heat to house (est.)" + }, + "thermal_power_to_installation": { + "name": "Heat to installation (est.)" + }, + "total_power_estimated": { + "name": "Total electrical power (est.)" + }, + "vent_exhaust_air_temperature": { + "name": "Exhaust air temperature" + }, + "vent_extract_air_temperature": { + "name": "Extract air temperature" + }, + "vent_extract_fan_speed": { + "name": "Extract fan speed" + }, + "vent_level": { + "name": "Fan level" + }, + "vent_outdoor_air_temperature": { + "name": "Outdoor air temperature" + }, + "vent_supply_air_temperature": { + "name": "Supply air temperature" + }, + "vent_supply_fan_speed": { + "name": "Supply fan speed" } }, "binary_sensor": { + "external_lockout": { + "name": "External lockout" + }, "fault_active": { "name": "Fault active" }, "lock_active": { "name": "Lock active" + }, + "output_2nd_heat_generator": { + "name": "2nd heat generator" + }, + "output_aux_circulation_pump": { + "name": "Auxiliary circulation pump" + }, + "output_compressor_1": { + "name": "Compressor 1" + }, + "output_compressor_2": { + "name": "Compressor 2" + }, + "output_cooling_pump": { + "name": "Cooling pump" + }, + "output_dhw_pump": { + "name": "DHW pump" + }, + "output_general_fault": { + "name": "General fault output" + }, + "output_heat_cool_changeover": { + "name": "Heat/cool changeover" + }, + "output_heating_pump_m13": { + "name": "Heating pump M13" + }, + "output_heating_pump_m14": { + "name": "Heating pump M14" + }, + "output_heating_pump_m15": { + "name": "Heating pump M15" + }, + "output_heating_pump_m20": { + "name": "Heating pump M20" + }, + "output_immersion_heater": { + "name": "Immersion heater" + }, + "output_mixer_m21_close": { + "name": "Mixer M21 close" + }, + "output_mixer_m21_open": { + "name": "Mixer M21 open" + }, + "output_mixer_m22_close": { + "name": "Mixer M22 close" + }, + "output_mixer_m22_open": { + "name": "Mixer M22 open" + }, + "output_pool_pump": { + "name": "Pool pump" + }, + "output_primary_cooling_pump": { + "name": "Primary cooling pump" + }, + "output_primary_pump": { + "name": "Primary pump / fan" + }, + "output_solar_pump": { + "name": "Solar pump" + }, + "smartgrid_input_1": { + "name": "SmartGrid input 1" + }, + "smartgrid_input_2": { + "name": "SmartGrid input 2" + }, + "utility_lockout": { + "name": "Utility (EVU) lockout" + } + }, + "number": { + "cal_alpha_base": { + "name": "Calibration: alpha base" + }, + "cal_alpha_deadband": { + "name": "Calibration: alpha deadband" + }, + "cal_alpha_sensitivity": { + "name": "Calibration: alpha sensitivity" + }, + "cal_heater_w": { + "name": "Calibration: 2nd-source heater power" + }, + "cal_k_defrost": { + "name": "Calibration: defrost power factor" + }, + "cal_k_defrost_loss": { + "name": "Calibration: defrost heat-loss factor" + }, + "cal_k_dhw": { + "name": "Calibration: DHW power factor" + }, + "cal_pump_floor_w": { + "name": "Calibration: floor pump power" + }, + "cal_pump_main_w": { + "name": "Calibration: main pump power" + }, + "set_dhw_setpoint": { + "name": "DHW setpoint" + }, + "set_dhw_setpoint_max": { + "name": "DHW setpoint maximum" + }, + "set_dhw_setpoint_min": { + "name": "DHW setpoint minimum" + }, + "set_hc1_curve_end": { + "name": "HC1 heating curve end" + }, + "set_hc1_curve_offset": { + "name": "HC1 curve offset" + }, + "set_hc1_fixed_flow": { + "name": "HC1 fixed flow setpoint" + }, + "set_hc1_room_setpoint": { + "name": "HC1 room setpoint" + }, + "set_hc23_cooling_setpoint": { + "name": "HC2/3 cooling room setpoint" + }, + "set_pool_setpoint": { + "name": "Pool setpoint" } }, "select": { + "set_operating_mode": { + "name": "Operating mode" + }, "sg_ready_mode": { "name": "SG Ready mode" } + }, + "climate": { + "thermostat": { + "name": "Thermostat" + } } } } diff --git a/custom_components/dimplex_wpm/translations/pl.json b/custom_components/dimplex_wpm/translations/pl.json new file mode 100644 index 0000000..6c80850 --- /dev/null +++ b/custom_components/dimplex_wpm/translations/pl.json @@ -0,0 +1,456 @@ +{ + "title": "Dimplex WPM", + "config": { + "step": { + "user": { + "title": "Połączenie z Dimplex WPM", + "description": "Połącz ze sterownikiem Dimplex WPM/NWPM przez Modbus TCP.", + "data": { + "host": "Host", + "port": "Port", + "unit_id": "ID urządzenia (Unit ID)", + "profile": "Model pompy ciepła (profil)", + "software_version": "Wersja oprogramowania WPM (H/J/L/M)", + "scan_interval": "Interwał odpytywania (s)", + "timeout": "Limit czasu (s)" + } + }, + "features": { + "title": "Zainstalowane moduły i pomiary", + "description": "Wybierz moduły obecne w instalacji oraz dostępne liczniki. Podpowiedzi pochodzą z odczytu próbnego urządzenia. Bez licznika dana wielkość jest estymowana.", + "data": { + "enabled_modules": "Obecne moduły opcjonalne", + "has_electric_meter": "Licznik energii elektrycznej (rejestr 5170)", + "has_heat_meter": "Ciepłomierz (rejestry 5168/5096-5129)", + "has_flow_sensor": "Zewnętrzny czujnik przepływu", + "flow_sensor_entity": "Encja czujnika przepływu (opcjonalnie)", + "has_inverter_freq": "Dostępna częstotliwość inwertera (rejestr 114)", + "include_re_registers": "Dołącz rejestry z reverse engineeringu" + } + } + }, + "error": { + "cannot_connect": "Nie można połączyć się z urządzeniem" + }, + "abort": { + "already_configured": "To urządzenie jest już skonfigurowane" + } + }, + "options": { + "step": { + "init": { + "title": "Opcje Dimplex WPM", + "data": { + "scan_interval": "Interwał odpytywania (s)", + "enabled_modules": "Obecne moduły opcjonalne", + "has_electric_meter": "Licznik energii elektrycznej (rejestr 5170)", + "has_heat_meter": "Ciepłomierz (rejestry 5168/5096-5129)", + "has_flow_sensor": "Zewnętrzny czujnik przepływu", + "flow_sensor_entity": "Encja czujnika przepływu (opcjonalnie)", + "has_inverter_freq": "Dostępna częstotliwość inwertera (rejestr 114)", + "include_re_registers": "Dołącz rejestry z reverse engineeringu", + "enable_write_entities": "Włącz encje sterujące / zapis (zaawansowane)" + } + } + } + }, + "entity": { + "sensor": { + "alpha_house": { + "name": "Udział ciepła do domu" + }, + "compressor_power_estimated": { + "name": "Moc sprężarki (szac.)" + }, + "controller_info": { + "name": "Informacje o sterowniku" + }, + "cooling_flow_temperature": { + "name": "Temperatura zasilania chłodzenia pasywnego" + }, + "cooling_primary_return_temperature": { + "name": "Temperatura powrotu pierwotnego chłodzenia" + }, + "cooling_return_temperature": { + "name": "Temperatura powrotu chłodzenia pasywnego" + }, + "cop_estimated": { + "name": "COP (szac.)" + }, + "cop_measured": { + "name": "COP (zmierzony)" + }, + "ddelta_t_dt": { + "name": "Szybkość zmian różnicy temperatur" + }, + "delta_t": { + "name": "Różnica temperatur" + }, + "dhw_setpoint_temperature": { + "name": "Nastawa temperatury CWU" + }, + "dhw_temperature": { + "name": "Temperatura CWU" + }, + "electrical_energy_kwh": { + "name": "Energia elektryczna" + }, + "electrical_power": { + "name": "Moc elektryczna (licznik)" + }, + "electrical_power_best": { + "name": "Moc elektryczna" + }, + "energy_dhw": { + "name": "Energia CWU (licznik)" + }, + "energy_environment": { + "name": "Energia środowiskowa (licznik)" + }, + "energy_heating": { + "name": "Energia ogrzewania (licznik)" + }, + "energy_pool": { + "name": "Energia basenu (licznik)" + }, + "fault_code": { + "name": "Kod usterki" + }, + "fault_text": { + "name": "Usterka" + }, + "flow_rate": { + "name": "Przepływ" + }, + "flow_rate_smoothed": { + "name": "Przepływ (wygładzony)" + }, + "flow_temperature": { + "name": "Temperatura zasilania" + }, + "hc2_setpoint_temperature": { + "name": "Nastawa temperatury HC2" + }, + "hc2_temperature": { + "name": "Temperatura obiegu HC2" + }, + "hc3_setpoint_temperature": { + "name": "Nastawa temperatury HC3" + }, + "hc3_temperature": { + "name": "Temperatura obiegu HC3" + }, + "heat_energy_kwh": { + "name": "Energia cieplna" + }, + "heat_energy_to_house_kwh": { + "name": "Energia cieplna do domu" + }, + "heat_energy_to_installation_kwh": { + "name": "Energia cieplna do instalacji" + }, + "heat_output_best": { + "name": "Moc cieplna" + }, + "heat_output_power": { + "name": "Moc cieplna" + }, + "heater_power_estimated": { + "name": "Moc grzałki (szac.)" + }, + "holiday_days": { + "name": "Dni urlopu" + }, + "inverter_frequency": { + "name": "Częstotliwość inwertera" + }, + "lock_code": { + "name": "Kod blokady" + }, + "lock_text": { + "name": "Blokada" + }, + "operating_mode": { + "name": "Tryb pracy" + }, + "outdoor_temperature": { + "name": "Temperatura zewnętrzna" + }, + "party_hours": { + "name": "Godziny party" + }, + "pv_surplus": { + "name": "Nadwyżka PV" + }, + "return_setpoint_temperature": { + "name": "Nastawa temperatury powrotu" + }, + "return_temperature": { + "name": "Temperatura powrotu" + }, + "room_humidity_1": { + "name": "Wilgotność pomieszczenia 1" + }, + "room_humidity_2": { + "name": "Wilgotność pomieszczenia 2" + }, + "room_temperature_1": { + "name": "Temperatura pomieszczenia 1" + }, + "room_temperature_2": { + "name": "Temperatura pomieszczenia 2" + }, + "runtime_2nd_heat_generator": { + "name": "Czas pracy drugiego źródła ciepła" + }, + "runtime_aux_circulation_pump": { + "name": "Czas pracy pomocniczej pompy cyrkulacyjnej" + }, + "runtime_compressor_1": { + "name": "Czas pracy sprężarki 1" + }, + "runtime_compressor_2": { + "name": "Czas pracy sprężarki 2" + }, + "runtime_dhw_pump": { + "name": "Czas pracy pompy CWU" + }, + "runtime_heating_pump_m13": { + "name": "Czas pracy pompy obiegowej M13" + }, + "runtime_immersion_heater": { + "name": "Czas pracy grzałki zanurzeniowej" + }, + "runtime_pool_pump": { + "name": "Czas pracy pompy basenowej" + }, + "runtime_primary_pump": { + "name": "Czas pracy pompy pierwotnej / wentylatora" + }, + "sensor_error_code": { + "name": "Kod błędu czujnika" + }, + "sensor_error_text": { + "name": "Błąd czujnika" + }, + "sg_ready_code": { + "name": "Kod SG Ready" + }, + "sg_ready_text": { + "name": "Stan SG Ready" + }, + "solar_collector_temperature": { + "name": "Temperatura kolektora słonecznego" + }, + "solar_tank_temperature": { + "name": "Temperatura zasobnika solarnego" + }, + "source_inlet_temperature": { + "name": "Temperatura wlotu dolnego źródła" + }, + "source_outlet_temperature": { + "name": "Temperatura wylotu dolnego źródła" + }, + "status_code": { + "name": "Kod statusu" + }, + "status_text": { + "name": "Status" + }, + "thermal_power_compressor": { + "name": "Moc cieplna sprężarki (szac.)" + }, + "thermal_power_defrost_loss": { + "name": "Strata ciepła na odszranianie (szac.)" + }, + "thermal_power_heater": { + "name": "Moc cieplna grzałki (szac.)" + }, + "thermal_power_loop": { + "name": "Moc cieplna obiegu (szac.)" + }, + "thermal_power_to_house": { + "name": "Ciepło do domu (szac.)" + }, + "thermal_power_to_installation": { + "name": "Ciepło do instalacji (szac.)" + }, + "total_power_estimated": { + "name": "Całkowita moc elektryczna (szac.)" + }, + "vent_exhaust_air_temperature": { + "name": "Temperatura powietrza wyrzucanego" + }, + "vent_extract_air_temperature": { + "name": "Temperatura powietrza wywiewanego" + }, + "vent_extract_fan_speed": { + "name": "Prędkość wentylatora wywiewnego" + }, + "vent_level": { + "name": "Poziom wentylacji" + }, + "vent_outdoor_air_temperature": { + "name": "Temperatura powietrza zewnętrznego wentylacji" + }, + "vent_supply_air_temperature": { + "name": "Temperatura powietrza nawiewanego" + }, + "vent_supply_fan_speed": { + "name": "Prędkość wentylatora nawiewnego" + } + }, + "binary_sensor": { + "external_lockout": { + "name": "Blokada zewnętrzna" + }, + "fault_active": { + "name": "Aktywna usterka" + }, + "lock_active": { + "name": "Aktywna blokada" + }, + "output_2nd_heat_generator": { + "name": "Drugie źródło ciepła" + }, + "output_aux_circulation_pump": { + "name": "Pomocnicza pompa cyrkulacyjna" + }, + "output_compressor_1": { + "name": "Sprężarka 1" + }, + "output_compressor_2": { + "name": "Sprężarka 2" + }, + "output_cooling_pump": { + "name": "Pompa chłodzenia" + }, + "output_dhw_pump": { + "name": "Pompa CWU" + }, + "output_general_fault": { + "name": "Wyjście usterki ogólnej" + }, + "output_heat_cool_changeover": { + "name": "Przełączenie grzanie/chłodzenie" + }, + "output_heating_pump_m13": { + "name": "Pompa obiegowa M13" + }, + "output_heating_pump_m14": { + "name": "Pompa obiegowa M14" + }, + "output_heating_pump_m15": { + "name": "Pompa obiegowa M15" + }, + "output_heating_pump_m20": { + "name": "Pompa obiegowa M20" + }, + "output_immersion_heater": { + "name": "Grzałka zanurzeniowa" + }, + "output_mixer_m21_close": { + "name": "Zawór mieszający M21 zamknięty" + }, + "output_mixer_m21_open": { + "name": "Zawór mieszający M21 otwarty" + }, + "output_mixer_m22_close": { + "name": "Zawór mieszający M22 zamknięty" + }, + "output_mixer_m22_open": { + "name": "Zawór mieszający M22 otwarty" + }, + "output_pool_pump": { + "name": "Pompa basenowa" + }, + "output_primary_cooling_pump": { + "name": "Pierwotna pompa chłodzenia" + }, + "output_primary_pump": { + "name": "Pompa pierwotna / wentylator" + }, + "output_solar_pump": { + "name": "Pompa solarna" + }, + "smartgrid_input_1": { + "name": "Wejście SmartGrid 1" + }, + "smartgrid_input_2": { + "name": "Wejście SmartGrid 2" + }, + "utility_lockout": { + "name": "Blokada zakładu energetycznego (EVU)" + } + }, + "number": { + "cal_alpha_base": { + "name": "Kalibracja: baza alfa" + }, + "cal_alpha_deadband": { + "name": "Kalibracja: strefa nieczułości alfa" + }, + "cal_alpha_sensitivity": { + "name": "Kalibracja: czułość alfa" + }, + "cal_heater_w": { + "name": "Kalibracja: moc grzałki drugiego źródła" + }, + "cal_k_defrost": { + "name": "Kalibracja: współczynnik mocy odszraniania" + }, + "cal_k_defrost_loss": { + "name": "Kalibracja: współczynnik strat ciepła odszraniania" + }, + "cal_k_dhw": { + "name": "Kalibracja: współczynnik mocy CWU" + }, + "cal_pump_floor_w": { + "name": "Kalibracja: moc pompy podłogowej" + }, + "cal_pump_main_w": { + "name": "Kalibracja: moc pompy głównej" + }, + "set_dhw_setpoint": { + "name": "Nastawa CWU" + }, + "set_dhw_setpoint_max": { + "name": "Maksymalna nastawa CWU" + }, + "set_dhw_setpoint_min": { + "name": "Minimalna nastawa CWU" + }, + "set_hc1_curve_end": { + "name": "Koniec krzywej grzewczej HC1" + }, + "set_hc1_curve_offset": { + "name": "Przesunięcie krzywej HC1" + }, + "set_hc1_fixed_flow": { + "name": "Stała nastawa zasilania HC1" + }, + "set_hc1_room_setpoint": { + "name": "Nastawa pomieszczenia HC1" + }, + "set_hc23_cooling_setpoint": { + "name": "Nastawa pomieszczenia chłodzenia HC2/3" + }, + "set_pool_setpoint": { + "name": "Nastawa basenu" + } + }, + "select": { + "set_operating_mode": { + "name": "Tryb pracy" + }, + "sg_ready_mode": { + "name": "Tryb SG Ready" + } + }, + "climate": { + "thermostat": { + "name": "Termostat" + } + } + } +} diff --git a/dashboards/dimplex_wpm.yaml b/dashboards/dimplex_wpm.yaml new file mode 100644 index 0000000..a70924a --- /dev/null +++ b/dashboards/dimplex_wpm.yaml @@ -0,0 +1,403 @@ +# Dimplex WPM — combined dashboard (M3 v1) +# +# Built-in cards only (no HACS frontend dependency). Entity ids follow the +# derived default-naming scheme in spec/ENTITY_IDS.md — if you renamed entities, +# adjust the ids. Import via Settings → Dashboards → New → Edit (raw YAML) and +# paste, or include as a YAML-mode dashboard. Module/meter-specific cards hide +# themselves when their entity is unavailable. +# +# Optional polish (not required): install apexcharts-card for dual-axis power/COP +# plots — see spec/DASHBOARD_DESIGN.md. The History view below uses built-in +# history-graph / statistics-graph instead. +title: Dimplex WPM +views: + # ============================ Overview ============================ + - title: Overview + path: overview + type: sections + max_columns: 3 + sections: + - type: grid + cards: + - type: heading + heading: Dimplex WPM + icon: mdi:heat-pump + - type: glance + show_state: true + entities: + - entity: sensor.dimplex_wpm_status + name: Status + - entity: sensor.dimplex_wpm_outdoor_temperature + name: Outdoor + - entity: sensor.analytics_cop_est + name: COP + - entity: sensor.analytics_electrical_power + name: Power + - entity: sensor.dimplex_wpm_sg_ready_state + name: SG Ready + - type: grid + cards: + - type: heading + heading: Heat pump now + - type: tile + entity: sensor.dimplex_wpm_outdoor_temperature + name: Outdoor + - type: tile + entity: sensor.heating_circuit_1_flow_temperature + name: Flow + - type: tile + entity: sensor.heating_circuit_1_return_temperature + name: Return + - type: tile + entity: sensor.domestic_hot_water_dhw_temperature + name: DHW + - type: tile + entity: sensor.analytics_electrical_power + name: Electrical power + - type: gauge + entity: sensor.analytics_cop_est + name: COP (est.) + min: 0 + max: 6 + needle: true + severity: + green: 3.5 + yellow: 2 + red: 0 + - type: grid + cards: + - type: heading + heading: Operating state + - type: entities + show_header_toggle: false + entities: + - entity: sensor.dimplex_wpm_status + name: Operating status + secondary_info: last-changed + - entity: sensor.dimplex_wpm_lock + name: Lock + - entity: sensor.dimplex_wpm_fault + name: Fault + - entity: sensor.dimplex_wpm_sensor_error + name: Sensor error + - entity: binary_sensor.dimplex_wpm_lock_active + name: Any lock active + - entity: binary_sensor.dimplex_wpm_fault_active + name: Any fault active + - entity: binary_sensor.dimplex_wpm_2nd_heat_generator + name: 2nd heat generator running + - entity: sensor.dimplex_wpm_2nd_heat_generator_runtime + name: 2nd heat generator runtime + - type: entities + title: Smart Grid + entities: + - entity: sensor.dimplex_wpm_sg_ready_state + name: SG Ready state + - entity: binary_sensor.dimplex_wpm_smartgrid_input_1 + name: SG input 1 + - entity: binary_sensor.dimplex_wpm_smartgrid_input_2 + name: SG input 2 + - entity: binary_sensor.dimplex_wpm_utility_evu_lockout + name: Utility (EVU) lockout + + # ========================= Heat & Energy ========================= + - title: Heat & Energy + path: energy + type: sections + max_columns: 2 + sections: + - type: grid + cards: + - type: markdown + content: > + **Source legend** — values from the analytics engine are marked + **(est.)**. With a real meter installed, the corresponding entity + reports measured values (see the `source` attribute). + # --- Optional: apexcharts-card (install from HACS) for dual-axis / stacked + # plots. If you don't install it, delete this section and keep the + # built-in history-graph section below. + - type: grid + cards: + - type: custom:apexcharts-card + header: + title: Power & efficiency (6 h) + show: true + graph_span: 6h + yaxis: + - id: kw + decimals: 2 + - id: cop + opposite: true + min: 0 + max: 6 + series: + - entity: sensor.analytics_electrical_power + name: Electrical power + yaxis_id: kw + - entity: sensor.analytics_loop_heat_output_est + name: Heat output (est.) + yaxis_id: kw + - entity: sensor.analytics_cop_est + name: COP (est.) + yaxis_id: cop + stroke_width: 1 + - type: custom:apexcharts-card + stacked: true + header: + title: Thermal balance (6 h) + show: true + graph_span: 6h + series: + - entity: sensor.analytics_compressor_heat_output_est + name: Compressor + type: area + - entity: sensor.analytics_heater_heat_output_est + name: Heater + type: area + - entity: sensor.analytics_defrost_heat_loss_est + name: Defrost loss + type: area + # --- Built-in fallback (no HACS frontend dependency) --- + - type: grid + cards: + - type: heading + heading: Power & heat (6 h) + - type: history-graph + hours_to_show: 6 + entities: + - entity: sensor.analytics_electrical_power + - entity: sensor.analytics_heat_output + - entity: sensor.analytics_compressor_heat_output_est + - entity: sensor.analytics_heater_heat_output_est + - entity: sensor.analytics_defrost_heat_loss_est + - entity: sensor.analytics_loop_heat_output_est + - type: history-graph + hours_to_show: 6 + entities: + - entity: sensor.analytics_cop_est + - entity: sensor.analytics_cop_measured + - type: grid + cards: + - type: heading + heading: House / installation split + - type: history-graph + hours_to_show: 6 + entities: + - entity: sensor.analytics_heat_to_house_est + - entity: sensor.analytics_heat_to_installation_est + - entity: sensor.analytics_house_heat_fraction + - type: heading + heading: Flow & ΔT + - type: history-graph + hours_to_show: 6 + entities: + - entity: sensor.analytics_flow_rate + - entity: sensor.analytics_flow_rate_smoothed + - entity: sensor.analytics_temperature_difference + - type: grid + cards: + - type: heading + heading: Energy + - type: statistics-graph + period: day + days_to_show: 30 + stat_types: + - change + chart_type: bar + entities: + - entity: sensor.analytics_electrical_energy + - entity: sensor.analytics_heat_energy_to_house + - type: entities + title: Energy totals + entities: + - entity: sensor.analytics_electrical_energy + - entity: sensor.analytics_heat_energy + - entity: sensor.analytics_heat_energy_to_house + - entity: sensor.analytics_heat_energy_to_installation + - entity: sensor.analytics_heating_energy_meter + visibility: + - condition: state + entity: sensor.analytics_heating_energy_meter + state_not: unavailable + - type: markdown + content: > + Add **one** electrical-energy entity to the Energy Dashboard + (Settings → Dashboards → Energy) — the total is the safe default. + The heat-delivered (thermal) sensors are **not** electricity; do + not add them as grid consumption. + + # =========================== History ============================= + - title: History + path: history + type: sections + max_columns: 2 + sections: + - type: grid + cards: + - type: heading + heading: Temperatures (24 h) + - type: history-graph + hours_to_show: 24 + entities: + - entity: sensor.dimplex_wpm_outdoor_temperature + - entity: sensor.heating_circuit_1_flow_temperature + - entity: sensor.heating_circuit_1_return_temperature + - entity: sensor.heating_circuit_1_return_setpoint_temperature + - entity: sensor.domestic_hot_water_dhw_temperature + - entity: sensor.heat_source_source_inlet_temperature + - entity: sensor.heat_source_source_outlet_temperature + - type: grid + cards: + - type: heading + heading: Activity (24 h) + - type: history-graph + hours_to_show: 24 + entities: + - entity: sensor.dimplex_wpm_status + - entity: sensor.dimplex_wpm_inverter_frequency + - entity: binary_sensor.dimplex_wpm_compressor_1 + - entity: binary_sensor.heating_circuit_1_heating_pump_m13 + - entity: binary_sensor.domestic_hot_water_dhw_pump + - entity: binary_sensor.dimplex_wpm_2nd_heat_generator + - type: grid + cards: + - type: heading + heading: Long-term trends (30 d) + - type: statistics-graph + period: day + days_to_show: 30 + stat_types: + - mean + entities: + - entity: sensor.analytics_cop_est + - entity: sensor.dimplex_wpm_outdoor_temperature + + # ===================== Control (gated) =========================== + - title: Control + path: control + type: sections + max_columns: 2 + sections: + - type: grid + cards: + - type: markdown + content: > + ⚠️ **These controls write to the heat-pump controller.** Values are + range-validated, but changes affect a live system. Setpoint + register scaling is assumed whole-°C and should be verified on your + device. Controls appear only when **Enable control / write + entities** is on in the integration options. + - type: grid + cards: + - type: heading + heading: Thermostats + - type: thermostat + entity: climate.heating_circuit_1_thermostat + visibility: + - condition: state + entity: climate.heating_circuit_1_thermostat + state_not: unavailable + - type: thermostat + entity: climate.domestic_hot_water_thermostat + visibility: + - condition: state + entity: climate.domestic_hot_water_thermostat + state_not: unavailable + - type: grid + cards: + - type: heading + heading: Domestic hot water + - type: tile + entity: number.domestic_hot_water_dhw_setpoint + name: DHW setpoint + features: + - type: numeric-input + style: slider + visibility: + - condition: state + entity: number.domestic_hot_water_dhw_setpoint + state_not: unavailable + - type: heading + heading: Heating circuit 1 + - type: entities + entities: + - entity: number.heating_circuit_1_hc1_room_setpoint + - entity: number.heating_circuit_1_hc1_fixed_flow_setpoint + - entity: number.heating_circuit_1_hc1_heating_curve_end + - entity: number.heating_circuit_1_hc1_curve_offset + visibility: + - condition: state + entity: number.heating_circuit_1_hc1_room_setpoint + state_not: unavailable + - type: grid + cards: + - type: heading + heading: Modes + - type: entities + entities: + - entity: select.dimplex_wpm_sg_ready_mode + - entity: select.dimplex_wpm_operating_mode + visibility: + - condition: state + entity: select.dimplex_wpm_sg_ready_mode + state_not: unavailable + + # ================ Diagnostics & Calibration ====================== + - title: Diagnostics + path: diagnostics + type: sections + max_columns: 2 + sections: + - type: grid + cards: + - type: heading + heading: Estimation calibration + - type: entities + entities: + - entity: number.analytics_calibration_dhw_power_factor + - entity: number.analytics_calibration_defrost_power_factor + - entity: number.analytics_calibration_defrost_heat_loss_factor + - entity: number.analytics_calibration_2nd_source_heater_power + - entity: number.analytics_calibration_main_pump_power + - entity: number.analytics_calibration_floor_pump_power + - entity: number.analytics_calibration_alpha_base + - entity: number.analytics_calibration_alpha_sensitivity + - entity: number.analytics_calibration_alpha_deadband + - type: grid + cards: + - type: heading + heading: Estimation cross-check + - type: entities + entities: + - entity: sensor.dimplex_wpm_inverter_frequency + - entity: sensor.analytics_compressor_power_est + - entity: sensor.analytics_cop_est + - entity: sensor.analytics_temperature_difference + - entity: sensor.analytics_flow_rate + - type: grid + cards: + - type: heading + heading: Controller diagnostics + - type: entities + entities: + - entity: sensor.dimplex_wpm_controller_info + - entity: sensor.dimplex_wpm_status_code + - entity: sensor.dimplex_wpm_lock_code + - entity: sensor.dimplex_wpm_fault_code + - entity: sensor.dimplex_wpm_sensor_error_code + - entity: sensor.dimplex_wpm_compressor_1_runtime + - entity: sensor.dimplex_wpm_compressor_2_runtime + - entity: sensor.dimplex_wpm_primary_pump_fan_runtime + - type: grid + cards: + - type: heading + heading: Outputs + - type: entities + entities: + - entity: binary_sensor.dimplex_wpm_compressor_1 + - entity: binary_sensor.dimplex_wpm_compressor_2 + - entity: binary_sensor.dimplex_wpm_primary_pump_fan + - entity: binary_sensor.dimplex_wpm_2nd_heat_generator + - entity: binary_sensor.heating_circuit_1_heating_pump_m13 + - entity: binary_sensor.domestic_hot_water_dhw_pump + - entity: binary_sensor.dimplex_wpm_immersion_heater diff --git a/existing integration/dashboards/dashboard 1 b/existing integration/dashboards/dashboard 1 new file mode 100644 index 0000000..9148de2 --- /dev/null +++ b/existing integration/dashboards/dashboard 1 @@ -0,0 +1,75 @@ +title: Dimplex +path: dimplex +icon: mdi:heat-pump +cards: + - type: history-graph + title: Dimplex – status (6h) + hours_to_show: 6 + refresh_interval: 60 + entities: + - entity: sensor.dimplex_status_text + - entity: sensor.dimplex_lock_text + - entity: sensor.dimplex_fault_text + - entity: binary_sensor.dimplex_output_2nd_heat_generator + - entity: sensor.dimplex_sg_ready_state_inputs + name: SG Ready (inputs 3/4) + - type: entities + title: Dimplex – status + show_header_toggle: false + entities: + - entity: sensor.dimplex_outdoor_temperature + name: Outdoor Temperature + icon: mdi:home-thermometer + - entity: sensor.dimplex_flow_temperature + name: Flow Temperature + icon: mdi:thermometer + - entity: sensor.dimplex_return_setpoint_temperature + name: Return set temperature + icon: mdi:thermometer-check + - entity: sensor.dimplex_return_temperature + name: Return temperature + icon: mdi:thermometer + - entity: sensor.dimplex_dhw_temperature + icon: mdi:water-thermometer + name: Hot water temperature + - entity: sensor.dimplex_status_text + name: Operating status + secondary_info: last-changed + - entity: sensor.dimplex_lock_text + name: Lock status + secondary_info: last-updated + - entity: sensor.dimplex_fault_text + name: Fault status + secondary_info: last-updated + - entity: binary_sensor.dimplex_output_2nd_heat_generator + secondary_info: last-updated + - entity: sensor.dimplex_runtime_2nd_heat_generator + - entity: binary_sensor.dimplex_any_lock_active + name: Any lock active + - entity: binary_sensor.dimplex_any_fault_active + name: Any fault active + - entity: sensor.dimplex_sg_ready_mode_text + name: SG Ready mode + - entity: input_select.sg_ready_mode + name: SG Ready mode (set) + - entity: binary_sensor.dimplex_smartgrid_input_1 + name: SG Line 1 + - entity: binary_sensor.dimplex_smartgrid_input_2 + name: SG Line 2 + - entity: sensor.dimplex_sg_ready_state_inputs + name: SG Ready state (inputs 3/4) + - type: history-graph + title: Dimplex – temperatures 6h + refresh_interval: 60 + entities: + - entity: sensor.dimplex_outdoor_temperature + name: Outdoor + - entity: sensor.dimplex_return_setpoint_temperature + name: Return setpoint + - entity: sensor.dimplex_return_temperature + name: Return + - entity: sensor.dimplex_flow_temperature + name: Flow + - entity: sensor.dimplex_dhw_temperature + name: DHW + hours_to_show: 6 diff --git a/existing integration/dashboards/dashboard 2 b/existing integration/dashboards/dashboard 2 new file mode 100644 index 0000000..07e6562 --- /dev/null +++ b/existing integration/dashboards/dashboard 2 @@ -0,0 +1,33 @@ +type: sections +max_columns: 4 +title: Energy +path: energy +sections: + - type: grid + cards: + - type: history-graph + entities: + - entity: sensor.lak9_thermal_power_loop + - entity: sensor.lak9_compressor_power_estimated + - entity: sensor.lak9_thermal_power_compressor + - entity: sensor.lak9_thermal_power_heater + - entity: sensor.lak9_thermal_power_defrost_loss + - entity: sensor.lak9_thermal_power_loop + title: Heat production 6h + hours_to_show: 6 + grid_options: + columns: full + - type: history-graph + entities: + - entity: sensor.lak9_cop_en14511 + - entity: sensor.lak9_deltat + - entity: sensor.lak9_estimated_flow + - entity: sensor.lak9_estimated_flow_smoothed + - entity: sensor.aquaro_sensor_82464185810637_water_flow + grid_options: + columns: full + hours_to_show: 6 + - type: heading + heading: New section + column_span: 4 +cards: [] diff --git a/existing integration/dashboards/dashboard 3 b/existing integration/dashboards/dashboard 3 new file mode 100644 index 0000000..115a912 --- /dev/null +++ b/existing integration/dashboards/dashboard 3 @@ -0,0 +1,45 @@ +type: sections +max_columns: 4 +title: History +path: history +sections: + - type: grid + cards: + - type: heading + heading: New section + - title: History + type: history-graph + hours_to_show: 24 + entities: + - entity: sensor.dimplex_outdoor_temperature + - entity: sensor.dimplex_return_temperature + - entity: sensor.dimplex_flow_temperature + - entity: sensor.dimplex_dhw_temperature + - entity: sensor.dimplex_status_text + - entity: sensor.dimplex_inverter_frequency + - entity: binary_sensor.dimplex_output_2nd_heat_generator + - entity: sensor.dimplex_sg_ready_state_inputs + - entity: sensor.dimplex_return_setpoint_temperature + - entity: binary_sensor.dimplex_output_heating_pump_m13 + - entity: binary_sensor.dimplex_output_compressor_1 + - entity: binary_sensor.dimplex_output_additional_circulation_pump + - entity: binary_sensor.dimplex_output_dhw_pump + - entity: sensor.dimplex_dhw_setpoint_temperature + - entity: binary_sensor.dimplex_output_heating_pump_m15 + - entity: binary_sensor.dimplex_output_heating_pump_m14 + - entity: sensor.dimplex_status_messages + - entity: sensor.lak9_total_power_estimated_kw + - entity: sensor.lak9_compressor_power_estimated_kw + - entity: sensor.lak9_heater_power_estimated_kw + - entity: sensor.lak9_estimated_flow + - entity: sensor.lak9_estimated_flow_smoothed + - entity: sensor.dimplex_lock_text + - entity: sensor.dimplex_fault_text + - entity: sensor.dimplex_lock_messages + - entity: sensor.dimplex_fault_messages + - entity: sensor.aquaro_sensor_82464185810637_water_flow + grid_options: + columns: full + rows: auto + column_span: 4 +cards: [] diff --git a/existing integration/dashboards/dashboard 4 b/existing integration/dashboards/dashboard 4 new file mode 100644 index 0000000..8c4ee39 --- /dev/null +++ b/existing integration/dashboards/dashboard 4 @@ -0,0 +1,23 @@ +type: sections +max_columns: 4 +title: Parameters +path: parameters +sections: + - type: grid + cards: + - type: entities + entities: + - entity: input_number.lak9_defrost_heat_factor + - entity: input_number.lak9_alpha_base + - entity: input_number.lak9_alpha_sensitivity + - entity: input_number.lak9_alpha_deadband + - entity: input_boolean.lak9_enable_house_split + - entity: input_number.dimplex_heater_2nd_power_w + - entity: input_number.dimplex_pump_floor_power_w + - entity: input_boolean.dimplex_include_pumps_in_total + - entity: input_number.dimplex_k_defrost + - entity: input_number.dimplex_k_dhw + - entity: input_number.dimplex_pump_main_power_w + - type: heading + heading: New section +cards: [] diff --git a/existing integration/dimplex_lak9 (2).yaml b/existing integration/dimplex_lak9 (2).yaml new file mode 100644 index 0000000..3b786ae --- /dev/null +++ b/existing integration/dimplex_lak9 (2).yaml @@ -0,0 +1,997 @@ +modbus: + - name: dimplex_lak9 + type: tcp + host: 192.168.1.103 # IP of WPM / Dimplex module + port: 502 + delay: 2 + timeout: 3 + + sensors: + + # ===== System status (bit / enum codes) ===== + - name: "Dimplex Status Messages" + address: 103 + input_type: input + data_type: uint16 + unit_of_measurement: "" + scan_interval: 10 + unique_id: dimplex_status_messages + + - name: "Dimplex Lock Messages" + address: 104 + input_type: input + data_type: uint16 + unit_of_measurement: "" + scan_interval: 10 + + - name: "Dimplex Fault Messages" + address: 105 + input_type: input + data_type: uint16 + unit_of_measurement: "" + scan_interval: 10 + + - name: "Dimplex Sensor Errors" + address: 106 + input_type: input + data_type: uint16 + unit_of_measurement: "" + scan_interval: 10 + + - name: "Dimplex Inverter Frequency" + address: 114 + input_type: input + data_type: uint16 + unit_of_measurement: "Hz" + scale: 0.1 + precision: 1 + scan_interval: 60 + unique_id: dimplex_inverter_frequency + + # ===== Operating mode / party / holiday / ventilation ===== + - name: "Dimplex Operating Mode" + address: 5015 + input_type: input + data_type: uint16 + unit_of_measurement: "" + scan_interval: 10 + + - name: "Dimplex Party Mode Hours" + address: 5016 + input_type: input + data_type: uint16 + unit_of_measurement: "h" + scan_interval: 30 + + - name: "Dimplex Holiday Days" + address: 5017 + input_type: input + data_type: uint16 + unit_of_measurement: "d" + scan_interval: 30 + + - name: "Dimplex Ventilation Level" + address: 5034 + input_type: input + data_type: uint16 + unit_of_measurement: "" + scan_interval: 30 + + - name: "Dimplex Ventilation Boost Time" + address: 127 + input_type: input + data_type: uint16 + unit_of_measurement: "min" + scan_interval: 60 + + # ===== Basic temperatures and humidity ===== + - name: "Dimplex Outdoor Temperature" + address: 1 + input_type: input + data_type: int16 + unit_of_measurement: "°C" + scale: 0.1 + precision: 1 + scan_interval: 30 + unique_id: dimplex_outdoor_temp + + - name: "Dimplex Return Temperature" + address: 2 + input_type: input + data_type: int16 + unit_of_measurement: "°C" + scale: 0.1 + precision: 1 + scan_interval: 30 + unique_id: dimplex_return_temp + + - name: "Dimplex Return Setpoint Temperature" + address: 53 + input_type: input + data_type: int16 + unit_of_measurement: "°C" + scale: 0.1 + precision: 1 + scan_interval: 30 + + - name: "Dimplex DHW Temperature" + address: 3 + input_type: input + data_type: int16 + unit_of_measurement: "°C" + scale: 0.1 + precision: 1 + scan_interval: 30 + + - name: "Dimplex DHW Setpoint Temperature" + address: 58 + input_type: input + data_type: int16 + unit_of_measurement: "°C" + scale: 0.1 + precision: 1 + scan_interval: 30 + + - name: "Dimplex Flow Temperature" + address: 5 + input_type: input + data_type: int16 + unit_of_measurement: "°C" + scale: 0.1 + precision: 1 + scan_interval: 30 + unique_id: dimplex_flow_temp + + - name: "Dimplex Source Inlet Temperature" + address: 6 + input_type: input + data_type: int16 + unit_of_measurement: "°C" + scale: 0.1 + precision: 1 + scan_interval: 60 + + - name: "Dimplex Source Outlet Temperature" + address: 7 + input_type: input + data_type: int16 + unit_of_measurement: "°C" + scale: 0.1 + precision: 1 + scan_interval: 60 + + - name: "Dimplex HC2 Return Setpoint" + address: 54 + input_type: input + data_type: int16 + unit_of_measurement: "°C" + scale: 0.1 + precision: 1 + scan_interval: 30 + + - name: "Dimplex HC2 Return Temperature" + address: 9 + input_type: input + data_type: int16 + unit_of_measurement: "°C" + scale: 0.1 + precision: 1 + scan_interval: 30 + + - name: "Dimplex HC3 Return Setpoint" + address: 55 + input_type: input + data_type: int16 + unit_of_measurement: "°C" + scale: 0.1 + precision: 1 + scan_interval: 30 + + - name: "Dimplex HC3 Return Temperature" + address: 10 + input_type: input + data_type: int16 + unit_of_measurement: "°C" + scale: 0.1 + precision: 1 + scan_interval: 30 + + - name: "Dimplex Room Temperature 1" + address: 11 + input_type: input + data_type: int16 + unit_of_measurement: "°C" + scale: 0.1 + precision: 1 + scan_interval: 30 + + - name: "Dimplex Room Temperature 2" + address: 12 + input_type: input + data_type: int16 + unit_of_measurement: "°C" + scale: 0.1 + precision: 1 + scan_interval: 30 + + - name: "Dimplex Room Humidity 1" + address: 13 + input_type: input + data_type: int16 + unit_of_measurement: "%" + scale: 0.1 + precision: 1 + scan_interval: 60 + + - name: "Dimplex Room Humidity 2" + address: 14 + input_type: input + data_type: int16 + unit_of_measurement: "%" + scale: 0.1 + precision: 1 + scan_interval: 60 + + - name: "Dimplex Passive Cooling Flow Temperature" + address: 19 + input_type: input + data_type: int16 + unit_of_measurement: "°C" + scale: 0.1 + precision: 1 + scan_interval: 60 + + - name: "Dimplex Passive Cooling Return Temperature" + address: 20 + input_type: input + data_type: int16 + unit_of_measurement: "°C" + scale: 0.1 + precision: 1 + scan_interval: 60 + + - name: "Dimplex PassiveActive Primary Return Temperature" + address: 21 + input_type: input + data_type: int16 + unit_of_measurement: "°C" + scale: 0.1 + precision: 1 + scan_interval: 60 + + - name: "Dimplex Solar Collector Temperature" + address: 10 + input_type: input + data_type: int16 + unit_of_measurement: "°C" + scale: 0.1 + precision: 1 + scan_interval: 60 + + - name: "Dimplex Solar Tank Temperature" + address: 23 + input_type: input + data_type: int16 + unit_of_measurement: "°C" + scale: 0.1 + precision: 1 + scan_interval: 60 + + # Ventilation temperatures & speeds + - name: "Dimplex Ventilation Outdoor Air Temperature" + address: 120 + input_type: input + data_type: int16 + unit_of_measurement: "°C" + scale: 0.1 + precision: 1 + scan_interval: 60 + + - name: "Dimplex Ventilation Supply Air Temperature" + address: 121 + input_type: input + data_type: int16 + unit_of_measurement: "°C" + scale: 0.1 + precision: 1 + scan_interval: 60 + + - name: "Dimplex Ventilation Extract Air Temperature" + address: 122 + input_type: input + data_type: int16 + unit_of_measurement: "°C" + scale: 0.1 + precision: 1 + scan_interval: 60 + + - name: "Dimplex Ventilation Exhaust Air Temperature" + address: 123 + input_type: input + data_type: int16 + unit_of_measurement: "°C" + scale: 0.1 + precision: 1 + scan_interval: 60 + + - name: "Dimplex Ventilation Supply Fan Speed" + address: 125 + input_type: input + data_type: int16 + unit_of_measurement: "1/min" + scan_interval: 60 + + - name: "Dimplex Ventilation Extract Fan Speed" + address: 126 + input_type: input + data_type: int16 + unit_of_measurement: "1/min" + scan_interval: 60 + + # ===== Runtimes (hours) ===== + - name: "Dimplex Runtime Compressor 1" + address: 72 + input_type: input + data_type: uint16 + unit_of_measurement: "h" + state_class: total_increasing + scan_interval: 300 + + - name: "Dimplex Runtime Compressor 2" + address: 73 + input_type: input + data_type: uint16 + unit_of_measurement: "h" + state_class: total_increasing + scan_interval: 300 + + - name: "Dimplex Runtime Primary Pump_Fan" + address: 74 + input_type: input + data_type: uint16 + unit_of_measurement: "h" + state_class: total_increasing + scan_interval: 300 + + - name: "Dimplex Runtime 2nd Heat Generator" + address: 75 + input_type: input + data_type: uint16 + unit_of_measurement: "h" + state_class: total_increasing + scan_interval: 300 + + - name: "Dimplex Runtime Heating Pump M13" + address: 76 + input_type: input + data_type: uint16 + unit_of_measurement: "h" + state_class: total_increasing + scan_interval: 300 + + - name: "Dimplex Runtime DHW Pump" + address: 77 + input_type: input + data_type: uint16 + unit_of_measurement: "h" + state_class: total_increasing + scan_interval: 300 + + - name: "Dimplex Runtime Immersion Heater" + address: 78 + input_type: input + data_type: uint16 + unit_of_measurement: "h" + state_class: total_increasing + scan_interval: 300 + + - name: "Dimplex Runtime Pool Pump" + address: 79 + input_type: input + data_type: uint16 + unit_of_measurement: "h" + state_class: total_increasing + scan_interval: 300 + + - name: "Dimplex Runtime Additional Circulation Pump" + address: 71 + input_type: input + data_type: uint16 + unit_of_measurement: "h" + state_class: total_increasing + scan_interval: 300 + + # ===== Heat & energy quantities (raw) ===== + - name: "Dimplex Heating Energy 1_4" + address: 5096 + input_type: input + data_type: uint16 + unit_of_measurement: "kWh" + state_class: total_increasing + scan_interval: 600 + + - name: "Dimplex Heating Energy 5_8" + address: 5097 + input_type: input + data_type: uint16 + unit_of_measurement: "kWh" + state_class: total_increasing + scan_interval: 600 + + - name: "Dimplex Heating Energy 9_12" + address: 5098 + input_type: input + data_type: uint16 + unit_of_measurement: "kWh" + state_class: total_increasing + scan_interval: 600 + + - name: "Dimplex DHW Energy 1_4" + address: 5099 + input_type: input + data_type: uint16 + unit_of_measurement: "kWh" + state_class: total_increasing + scan_interval: 600 + + - name: "Dimplex DHW Energy 5_8" + address: 5100 + input_type: input + data_type: uint16 + unit_of_measurement: "kWh" + state_class: total_increasing + scan_interval: 600 + + - name: "Dimplex DHW Energy 9_12" + address: 5101 + input_type: input + data_type: uint16 + unit_of_measurement: "kWh" + state_class: total_increasing + scan_interval: 600 + + - name: "Dimplex Pool Energy 1_4" + address: 5102 + input_type: input + data_type: uint16 + unit_of_measurement: "kWh" + state_class: total_increasing + scan_interval: 600 + + - name: "Dimplex Pool Energy 5_8" + address: 5103 + input_type: input + data_type: uint16 + unit_of_measurement: "kWh" + state_class: total_increasing + scan_interval: 600 + + - name: "Dimplex Pool Energy 9_12" + address: 5104 + input_type: input + data_type: uint16 + unit_of_measurement: "kWh" + state_class: total_increasing + scan_interval: 600 + + - name: "Dimplex Environmental Energy 1_4" + address: 5127 + input_type: input + data_type: uint16 + unit_of_measurement: "kWh" + state_class: total_increasing + scan_interval: 600 + + - name: "Dimplex Environmental Energy 5_8" + address: 5128 + input_type: input + data_type: uint16 + unit_of_measurement: "kWh" + state_class: total_increasing + scan_interval: 600 + + - name: "Dimplex Environmental Energy 9_12" + address: 5129 + input_type: input + data_type: uint16 + unit_of_measurement: "kWh" + state_class: total_increasing + scan_interval: 600 + + # ===== HC1 settings ===== + - name: "Dimplex HC1 Curve Offset" + address: 5036 + input_type: input + data_type: uint16 + unit_of_measurement: "" + scan_interval: 120 + + - name: "Dimplex HC1 Room Temperature Setpoint" + address: 46 + input_type: input + data_type: uint16 + unit_of_measurement: "°C" + scan_interval: 120 + + - name: "Dimplex HC1 Fixed Flow Setpoint" + address: 5037 + input_type: input + data_type: uint16 + unit_of_measurement: "°C" + scan_interval: 120 + + - name: "Dimplex HC1 Heating Curve End" + address: 5038 + input_type: input + data_type: uint16 + unit_of_measurement: "°C" + scan_interval: 120 + + - name: "Dimplex HC1 Hysteresis" + address: 47 + input_type: input + data_type: uint16 + unit_of_measurement: "K" + scan_interval: 120 + + - name: "Dimplex HC1 Cooling Room Setpoint" + address: 5043 + input_type: input + data_type: uint16 + unit_of_measurement: "°C" + scan_interval: 120 + + - name: "Dimplex HC1 Cooling Room Setpoint 35AT" + address: 5134 + input_type: input + data_type: uint16 + unit_of_measurement: "°C" + scan_interval: 120 + + # ===== HC2/3 settings ===== + - name: "Dimplex HC2_3 Circuit Selection" + address: 5082 + input_type: input + data_type: uint16 + unit_of_measurement: "" + scan_interval: 120 + + - name: "Dimplex HC2_3 Heating Curve End" + address: 5084 + input_type: input + data_type: uint16 + unit_of_measurement: "°C" + scan_interval: 120 + + - name: "Dimplex HC2_3 Fixed Temperature" + address: 5085 + input_type: input + data_type: uint16 + unit_of_measurement: "°C" + scan_interval: 120 + + - name: "Dimplex HC2_3 Curve Offset" + address: 5086 + input_type: input + data_type: uint16 + unit_of_measurement: "" + scan_interval: 120 + + - name: "Dimplex HC2_3 Mixer Run Time" + address: 5087 + input_type: input + data_type: uint16 + unit_of_measurement: "min" + scan_interval: 120 + + - name: "Dimplex HC2_3 Mixer Hysteresis" + address: 93 + input_type: input + data_type: uint16 + unit_of_measurement: "K" + scan_interval: 120 + + - name: "Dimplex HC2_3 Maximum Temperature" + address: 5088 + input_type: input + data_type: uint16 + unit_of_measurement: "°C" + scan_interval: 120 + + - name: "Dimplex HC2_3 Cooling Room Setpoint" + address: 5089 + input_type: input + data_type: uint16 + unit_of_measurement: "°C" + scan_interval: 120 + + # ===== DHW settings ===== + - name: "Dimplex DHW Hysteresis" + address: 5045 + input_type: input + data_type: uint16 + unit_of_measurement: "K" + scan_interval: 120 + + - name: "Dimplex DHW Setpoint" + address: 5047 + input_type: input + data_type: uint16 + unit_of_measurement: "°C" + scan_interval: 120 + + - name: "Dimplex DHW Setpoint Minimum" + address: 5145 + input_type: input + data_type: uint16 + unit_of_measurement: "°C" + scan_interval: 120 + + - name: "Dimplex DHW Setpoint Maximum" + address: 5048 + input_type: input + data_type: uint16 + unit_of_measurement: "°C" + scan_interval: 120 + + # ===== Pool settings ===== + - name: "Dimplex Pool Hysteresis" + address: 5049 + input_type: input + data_type: uint16 + unit_of_measurement: "K" + scan_interval: 120 + + - name: "Dimplex Pool Setpoint" + address: 5051 + input_type: input + data_type: uint16 + unit_of_measurement: "°C" + scan_interval: 120 + + # ===== 2nd heat generator settings ===== + - name: "Dimplex 2nd Heat Generator Mixer Hysteresis" + address: 48 + input_type: input + data_type: uint16 + unit_of_measurement: "K" + scan_interval: 120 + + - name: "Dimplex 2nd Heat Generator Parallel Limit Temp" + address: 5020 + input_type: input + data_type: uint16 + unit_of_measurement: "°C" + scan_interval: 120 + + - name: "Dimplex 2nd Heat Generator Mixer Run Time" + address: 5021 + input_type: input + data_type: uint16 + unit_of_measurement: "min" + scan_interval: 120 + + # ===== Smart Grid / SG Ready – mode register (R/W) ===== + - name: "Dimplex SG Ready Mode" + address: 5167 + input_type: holding # ważne: holding, bo rejestr jest zapisywalny + data_type: uint16 + unit_of_measurement: "" + scan_interval: 30 + + # ===== Energy management – powers (from M 3.5) ===== + - name: "Dimplex Heating Power" + address: 5168 + input_type: input # R only + data_type: uint16 + # Raw value is W/10 => 1 unit = 10 W + # kW = value * 10 / 1000 = value * 0.01 + unit_of_measurement: "kW" + scale: 0.01 + precision: 2 + scan_interval: 30 + + - name: "Dimplex Electrical Power" + address: 5170 + input_type: input + data_type: uint16 + unit_of_measurement: "kW" + scale: 0.01 + precision: 2 + scan_interval: 30 + + - name: "Dimplex PV Surplus" + address: 5182 + input_type: holding # R/W + data_type: uint16 + unit_of_measurement: "kW" + scale: 0.01 + precision: 2 + scan_interval: 30 + + binary_sensors: + + # ===== Digital inputs ===== + - name: "Dimplex SmartGrid Input 1" + address: 3 + input_type: coil + scan_interval: 5 + + - name: "Dimplex SmartGrid Input 2" + address: 4 + input_type: coil + scan_interval: 5 + + - name: "Dimplex Utility Lockout" + address: 5 + input_type: coil + scan_interval: 5 + + - name: "Dimplex External Lockout" + address: 6 + input_type: coil + scan_interval: 5 + + # ===== Digital outputs (actuators) ===== + - name: "Dimplex Output Compressor 1" + address: 41 + input_type: coil + scan_interval: 5 + + - name: "Dimplex Output Compressor 2" + address: 42 + input_type: coil + scan_interval: 5 + + - name: "Dimplex Output Primary Pump_Fan" + address: 43 + input_type: coil + scan_interval: 5 + + - name: "Dimplex Output 2nd Heat Generator" + address: 44 + input_type: coil + scan_interval: 5 + unique_id: dimplex_output_2nd_heat_generator + + - name: "Dimplex Output Heating Pump M13" + address: 45 + input_type: coil + scan_interval: 5 + + - name: "Dimplex Output DHW Pump" + address: 46 + input_type: coil + scan_interval: 5 + + - name: "Dimplex Output Mixer M21 Open" + address: 47 + input_type: coil + scan_interval: 5 + + - name: "Dimplex Output Mixer M21 Close" + address: 48 + input_type: coil + scan_interval: 5 + + - name: "Dimplex Output Additional Circulation Pump" + address: 49 + input_type: coil + scan_interval: 5 + + - name: "Dimplex Output Immersion Heater" + address: 50 + input_type: coil + scan_interval: 5 + + - name: "Dimplex Output Heating Pump M15" + address: 51 + input_type: coil + scan_interval: 5 + + - name: "Dimplex Output Mixer M22 Open" + address: 52 + input_type: coil + scan_interval: 5 + + - name: "Dimplex Output Mixer M22 Close" + address: 53 + input_type: coil + scan_interval: 5 + + - name: "Dimplex Output Pool Pump" + address: 56 + input_type: coil + scan_interval: 5 + + - name: "Dimplex Output General Fault" + address: 57 + input_type: coil + scan_interval: 5 + + - name: "Dimplex Output Heating Pump M14" + address: 59 + input_type: coil + scan_interval: 5 + + - name: "Dimplex Output Cooling Pump" + address: 60 + input_type: coil + scan_interval: 5 + + - name: "Dimplex Output Heating Pump M20" + address: 61 + input_type: coil + scan_interval: 5 + + - name: "Dimplex Output Heat_Cool Changeover" + address: 66 + input_type: coil + scan_interval: 5 + + - name: "Dimplex Output Primary Cooling Pump" + address: 68 + input_type: coil + scan_interval: 5 + + - name: "Dimplex Output Solar Pump" + address: 71 + input_type: coil + scan_interval: 5 + +template: + - sensor: + # ---------- Text mapping for status codes ---------- + - name: "Dimplex Status Text" + unique_id: "dimplex_status_text" + icon: mdi:information + state: > + {% set code = states('sensor.dimplex_status_messages') | int(-1) %} + {% set status_map = { + 0: 'Idle', + 1: 'Off', + 2: 'Heating', + 4: 'Domestic hot water', + 10: 'Defrost', + 11: 'Flow monitoring', + 30: 'Lock' + } %} + {{ status_map.get(code, 'Unknown status (code %s)' | format(code)) }} + + - name: "Dimplex Lock Text" + unique_id: "dimplex_lock_text" + icon: mdi:lock-alert + state: > + {% set code = states('sensor.dimplex_lock_messages') | int(0) %} + {% set lock_map = { + 0: 'No lock', + 7: 'System check', + 9: 'Pump pre-run', + 10: 'Minimum standstill time', + 11: 'Grid load limitation', + 12: 'Anti short-cycling lock', + 15: 'Utility (EVU) lock', + 17: 'Flow lock', + 35: 'Fault lock' + } %} + {{ lock_map.get(code, 'Unknown lock (code %s)' | format(code)) }} + + - name: "Dimplex Fault Text" + unique_id: "dimplex_fault_text" + icon: mdi:alert-circle + state: > + {% set code = states('sensor.dimplex_fault_messages') | int(0) %} + {% set fault_map = { + 0: 'No fault', + 6: 'Electronic expansion valve fault', + 15: 'Sensor fault', + 19: 'Primary circuit fault', + 22: 'DHW fault', + 23: 'Compressor load fault', + 24: 'Configuration / coding fault', + 25: 'Low pressure fault', + 26: 'Frost protection fault', + 29: 'Temperature difference fault', + 31: 'Flow fault' + } %} + {{ fault_map.get(code, 'Unknown fault (code %s)' | format(code)) }} + + # ---------- SG Ready mode as text ---------- + - name: "Dimplex SG Ready Mode Text" + unique_id: "dimplex_sg_ready_mode_text" + icon: mdi:solar-power + state: > + {% set code = states('sensor.dimplex_sg_ready_mode') | int(0) %} + {% set sg_map = { + 0: 'Hardware', + 10: 'Yellow', + 11: 'Green', + 12: 'Red', + 13: 'Deep Green' + } %} + {{ sg_map.get(code, 'Unknown (code %s)' | format(code)) }} + + - name: "Dimplex SG Ready State Inputs" + unique_id: "dimplex_sg_ready_state_inputs" + icon: mdi:solar-power + state: > + {% set sg1 = 1 if is_state('binary_sensor.dimplex_smartgrid_input_1', 'on') else 0 %} + {% set sg2 = 1 if is_state('binary_sensor.dimplex_smartgrid_input_2', 'on') else 0 %} + {% if sg1 == 0 and sg2 == 0 %} + Yellow + {% elif sg1 == 0 and sg2 == 1 %} + Red + {% elif sg1 == 1 and sg2 == 0 %} + Green + {% elif sg1 == 1 and sg2 == 1 %} + Deep Green + {% else %} + Unknown + {% endif %} + + binary_sensor: + - name: "Dimplex Any Lock Active" + unique_id: "dimplex_any_lock_active" + device_class: problem + state: > + {{ states('sensor.dimplex_lock_messages') | int(0) != 0 }} + + - name: "Dimplex Any Fault Active" + unique_id: "dimplex_any_fault_active" + device_class: problem + state: > + {{ states('sensor.dimplex_fault_messages') | int(0) != 0 }} + +input_select: + sg_ready_mode: + name: SG Ready Mode + options: + - Hardware + - Yellow + - Green + - Red + - Deep Green + icon: mdi:solar-power + +automation: + # --- 1) Zmiana input_select -> zapis do Modbus (5167) --- + - alias: "Dimplex SG Ready – write register" + mode: single + trigger: + - platform: state + entity_id: input_select.sg_ready_mode + action: + - variables: + map: + Hardware: 0 + Yellow: 10 + Green: 11 + Red: 12 + Deep Green: 13 + sel: "{{ trigger.to_state.state }}" + - service: modbus.write_register + data: + hub: dimplex_lak9 # nazwa z sekcji modbus: name: + unit: 1 # najczęściej 1 + address: 5167 + value: "{{ map[sel] }}" + + # --- 2) Zmiana rejestru -> aktualizacja input_select --- + - alias: "Dimplex SG Ready – sync from register" + mode: single + trigger: + - platform: state + entity_id: sensor.dimplex_sg_ready_mode + action: + - variables: + code: "{{ trigger.to_state.state | int(0) }}" + map: + 0: 'Hardware' + 10: 'Yellow' + 11: 'Green' + 12: 'Red' + 13: 'Deep Green' + - service: input_select.select_option + data: + entity_id: input_select.sg_ready_mode + option: "{{ map.get(code, 'Hardware') }}" diff --git a/existing integration/dimplex_lak9_energy_estimator (1).yaml b/existing integration/dimplex_lak9_energy_estimator (1).yaml new file mode 100644 index 0000000..feb0fd9 --- /dev/null +++ b/existing integration/dimplex_lak9_energy_estimator (1).yaml @@ -0,0 +1,292 @@ +############################################# +# DIMPLEX LAK9 — ELEKTRYKA (ENERGY-DASHBOARD READY) +# +# Daje: +# - Power: sprężarka / grzałka / total (W + kW) +# - Energy: sprężarka / grzałka / total (kWh) + wrappery [ED] do Energy Dashboard +# - Utility meters (daily/monthly/yearly) do raportów +# +# Wejścia: +# - sensor.dimplex_inverter_frequency (Hz) +# - sensor.dimplex_status_messages (INT) +# - binary_sensor.dimplex_output_2nd_heat_generator (on/off) +############################################# + +############################ +# 1) HELPERS (UI) +############################ +input_number: + dimplex_k_dhw: + name: "LAK9 k_dhw (multiplier)" + min: 0.90 + max: 1.20 + step: 0.01 + mode: box + unit_of_measurement: "" + initial: 1.00 + + dimplex_k_defrost: + name: "LAK9 k_defrost (multiplier)" + min: 0.90 + max: 1.30 + step: 0.01 + mode: box + unit_of_measurement: "" + initial: 1.05 + + dimplex_heater_2nd_power_w: + name: "LAK9 2nd source heater power" + min: 0 + max: 8000 + step: 50 + mode: box + unit_of_measurement: W + initial: 6000 + + dimplex_pump_main_power_w: + name: "LAK9 main pump power (optional)" + min: 0 + max: 400 + step: 5 + mode: box + unit_of_measurement: W + initial: 0 + + dimplex_pump_floor_power_w: + name: "LAK9 floor pump power (optional)" + min: 0 + max: 400 + step: 5 + mode: box + unit_of_measurement: W + initial: 0 + +input_boolean: + dimplex_include_pumps_in_total: + name: "LAK9 include pump power in TOTAL (optional)" + icon: mdi:pump + initial: false + + +############################ +# 2) TEMPLATE (JEDEN BLOK) +############################ +template: + - sensor: + + # 2.1 LUT: Hz -> W (sprężarka) — baza + - name: "LAK9 Compressor Power LUT (base)" + unique_id: lak9_compressor_power_lut_base + unit_of_measurement: "W" + device_class: power + state_class: measurement + state: > + {% set hz = states('sensor.dimplex_inverter_frequency') | float(0) %} + {% if hz <= 0 %} + 0 + {% else %} + {% set pts = [ + (32,1050),(35,1110),(39,1190),(41,1290), + (45,1340),(49,1500),(53,1570),(57,1720), + (61,1850),(63,1940),(65,2010),(67,2050), + (71,2200),(75,2320),(79,2450),(83,2550) + ] %} + {% set x = hz %} + {% if x < pts[0][0] %}{% set x = pts[0][0] %}{% endif %} + {% if x > pts[-1][0] %}{% set x = pts[-1][0] %}{% endif %} + {% set ns = namespace(y=0) %} + {% for i in range(0, pts|length - 1) %} + {% set x0 = pts[i][0] %}{% set y0 = pts[i][1] %} + {% set x1 = pts[i+1][0] %}{% set y1 = pts[i+1][1] %} + {% if x >= x0 and x <= x1 %} + {% set ns.y = y0 + (y1 - y0) * ((x - x0) / (x1 - x0)) %} + {% endif %} + {% endfor %} + {{ ns.y | round(0) }} + {% endif %} + + # 2.2 Sprężarka: status-aware + mnożniki DHW/Defrost + - name: "LAK9 Compressor Power Estimated" + unique_id: lak9_compressor_power_estimated + unit_of_measurement: "W" + device_class: power + state_class: measurement + state: > + {% set base = states('sensor.lak9_compressor_power_lut_base') | float(0) %} + {% set sc = states('sensor.dimplex_status_messages') | int(-1) %} + {% set k_defrost = states('input_number.dimplex_k_defrost') | float(1.05) %} + {% set k_dhw = states('input_number.dimplex_k_dhw') | float(1.00) %} + {% if base <= 0 %} + 0 + {% elif sc in [0, 1, 30, 11] %} + 0 + {% elif sc == 10 %} + {{ (base * k_defrost) | round(0) }} + {% elif sc == 4 %} + {{ (base * k_dhw) | round(0) }} + {% else %} + {{ base | round(0) }} + {% endif %} + + - name: "LAK9 Compressor Power Estimated (kW)" + unique_id: lak9_compressor_power_estimated_kw + unit_of_measurement: "kW" + device_class: power + state_class: measurement + state: > + {{ (states('sensor.lak9_compressor_power_estimated') | float(0) / 1000) | round(3) }} + + # 2.3 Grzałka: 0 / stała moc + - name: "LAK9 Heater Power Estimated" + unique_id: lak9_heater_power_estimated + unit_of_measurement: "W" + device_class: power + state_class: measurement + state: > + {% set p = states('input_number.dimplex_heater_2nd_power_w') | float(6000) %} + {% if is_state('binary_sensor.dimplex_output_2nd_heat_generator', 'on') %} + {{ p | round(0) }} + {% else %} + 0 + {% endif %} + + - name: "LAK9 Heater Power Estimated (kW)" + unique_id: lak9_heater_power_estimated_kw + unit_of_measurement: "kW" + device_class: power + state_class: measurement + state: > + {{ (states('sensor.lak9_heater_power_estimated') | float(0) / 1000) | round(3) }} + + # 2.4 Pompy (opcjonalnie): stała moc, tylko do TOTAL + - name: "LAK9 Pump Power Estimated (optional)" + unique_id: lak9_pump_power_estimated_optional + unit_of_measurement: "W" + device_class: power + state_class: measurement + state: > + {% if is_state('input_boolean.dimplex_include_pumps_in_total', 'on') %} + {% set p1 = states('input_number.dimplex_pump_main_power_w') | float(0) %} + {% set p2 = states('input_number.dimplex_pump_floor_power_w') | float(0) %} + {{ (p1 + p2) | round(0) }} + {% else %} + 0 + {% endif %} + + # 2.5 TOTAL: sprężarka + grzałka + pompy(opcjonalnie) + - name: "LAK9 Total Power Estimated" + unique_id: lak9_total_power_estimated + unit_of_measurement: "W" + device_class: power + state_class: measurement + state: > + {% set p_comp = states('sensor.lak9_compressor_power_estimated') | float(0) %} + {% set p_heat = states('sensor.lak9_heater_power_estimated') | float(0) %} + {% set p_pump = states('sensor.lak9_pump_power_estimated_optional') | float(0) %} + {{ (p_comp + p_heat + p_pump) | round(0) }} + + - name: "LAK9 Total Power Estimated (kW)" + unique_id: lak9_total_power_estimated_kw + unit_of_measurement: "kW" + device_class: power + state_class: measurement + state: > + {{ (states('sensor.lak9_total_power_estimated') | float(0) / 1000) | round(3) }} + + ######################################################### + # 2.6 ENERGY DASHBOARD WRAPPERS [ED] + # (te trzy encje wybierasz w Energy Dashboard) + ######################################################### + - name: "LAK9 Energy TOTAL (kWh) [ED]" + unique_id: lak9_energy_total_kwh_ed + unit_of_measurement: "kWh" + device_class: energy + state_class: total_increasing + state: "{{ states('sensor.lak9_total_energy_kwh') | float(0) }}" + + - name: "LAK9 Energy Compressor (kWh) [ED]" + unique_id: lak9_energy_compressor_kwh_ed + unit_of_measurement: "kWh" + device_class: energy + state_class: total_increasing + state: "{{ states('sensor.lak9_compressor_energy_kwh') | float(0) }}" + + - name: "LAK9 Energy Heater (kWh) [ED]" + unique_id: lak9_energy_heater_kwh_ed + unit_of_measurement: "kWh" + device_class: energy + state_class: total_increasing + state: "{{ states('sensor.lak9_heater_energy_kwh') | float(0) }}" + + +############################ +# 3) ENERGIA (kWh) — integracja mocy +############################ +sensor: + - platform: integration + name: "LAK9 Compressor Energy (kWh)" + unique_id: lak9_compressor_energy_kwh + source: sensor.lak9_compressor_power_estimated + unit_prefix: k + unit_time: h + method: trapezoidal + + - platform: integration + name: "LAK9 Heater Energy (kWh)" + unique_id: lak9_heater_energy_kwh + source: sensor.lak9_heater_power_estimated + unit_prefix: k + unit_time: h + method: left + + - platform: integration + name: "LAK9 Total Energy (kWh)" + unique_id: lak9_total_energy_kwh + source: sensor.lak9_total_power_estimated + unit_prefix: k + unit_time: h + method: trapezoidal + + +############################ +# 4) UTILITY METERS (daily / monthly / yearly) — raporty +############################ +utility_meter: + lak9_total_energy_daily: + name: "LAK9 Total Energy Daily" + source: sensor.lak9_energy_total_kwh_ed + cycle: daily + lak9_total_energy_monthly: + name: "LAK9 Total Energy Monthly" + source: sensor.lak9_energy_total_kwh_ed + cycle: monthly + lak9_total_energy_yearly: + name: "LAK9 Total Energy Yearly" + source: sensor.lak9_energy_total_kwh_ed + cycle: yearly + + lak9_compressor_energy_daily: + name: "LAK9 Compressor Energy Daily" + source: sensor.lak9_energy_compressor_kwh_ed + cycle: daily + lak9_compressor_energy_monthly: + name: "LAK9 Compressor Energy Monthly" + source: sensor.lak9_energy_compressor_kwh_ed + cycle: monthly + lak9_compressor_energy_yearly: + name: "LAK9 Compressor Energy Yearly" + source: sensor.lak9_energy_compressor_kwh_ed + cycle: yearly + + lak9_heater_energy_daily: + name: "LAK9 Heater Energy Daily" + source: sensor.lak9_energy_heater_kwh_ed + cycle: daily + lak9_heater_energy_monthly: + name: "LAK9 Heater Energy Monthly" + source: sensor.lak9_energy_heater_kwh_ed + cycle: monthly + lak9_heater_energy_yearly: + name: "LAK9 Heater Energy Yearly" + source: sensor.lak9_energy_heater_kwh_ed + cycle: yearly \ No newline at end of file diff --git a/existing integration/dimplex_lak9_thermal_energy_estimator (1).yaml b/existing integration/dimplex_lak9_thermal_energy_estimator (1).yaml new file mode 100644 index 0000000..67ae879 --- /dev/null +++ b/existing integration/dimplex_lak9_thermal_energy_estimator (1).yaml @@ -0,0 +1,330 @@ +############################################# +# DIMPLEX LAK9 — KOMPLETNY MODEL ENERGII CIEPLNEJ (HA) +# - COP (EN14511) interpolacja 2D na A/W +# - Q_comp_th: ciepło ze sprężarki (tylko CO/DHW) +# - Q_heater_th: ciepło z grzałek (1:1, zawsze gdy grzałka on) +# - Q_defrost_loss_th: strata cieplna defrostu (pobór ciepła z instalacji) +# - Q_loop_th = Q_comp_th + Q_heater_th - Q_defrost_loss_th +# - α split: dom vs instalacja (heurystyka na bazie d(ΔT)/dt) +# - Integracje: kWh_th (dom, instalacja) + defrost loss opcjonalnie +# +# WYMAGANE ENCJE WEJŚCIOWE (podmień jeśli masz inne): +# - sensor.dimplex_outdoor_temp (°C) [u Ciebie działa] +# - sensor.dimplex_flow_temp (°C) [u Ciebie działa] +# - sensor.dimplex_flow_temperature (°C) (do ΔT) +# - sensor.dimplex_return_temperature (°C) (do ΔT) +# - sensor.dimplex_status_messages (INT) (2=CO,4=DHW,10=defrost,...) +# - sensor.lak9_compressor_power_status_code (W) (z estymatora elektryki) +# - sensor.lak9_2nd_source_heater_power (W) (z estymatora elektryki) +############################################# + +############################ +# 1) PARAMETRY EDYTOWALNE +############################ +input_number: + lak9_defrost_heat_factor: + name: "LAK9 defrost heat factor (k_loss)" + min: 0.2 + max: 4.0 + step: 0.05 + initial: 2.35 + + lak9_alpha_base: + name: "LAK9 alpha base" + min: 0.0 + max: 1.0 + step: 0.01 + initial: 0.85 + + lak9_alpha_sensitivity: + name: "LAK9 alpha sensitivity" + min: 0.0 + max: 2.0 + step: 0.05 + initial: 0.4 + + lak9_alpha_deadband: + name: "LAK9 alpha deadband" + min: 0.0 + max: 0.2 + step: 0.01 + initial: 0.03 + +input_boolean: + lak9_enable_house_split: + name: "LAK9 enable house/installation split" + initial: true + + +############################ +# 2) TEMPLATE SENSORS (JEDEN BLOK) +############################ +template: + - sensor: + + ######################################################### + # 2.1 COP EN14511 (A/W) — interpolacja 2D + ######################################################### + - name: "LAK9 COP EN14511" + unique_id: lak9_cop_en14511 + unit_of_measurement: "COP" + state_class: measurement + state: > + {% set out_raw = states('sensor.dimplex_outdoor_temperature') %} + {% set flow_raw = states('sensor.dimplex_flow_temperature') %} + + {% if out_raw in ['unknown','unavailable',''] or flow_raw in ['unknown','unavailable',''] %} + 0 + {% else %} + {% set t_out = out_raw | replace('°C','') | replace(',','.') | trim | float(none) %} + {% set t_flow = flow_raw | replace('°C','') | replace(',','.') | trim | float(none) %} + {% if t_out is none or t_flow is none %} + 0 + {% else %} + {% set A = t_out %} + {% if A < -7 %}{% set A = -7 %}{% endif %} + {% if A > 10 %}{% set A = 10 %}{% endif %} + + {% set W = t_flow %} + {% if W < 35 %}{% set W = 35 %}{% endif %} + {% if W > 55 %}{% set W = 55 %}{% endif %} + + {% set A_pts = [-7, 2, 7, 10] %} + {% set W_pts = [35, 45, 55] %} + + {% set COP = { + -7:{35:2.4, 45:2.24, 55:1.72}, + 2:{35:3.6, 45:2.96, 55:2.44}, + 7:{35:4.8, 45:3.30, 55:2.86}, + 10:{35:5.1, 45:3.57, 55:2.98} + } %} + + {% set A0 = (A_pts | select('le', A) | list | max) %} + {% set A1 = (A_pts | select('ge', A) | list | min) %} + {% set W0 = (W_pts | select('le', W) | list | max) %} + {% set W1 = (W_pts | select('ge', W) | list | min) %} + + {% set Q11 = COP[A0][W0] %} + {% set Q21 = COP[A1][W0] %} + {% set Q12 = COP[A0][W1] %} + {% set Q22 = COP[A1][W1] %} + + {% if A0 == A1 and W0 == W1 %} + {{ Q11 | round(3) }} + {% elif A0 == A1 %} + {{ (Q11 + (Q12 - Q11) * ((W - W0) / (W1 - W0))) | round(3) }} + {% elif W0 == W1 %} + {{ (Q11 + (Q21 - Q11) * ((A - A0) / (A1 - A0))) | round(3) }} + {% else %} + {{ ( + Q11 * (A1 - A) * (W1 - W) + + Q21 * (A - A0) * (W1 - W) + + Q12 * (A1 - A) * (W - W0) + + Q22 * (A - A0) * (W - W0) + ) / ((A1 - A0) * (W1 - W0)) | round(3) }} + {% endif %} + {% endif %} + {% endif %} + + ######################################################### + # 2.2 Ciepło z grzałek (1:1) + ######################################################### + - name: "LAK9 Thermal Power Heater" + unique_id: lak9_thermal_power_heater + unit_of_measurement: "W" + device_class: power + state_class: measurement + state: > + {{ states('sensor.lak9_heater_power_estimated') | float(0) | round(0) }} + + ######################################################### + # 2.3 Ciepło ze sprężarki (tylko CO/DHW): Q = P_el * COP + ######################################################### + - name: "LAK9 Thermal Power Compressor" + unique_id: lak9_thermal_power_compressor + unit_of_measurement: "W" + device_class: power + state_class: measurement + state: > + {% set sc = states('sensor.dimplex_status_messages') | int(-1) %} + {% set p = states('sensor.lak9_compressor_power_estimated') | float(0) %} + {% set cop = states('sensor.lak9_cop_en14511') | float(0) %} + {% if sc in [2, 4] %} + {{ (p * cop) | round(0) }} + {% else %} + 0 + {% endif %} + + ######################################################### + # 2.4 Strata defrostu (pobór ciepła z instalacji) + ######################################################### + - name: "LAK9 Thermal Power Defrost Loss" + unique_id: lak9_thermal_power_defrost_loss + unit_of_measurement: "W" + device_class: power + state_class: measurement + state: > + {% set sc = states('sensor.dimplex_status_messages') | int(-1) %} + {% set p = states('sensor.lak9_compressor_power_estimated') | float(0) %} + {% set k = states('input_number.lak9_defrost_heat_factor') | float(1.0) %} + {% if sc == 10 %} + {{ (p * k) | round(0) }} + {% else %} + 0 + {% endif %} + + ######################################################### + # 2.5 Q_loop — netto ciepło “w obiegu” (do domu + instalacji) + ######################################################### + - name: "LAK9 Thermal Power Loop" + unique_id: lak9_thermal_power_loop + unit_of_measurement: "W" + device_class: power + state_class: measurement + state: > + {% set q_comp = states('sensor.lak9_thermal_power_compressor') | float(0) %} + {% set q_heat = states('sensor.lak9_thermal_power_heater') | float(0) %} + {% set q_def = states('sensor.lak9_thermal_power_defrost_loss') | float(0) %} + {{ (q_comp + q_heat - q_def) | round(0) }} + + ######################################################### + # 2.6 ΔT (flow - return) — heurystyka + ######################################################### + - name: "LAK9 DeltaT" + unique_id: lak9_deltat + unit_of_measurement: "K" + state_class: measurement + state: > + {{ (states('sensor.dimplex_flow_temperature') | float(0) - + states('sensor.dimplex_return_temperature') | float(0)) | round(3) }} + + ######################################################### + # 2.7 α — udział energii oddanej do domu + ######################################################### + - name: "LAK9 Alpha House" + unique_id: lak9_alpha_house + state: > + {% if is_state('input_boolean.lak9_enable_house_split', 'off') %} + 1 + {% else %} + {% set a0 = states('input_number.lak9_alpha_base') | float(0.85) %} + {% set s = states('input_number.lak9_alpha_sensitivity') | float(0.4) %} + {% set db = states('input_number.lak9_alpha_deadband') | float(0.03) %} + {% set d = states('sensor.lak9_ddeltat_dt') | float(0) %} + + {% if d < db and d > -db %}{% set d = 0 %}{% endif %} + {% set a = a0 - s * d %} + {% if a < 0 %}0{% elif a > 1 %}1{% else %}{{ a | round(3) }}{% endif %} + {% endif %} + + ######################################################### + # 2.8 Rozdział: DOM / INSTALACJA + ######################################################### + - name: "LAK9 Thermal Power To House" + unique_id: lak9_thermal_power_to_house + unit_of_measurement: "W" + device_class: power + state_class: measurement + state: > + {{ (states('sensor.lak9_thermal_power_loop') | float(0) * + states('sensor.lak9_alpha_house') | float(1)) | round(0) }} + + - name: "LAK9 Thermal Power To Installation" + unique_id: lak9_thermal_power_to_installation + unit_of_measurement: "W" + device_class: power + state_class: measurement + state: > + {{ (states('sensor.lak9_thermal_power_loop') | float(0) - + states('sensor.lak9_thermal_power_to_house') | float(0)) | round(0) }} + + ######################################################### + # (DODATEK) Przepływ estymowany z bilansu: Q = m*cp*ΔT + # V̇[m³/h] ≈ Q[W] / (4180 * ΔT[K]) * 3.6 + # Ograniczenia: + # - tylko CO/DHW + # - ΔT > 0.5 K + # - clamp 0..3.8 m³/h + ######################################################### + - name: "LAK9 Estimated Flow" + unique_id: lak9_estimated_flow_m3h + unit_of_measurement: "m³/h" + state_class: measurement + icon: mdi:water-pump + state: > + {% set sc = states('sensor.dimplex_status_messages') | int(-1) %} + {% set q = states('sensor.lak9_thermal_power_compressor') | float(0) %} {# W #} + {% set dt = states('sensor.lak9_deltat') | float(0) %} {# K #} + + {% if sc in [2,4] and q > 0 and dt > 0.5 %} + {% set v_m3h = (q / (4180 * dt)) * 3.6 %} + {% if v_m3h < 0 %} 0 + {% elif v_m3h > 3.8 %} 3.8 + {% else %} {{ v_m3h | round(2) }} + {% endif %} + {% else %} + 0 + {% endif %} + + ######################################################### + # (DODATEK) Przepływ wygładzony (EMA) + # - stabilniejszy wykres i stabilniejsze liczenie Q_hyd + ######################################################### + - name: "LAK9 Estimated Flow (smoothed)" + unique_id: lak9_estimated_flow_m3h_smoothed + unit_of_measurement: "m³/h" + state_class: measurement + icon: mdi:water + state: > + {{ states('sensor.lak9_estimated_flow') | float(0) | round(2) }} + +############################ +# 3) POCHODNE I INTEGRACJE +############################ +sensor: + - platform: derivative + name: "LAK9 dDeltaT/dt" + unique_id: lak9_ddeltat_dt + source: sensor.lak9_deltat + unit_time: min + time_window: "00:05:00" + + - platform: integration + name: "LAK9 Thermal Energy To House (kWh_th)" + unique_id: lak9_thermal_energy_to_house_kwh_th + source: sensor.lak9_thermal_power_to_house + unit_prefix: k + method: trapezoidal + + - platform: integration + name: "LAK9 Installation Energy (kWh_th)" + unique_id: lak9_installation_energy_kwh_th + source: sensor.lak9_thermal_power_to_installation + unit_prefix: k + method: trapezoidal + + # Opcjonalnie: ile “zjadł” defrost (kWh_th) z domu + - platform: integration + name: "LAK9 Defrost Heat Loss (kWh_th)" + unique_id: lak9_defrost_heat_loss_kwh_th + source: sensor.lak9_thermal_power_defrost_loss + unit_prefix: k + method: trapezoidal + + - platform: statistics + name: "LAK9 Flow Mean 10m" + unique_id: lak9_flow_mean_10m + entity_id: sensor.lak9_estimated_flow_m3h + state_characteristic: mean + max_age: + minutes: 10 + sampling_size: 500 + + - platform: statistics + name: "LAK9 Flow Mean 20m" + unique_id: lak9_flow_mean_20m + entity_id: sensor.lak9_estimated_flow_m3h + state_characteristic: mean + max_age: + minutes: 20 + sampling_size: 1000 \ No newline at end of file diff --git a/hacs.json b/hacs.json index e94a471..325d746 100644 --- a/hacs.json +++ b/hacs.json @@ -1,10 +1,6 @@ { "name": "Dimplex WPM", - "domains": ["dimplex_wpm"], "country": "PL", - "render_readme": true, "homeassistant": "2024.2.0", - "zip_release": false, - "filename": "dimplex_wpm.zip", - "integration_type": "integration" + "render_readme": true } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4dd0b17 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[tool.ruff] +target-version = "py312" +line-length = 100 +extend-exclude = ["spec", "existing integration"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP"] +ignore = ["E501"] + +[tool.ruff.lint.isort] +force-sort-within-sections = true +known-first-party = ["custom_components"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" diff --git a/spec/DASHBOARD_DESIGN.md b/spec/DASHBOARD_DESIGN.md new file mode 100644 index 0000000..71dfed8 --- /dev/null +++ b/spec/DASHBOARD_DESIGN.md @@ -0,0 +1,687 @@ +# Dimplex WPM — Dashboard Design + +> **Status: M3 v1 AUTHORED.** The built-in-card dashboard is shipped at +> `dashboards/dimplex_wpm.yaml`, using the REAL entity ids documented in +> `spec/ENTITY_IDS.md` (the placeholder slugs in the sketches below — `analytics_*` +> / `sg_*` / `cop_en14511` — were reconciled: module is `energy`→device "Analytics", +> SG lives on the controller, COP is `cop_estimated`/`cop_measured`, energy uses +> single source-attributed entities, flow adds an EMA `flow_rate_smoothed`). +> Climate cards are omitted (platform deferred). apexcharts remains an optional +> v2 upgrade. The sections below are retained as design rationale. + +> **Status: DESIGN.** Companion to `DESIGN.md` (architecture, device tree §5/§5.0, +> source matrix §6, capabilities §4.1) and `spec/REGISTERS.md` (canonical +> registers/entities). This document specifies the Home Assistant front-end: +> importable Lovelace dashboards now, an optional custom card later. +> +> Scope: integration domain `dimplex_wpm`. Entity-id convention (derived from +> `has_entity_name = True`, `unique_id = {entry_id}_{module}_{key}`) used +> throughout this doc: +> +> ``` +> sensor.dimplex_wpm__ # e.g. sensor.dimplex_wpm_hc1_flow_temperature +> binary_sensor.dimplex_wpm__ +> number.dimplex_wpm__ +> select.dimplex_wpm__ +> climate.dimplex_wpm__ +> ``` +> +> Modules: `controller`, `hc1`, `hc2_3`, `dhw`, `pool`, `vent`, `solar`, +> `source`, `sg` (smart grid / EMS), `analytics`. These are placeholders — the +> implementation team owns the final slugs, but the patterns below are +> entity-id-agnostic and degrade cleanly if a slug changes. + +--- + +## 1. Overall approach + +### Recommendation: **ship importable YAML dashboards first (v1); defer the custom Lovelace card to v2 (and only if a concrete gap survives).** + +**Order:** +1. **v1 — four-to-five importable YAML dashboards** built almost entirely on + **built-in cards** plus a thin, *optional* dependency on `mushroom` (chips + for compact status) and `apexcharts-card` (the energy/COP plots that + built-in `history-graph` cannot do well). These are by far the two most + widely installed HACS frontend cards in 2025/2026, so the "optional + dependency" cost to users is low. We also provide a **built-in-only + fallback** for every view so the dashboards render even with zero HACS + frontend installs. +2. **v2 — *only if needed* — a custom Lovelace card** in a **separate HACS + "frontend" repo** (the backend integration must not register cards; this is + HA convention and a hard requirement for HACS/core acceptance). This is the + `M4` milestone in `DESIGN.md §12` and stays optional. + +### Why YAML-first, against 2025/2026 HA practice + +- **Sections view is the modern default.** Since the 2024.x → 2025.x cycle, + the **sections** view type (drag-resizable grid, `grid_options`, + `column_span`, conditional visibility per card) is the standard layout + engine. It gives responsive, masonry-free layouts that previously *required* + a custom layout card (`layout-card`). We build every multi-card view as + `type: sections`. This removes the historical reason to reach for custom + layout cards. +- **Built-in cards got good.** `tile` (with features: `target-temperature`, + `toggle`, state-content lines), `gauge` (with severity bands), + `history-graph`, `statistics-graph` (long-term stats, hourly/daily/monthly), + `entities`, `conditional`, `markdown`, `entity` badges, and the native + `energy-*` cards cover ~90% of what a heat-pump dashboard needs without any + HACS dependency. +- **The two genuine gaps** that built-ins still don't cover well: + 1. **Multi-series power/heat plots with mixed units and a secondary axis** + (e.g. COP on a right axis against thermal kW on the left, stacked + compressor/heater/defrost areas). `history-graph` cannot do dual axes, + stacking, or per-series styling. **`custom:apexcharts-card`** is the + de-facto standard here and is the single most valuable optional card. + 2. **Dense, glanceable status at the top of Overview.** `custom:mushroom-*` + (chips, template cards, climate card) gives a noticeably nicer compact + status row than `entities`/`tile`, but built-in `tile` + badges are an + acceptable fallback. + +### What a *custom card* would add vs built-ins — and the v1 verdict + +A bespoke `dimplex-wpm-card` could deliver, in one widget: + +- A **hydraulic schematic** (animated flow arrows source → compressor → + buffer → HC/DHW, live ΔT, live estimated flow) — genuinely impossible with + built-ins. +- **First-class measured-vs-estimated rendering** — a badge baked into every + value that reads the entity's `source` attribute, instead of us hand-wiring + conditional cards (see §3). +- **Auto-discovery of present modules** from the device tree, so the card + configures itself instead of the user hand-editing YAML when DHW/Pool/Solar + appear. + +**Verdict for v1: not worth it.** The schematic is delightful but cosmetic; +everything functional (status, control, energy, calibration, measured-vs- +estimated signalling) is achievable today with sections + built-ins + +apexcharts. A custom card is a *frontend repo to maintain, version against HA +breaking changes, and submit to HACS separately* — real ongoing cost for a +solo/small project whose differentiator (per `DESIGN.md §1`) is the +**estimation engine**, not pixels. Build the card in v2 if and only if user +feedback shows the schematic / auto-config gap is real. Until then, the +auto-config win is better spent on **generating** the YAML dashboards from the +active capability set at setup time (a small backend nicety) than on a +runtime card. + +--- + +## 2. Dashboard set + +Five views. All are `type: sections` unless noted. Each view degrades by +**capability** and **module presence** via `conditional`/per-card visibility +(§3). Names map to `DESIGN.md §9 Etap 1` (Overview/Status, Energy & Heat, +History, Calibration/Parameters) plus a dedicated **Control** view because +control entities only exist behind the `enable_control` gate and deserve +isolation from read-only daily use. + +| # | View | Path | Audience | Built-in-only? | +|---|------|------|----------|----------------| +| 1 | Overview / Status | `overview` | daily glance | yes (mushroom optional) | +| 2 | Heat & Energy | `energy` | the differentiator | apexcharts recommended | +| 3 | Temperatures / History | `history` | trends | yes (statistics-graph) | +| 4 | Control | `control` | gated, expert | yes | +| 5 | Calibration / Diagnostics | `diagnostics` | setup & tuning | yes | + +--- + +### View 1 — Overview / Status + +Goal: "is the pump OK, what is it doing right now, key temps." This is the +modern replacement for existing **dashboard 1**, reorganised around the device +tree and with measured-vs-estimated made explicit. + +**Top: heading + status chips (mushroom optional, badges fallback).** + +- **Card: heading** — `type: heading`, `heading: Dimplex WPM`, icon + `mdi:heat-pump`. Optional `badges:` row bound to + `sensor.dimplex_wpm_controller_status_text`, + `binary_sensor.dimplex_wpm_controller_fault_active`, + `binary_sensor.dimplex_wpm_controller_lock_active`. +- **Card: chips** — `custom:mushroom-chips-card` with one chip per: + current status (`controller_status_text`), outdoor temp, active power + (`analytics_total_power` — shows estimated badge per §3), DHW temp, SG Ready + mode. *Fallback:* a `glance` card with the same entities. Why: glanceable + "everything fine" read without scrolling. + +**Section: Heat pump now.** (`type: grid`) + +- **Card: tile × N** — one `tile` per primary live value: + `controller_outdoor_temperature`, `hc1_flow_temperature`, + `hc1_return_temperature`, `dhw_temperature`, + `analytics_cop_en14511` (with source badge), `analytics_total_power`. + Why `tile`: large, tap-to-more-info, state-coloured, plays well in the + sections grid; replaces the cramped `entities` list from dashboard 1. +- **Card: gauge** — `analytics_cop_en14511`, `min: 0 max: 6`, severity green + ≥3.5 / amber 2–3.5 / red <2. COP is the headline KPI of this integration; + a gauge sells it. + +**Section: Operating state.** (`type: grid`) + +- **Card: entities** — the status block: `controller_status_text` (with + `secondary_info: last-changed`), `controller_lock_text`, + `controller_fault_text`, `controller_sensor_error_text`, + `binary_sensor.dimplex_wpm_controller_lock_active`, + `binary_sensor.dimplex_wpm_controller_fault_active`, + `binary_sensor.dimplex_wpm_source_2nd_heat_generator` (E10 output, the + "expensive backup heat is running" signal users care about — carried over + from dashboard 1), `controller_runtime_2nd_heat_generator`. +- **Card: conditional → entities (Smart Grid)** — visible only when the `sg` + module exists. `sg_ready_mode_text`, + `binary_sensor.dimplex_wpm_sg_smartgrid_input_1`, + `binary_sensor.dimplex_wpm_sg_smartgrid_input_2`, + `binary_sensor.dimplex_wpm_sg_utility_lockout`. Replaces dashboard 1's SG + block; the legacy `input_select.sg_ready_mode` helper is gone — control + moves to View 4. + +**Section: Module tiles (each conditional on its module).** (`type: grid`) +One small `conditional` → `entities`/`tile` group per present module: + +- **HC2/3** (cond. module `hc2_3`): `hc2_3_hc2_temperature`, + `hc2_3_hc3_temperature`, setpoints. +- **Pool** (cond. module `pool`): `pool_setpoint`, pool pump output. +- **Ventilation** (cond. module `vent`): supply/extract/exhaust temps, fan + speeds, level. +- **Solar** (cond. module `solar`): `solar_collector_temperature`, + `solar_tank_temperature`. +- **Source** (cond. module `source`): `source_inlet_temperature`, + `source_outlet_temperature`. + +--- + +### View 2 — Heat & Energy *(the differentiator — measured-vs-estimated front and centre)* + +Goal: show power, heat, COP, flow, and energy — and **never let the user +confuse an estimate for a meter reading.** Modernises **dashboard 2**. + +This view is where `custom:apexcharts-card` earns its keep. We provide an +apexcharts layout and a `history-graph` fallback section. + +**Section: Live power & heat balance.** (`type: grid`) + +- **Card: apexcharts (stacked area)** — instantaneous **thermal** balance: + `analytics_thermal_power_compressor`, + `analytics_thermal_power_heater`, + `analytics_thermal_power_defrost_loss` (negative), + with `analytics_thermal_power_loop` as a line overlay. Stacked area shows + the heat decomposition the estimation engine produces — impossible in + `history-graph`. *Fallback:* `history-graph` of the same five series (the + exact set in dashboard 2). +- **Card: apexcharts (dual axis)** — left axis kW + (`analytics_total_power`, `analytics_thermal_power_loop`), right axis COP + (`analytics_cop_en14511`). The COP-vs-power relationship is the core story + and needs two axes. + +**Section: House / installation split.** (cond. capability; `type: grid`) + +- **Card: apexcharts (stacked)** — `analytics_thermal_power_to_house` vs + `analytics_thermal_power_to_installation`. The α-split heuristic + (`DESIGN.md §6.1 step 6`) is a headline estimation feature; give it a home. + +**Section: Flow.** (`type: grid`) + +- **Card: apexcharts / history-graph** — `analytics_estimated_flow`, + `analytics_estimated_flow_smoothed`, `analytics_delta_t`, and — *only when + `has_flow_sensor`* — the external measured flow entity + (`capabilities.flow_sensor_entity`). When the real sensor is present, it is + plotted as a **bold solid** line and the estimate as **dashed**, with a note + card: "Solid = measured flow sensor; dashed = hydraulic estimate." This is + the cross-check dashboard 2 hinted at with the `aquaro_*` entity. + +**Section: Energy (today / total).** (`type: grid`) + +- **Card: statistics-graph** — `period: day`, `stat_types: [change]`, + bar chart of `analytics_energy_total_kwh` (electric) and + `analytics_energy_to_house_kwh` (thermal). Long-term-stats native card; no + HACS needed. +- **Card: entities** — totals row: electric `energy_*_kwh`, thermal + `energy_*_kwh`, each carrying its `source` badge (§3). Where a heat meter + exists, the **measured** energy entity is shown; where not, the **estimated** + one — they are *different entities*, never the same entity flipping source. +- **Card: markdown (link)** — "Configure these in Settings → Energy + Dashboard" with the entity list (§4). + +> **Measured-vs-estimated discipline on this view:** every estimated series +> uses a consistent visual language — name suffix " (est.)", a +> `mdi:calculator-variant` badge, and (apexcharts) a dashed/lighter stroke. +> Measured series get `mdi:gauge` and a solid stroke. See §3 for the +> mechanics. + +--- + +### View 3 — Temperatures / History + +Goal: trends over hours/days/weeks. Modernises **dashboard 3** but splits the +one giant 26-entity `history-graph` into purposeful, readable graphs and adds +**long-term** statistics (the old view only had 24h live history). + +**Section: Temperatures (24 h live).** (`type: grid`) + +- **Card: history-graph** `hours_to_show: 24` — temperature cluster only: + `controller_outdoor_temperature`, `hc1_flow_temperature`, + `hc1_return_temperature`, `hc1_return_setpoint`, `dhw_temperature`, + `dhw_setpoint`. (Plus conditional HC2/3, Source temps when present.) + +**Section: Activity (24 h live).** (`type: grid`) + +- **Card: history-graph** `hours_to_show: 24` — the binary/state cluster: + `controller_status_text`, `controller_inverter_frequency`, + `binary_sensor.dimplex_wpm_source_compressor_1`, + `binary_sensor.dimplex_wpm_hc1_heating_pump_m13`, + `binary_sensor.dimplex_wpm_dhw_pump`, + `binary_sensor.dimplex_wpm_source_2nd_heat_generator`. Separating temps from + on/off states fixes dashboard 3's unreadable mixed graph. + +**Section: Long-term trends.** (`type: grid`) + +- **Card: statistics-graph** `period: day`, `days_to_show: 30` — daily mean + COP, daily energy, daily mean outdoor temp. Uses HA long-term statistics — + the genuinely new capability this redesign should expose. + +--- + +### View 4 — Control *(gated: only render behind `enable_control`)* + +Goal: isolate every *write* in one clearly-labelled place, with a prominent +warning, so daily users never fat-finger a setpoint. The entire view is +wrapped so it shows nothing useful unless control entities exist. + +Because control entities are simply **absent** when `enable_control` is off, +the cleanest gate is: each control card is a `conditional` keyed on the +existence/availability of a representative control entity (e.g. +`number.dimplex_wpm_dhw_setpoint`). When absent, an `entity_filter`/conditional +shows instead a `markdown` notice: "Control is disabled. Enable *Advanced +control* in the integration's options to expose setpoints." + +**Section: Warning.** (`type: grid`) + +- **Card: markdown** — "These controls write directly to the heat-pump + controller. Values are range-validated, but changes affect a live system." + +**Section: Climate (cond. `enable_climate`).** (`type: grid`) + +- **Card: thermostat** (built-in) — `climate.dimplex_wpm_hc1` and + `climate.dimplex_wpm_dhw`. Built-in thermostat card; no HACS dependency. + *Optional:* `custom:mushroom-climate-card` for a denser look. + +**Section: Heating circuit 1 setpoints (cond.).** (`type: grid`) + +- **Card: entities** — `number.dimplex_wpm_hc1_curve_offset` (enum-decoded + −19..+19 K), `number.dimplex_wpm_hc1_fixed_flow`, + `number.dimplex_wpm_hc1_curve_end`, `number.dimplex_wpm_hc1_hysteresis`. + +**Section: DHW & Pool setpoints (each cond. on module).** (`type: grid`) + +- **Card: tile (with target-temperature feature)** — + `number.dimplex_wpm_dhw_setpoint`, `number.dimplex_wpm_pool_setpoint`. + `tile` + a number feature gives a clean slider. + +**Section: Smart Grid (cond. `enable_sg_ready` + `sg` module).** (`type: grid`) + +- **Card: entities** — `select.dimplex_wpm_sg_ready_mode` + (Hardware/Yellow/Green/Red/Deep-Green), with the read-back + `sensor.dimplex_wpm_sg_ready_mode_text` beside it. This replaces the legacy + `input_select.sg_ready_mode` helper from dashboard 1 with the integration's + native `select`. + +--- + +### View 5 — Calibration / Diagnostics + +Goal: tuning of the estimation engine (always available — these `number`s +write to HA options, not the pump, per `DESIGN.md §5.0/§5.3a`) plus raw +diagnostics. Modernises **dashboard 4** (which mixed `input_number` helpers). + +**Section: Estimation calibration.** (`type: grid`) + +- **Card: entities** — the Config-category `number`s (all native now, no more + `input_number` helpers): `number.dimplex_wpm_analytics_k_dhw`, + `analytics_k_defrost`, `analytics_k_defrost_loss`, + `analytics_heater_2nd_power_w`, `analytics_pump_main_power_w`, + `analytics_pump_floor_power_w`, `analytics_alpha_base`, + `analytics_alpha_sensitivity`, `analytics_alpha_deadband`. Group with a + `header:` and a `markdown` explaining each knob (carries the intent of + dashboard 4 but with proper labels). + +**Section: Live estimation cross-check.** (`type: grid`) + +- **Card: entities/glance** — for tuning, show the inputs and outputs + side-by-side: `controller_inverter_frequency`, + `analytics_compressor_power_est`, `analytics_cop_en14511`, + `analytics_delta_t`, `analytics_estimated_flow`. Plus, when meters exist, + the measured counterparts so the user can calibrate against ground truth. + +**Section: Controller diagnostics.** (`type: grid`) + +- **Card: entities** — `controller_info` (host/port/unit/firmware/profile in + attributes), software version, raw `status_code`/`lock_code`/`fault_code`/ + `sensor_error_code` (the numeric diagnostics), all `runtime_*`. +- **Card: entities (outputs)** — collapsible list of the coil + `binary_sensor`s (compressor 1/2, pumps M13/M14/M15/M20, mixers, DHW/pool/ + solar pumps, general fault). These are `EntityCategory.DIAGNOSTIC`, kept off + the daily views. + +--- + +## 3. Measured vs estimated UX + +This is the dashboard's most important contract: a value the user reads must +be unmistakably tagged as **meter-measured** or **engine-estimated**. The +integration gives us two hooks (`DESIGN.md §5.0, §6`): + +1. A `source: measured | estimated` **attribute** on every + power/heat/flow/COP/energy entity. +2. **Capabilities** (`has_electric_meter`, `has_heat_meter`, + `has_flow_sensor`, `has_inverter_freq`) that gate which entities even exist. + +### Strategy: gate by capability, label by attribute + +Two complementary mechanics: + +**(a) Separate entities, gated by capability (preferred for energy/power).** +Where the integration creates a *different entity* for measured vs estimated +(e.g. `analytics_energy_total_kwh_measured` exists only with `has_heat_meter`, +else `analytics_energy_total_kwh_estimated` exists), the dashboard uses a +`conditional` card keyed on entity existence/availability. Result: the +Energy view shows the *measured* card on metered installs and the *estimated* +card otherwise — the user never sees both for the same quantity, and there is +no ambiguity. This is the cleanest pattern and matches `DESIGN.md §6` +("encje measured/estimated … brak sprzętu → encje estimated"). + +> If the implementation instead exposes **one** entity per quantity whose +> `source` attribute flips, use pattern (b) for the badge and a +> `state_attr(...,'source')`-templated note instead of conditional existence. + +**(b) Per-value badge by `source` attribute (always-on labelling).** +Regardless of (a), every estimated value carries a visible marker so it reads +correctly even out of context: + +- **Name suffix** " (est.)" / " (meter)" on the card config. +- **Icon badge:** estimated → `mdi:calculator-variant`; measured → + `mdi:gauge`. On `tile`/`entities`, drive this with a `card_mod`-free, + template-free approach where possible: set static `icon`/`name` per card + because we already *know* the source from the capability gate. Where the + source can change at runtime, use a `custom:template-entity-row` (from + `lovelace-card-mod`'s sibling `template-entity-row`) or a small + `markdown`/`template` card reading + `{{ state_attr('sensor.dimplex_wpm_analytics_cop_en14511','source') }}`. +- **Chart stroke (apexcharts):** measured = solid, estimated = dashed + + lighter opacity, set per-series. This makes the measured-vs-estimated split + legible at a glance on View 2's flow cross-check. +- **A standing legend `markdown` card** at the top of View 2: + *"`mdi:gauge` = from a physical meter · `mdi:calculator-variant` = + estimated by the analytics engine (no meter installed)."* + +### Adapting to absent modules / capabilities + +Every module-specific card is wrapped in a **`conditional`** (sections view +also supports per-card `visibility:` conditions, which is preferred in +2025/2026 over the older `conditional` card type): + +```yaml +visibility: + - condition: state # card hidden when entity is unavailable/absent + entity: sensor.dimplex_wpm_pool_setpoint + state_not: unavailable +``` + +- **No DHW/Pool/Solar/Vent/Source module** → those entities are never created; + their cards' visibility condition fails → cards vanish. No broken "entity not + found" tiles. +- **No electric meter (`has_electric_meter = false`)** → measured power/energy + cards hidden; estimated cards shown; legend says "estimated". +- **No flow sensor** → the measured-flow series and its note are dropped from + View 2's flow card; only the estimate remains. +- **No `enable_control`** → entire View 4 collapses to its "control disabled" + notice (control entities don't exist to satisfy the gate). +- **No inverter freq (`has_inverter_freq = false`) and no electric meter** → + power estimate degrades (per `DESIGN.md §6`); the COP gauge and power charts + show a low-confidence note via a `markdown` conditional on + `source == 'estimated'` *and* a `confidence` attribute if exposed. + +Because all gating is condition-based, **one shipped YAML works across every +install**; nothing needs hand-editing as hardware/modules differ. (A future +backend nicety: generate the YAML pre-trimmed to the active capability set — +see §1 verdict — but the conditional approach means we don't *depend* on it.) + +--- + +## 4. Energy Dashboard integration + +The integration exposes native kWh sensors (`device_class: energy`, +`state_class: total_increasing`, `RestoreSensor`) precisely so they drop into +HA's native Energy Dashboard with zero helpers (`DESIGN.md §8`). The dashboard +docs/onboarding should direct users to **Settings → Dashboards → Energy** and +register: + +**Grid / consumption (Individual devices, or "Grid consumption" if it's the +pump's whole feed):** + +- `sensor.dimplex_wpm_analytics_energy_total_kwh` — total electrical energy + (measured if `has_electric_meter`, else estimated). **Primary entity to add.** +- Optionally the breakdown as **Individual devices**: + `analytics_energy_compressor_kwh`, `analytics_energy_heater_kwh`. + +**Important guidance text (ship in README / view-2 markdown):** + +> Add **one** electrical-energy entity to avoid double counting: either the +> total (`…_energy_total_kwh`) **or** the compressor + heater breakdown — not +> both. The total is the safe default. +> +> The **thermal** energy sensors (`…_energy_to_house_kwh`, +> `…_energy_to_installation_kwh`, measured `5096–5129` heat-meter sums where a +> heat meter exists) are **heat delivered, not electricity consumed** — do +> **not** add them as grid consumption. They are useful in the *Energy* +> dashboard only as context; prefer viewing them on the integration's Heat & +> Energy view (View 2). If you want a COP/SPF-style ratio in Energy, that is +> out of scope for the native Energy Dashboard — use View 2's COP chart. +> +> If you prefer custom billing cycles, the optional documented helpers +> (`utility_meter` on `…_energy_total_kwh`) give monthly/peak-offpeak buckets +> without us re-implementing calendar cycles (`DESIGN.md §8`). + +When `has_electric_meter` is true the registered total is the **meter +integral** (`5170`); when false it is the **estimate integral** — but the +entity id, `device_class`, and `state_class` are identical, so the Energy +Dashboard behaves the same either way (`DESIGN.md §8`). The `source` attribute +lets View 2 still badge it. + +--- + +## 5. Dependencies + +| Card | Source | v1 status | Used for | Built-in fallback | +|------|--------|-----------|----------|-------------------| +| (core) sections, tile, gauge, entities, history-graph, statistics-graph, thermostat, conditional, markdown, energy-* | **HA built-in** | **required (already present)** | everything structural + control + LTS | — | +| `apexcharts-card` | HACS (`RomRider/apexcharts-card`) | **recommended, optional** | View 2 multi-series / dual-axis / stacked / dashed-vs-solid | `history-graph` (provided) | +| `mushroom` | HACS (`piitaya/lovelace-mushroom`) | **optional, cosmetic** | Overview chips, denser climate | `glance` / `tile` (provided) | +| `template-entity-row` *(or card-mod)* | HACS | **optional** | runtime `source`-attribute badge when source can flip | static per-card naming (provided) | + +Principles: +- **Built-in first.** Every view renders with **zero HACS frontend installs**; + we ship both an apexcharts section and a history-graph section and let users + delete the one they don't want. +- **Only two cards are worth recommending:** `apexcharts-card` (real + functional gap) and `mushroom` (polish). Both are top-tier-popularity, so + the install ask is light. +- **No custom backend-registered resources** — the integration ships YAML + files under `dashboards/` for manual/UI import (`DESIGN.md §9`), not + auto-registered cards. The v2 custom card, if built, lives in a **separate + HACS frontend repo**. + +--- + +## 6. Concrete YAML sketches + +Placeholder entity ids per the convention in the header. The implementation +team extends these patterns to the full entity set. + +### 6.1 Overview status section (built-in only: sections + tile + gauge) + +```yaml +# View 1 — Overview, "Heat pump now" section +type: grid +cards: + - type: heading + heading: Heat pump now + icon: mdi:heat-pump + - type: tile + entity: sensor.dimplex_wpm_controller_outdoor_temperature + name: Outdoor + - type: tile + entity: sensor.dimplex_wpm_hc1_flow_temperature + name: Flow + - type: tile + entity: sensor.dimplex_wpm_hc1_return_temperature + name: Return + - type: tile + entity: sensor.dimplex_wpm_dhw_temperature + name: DHW + visibility: + - condition: state + entity: sensor.dimplex_wpm_dhw_temperature + state_not: unavailable + - type: gauge + entity: sensor.dimplex_wpm_analytics_cop_en14511 + name: COP (est.) + min: 0 + max: 6 + needle: true + severity: + green: 3.5 + yellow: 2 + red: 0 +``` + +### 6.2 Energy view — multi-series power/COP (apexcharts) with measured-vs-estimated styling, plus history-graph fallback + +```yaml +# View 2 — dual-axis: thermal kW (left) vs COP (right). Estimated = dashed. +type: custom:apexcharts-card +header: + title: Power & efficiency (6 h) + show: true +graph_span: 6h +yaxis: + - id: kw + decimals: 2 + apex_config: + title: { text: kW } + - id: cop + opposite: true + min: 0 + max: 6 + apex_config: + title: { text: COP } +series: + - entity: sensor.dimplex_wpm_analytics_thermal_power_loop + name: Heat output (est.) + yaxis_id: kw + stroke_width: 2 + # estimated -> dashed + extend_to: now + - entity: sensor.dimplex_wpm_analytics_total_power + name: Electric power + yaxis_id: kw + - entity: sensor.dimplex_wpm_analytics_cop_en14511 + name: COP (est.) + yaxis_id: cop + stroke_width: 1 +--- +# Fallback (built-in) if apexcharts is not installed — drop one or the other. +type: history-graph +title: Power & heat (6 h) +hours_to_show: 6 +entities: + - entity: sensor.dimplex_wpm_analytics_thermal_power_loop + - entity: sensor.dimplex_wpm_analytics_thermal_power_compressor + - entity: sensor.dimplex_wpm_analytics_thermal_power_heater + - entity: sensor.dimplex_wpm_analytics_thermal_power_defrost_loss + - entity: sensor.dimplex_wpm_analytics_total_power +``` + +### 6.3 Measured-vs-estimated: capability-gated conditional + source badge + +```yaml +# Energy totals row — show MEASURED card only when the heat meter exists, +# otherwise the ESTIMATED card. Separate entities, gated by availability. +type: grid +cards: + - type: entities + title: Heat delivered (meter) + visibility: + - condition: state + entity: sensor.dimplex_wpm_analytics_energy_to_house_kwh_measured + state_not: unavailable + entities: + - entity: sensor.dimplex_wpm_analytics_energy_to_house_kwh_measured + name: To house (meter) + icon: mdi:gauge + - type: entities + title: Heat delivered (estimated) + visibility: + - condition: state + entity: sensor.dimplex_wpm_analytics_energy_to_house_kwh_estimated + state_not: unavailable + entities: + - entity: sensor.dimplex_wpm_analytics_energy_to_house_kwh_estimated + name: To house (est.) + icon: mdi:calculator-variant + +# Runtime badge when ONE entity flips its `source` attribute instead: +- type: markdown + content: > + COP source: + **{{ state_attr('sensor.dimplex_wpm_analytics_cop_en14511','source') }}** + {% if state_attr('sensor.dimplex_wpm_analytics_cop_en14511','source') + == 'estimated' %}— no heat meter; value is engine-estimated.{% endif %} +``` + +### 6.4 Gated control entity (View 4) + +```yaml +# Only renders when control entities exist (enable_control on); otherwise the +# notice card shows instead. +type: grid +cards: + - type: tile + entity: number.dimplex_wpm_dhw_setpoint + name: DHW setpoint + features: + - type: numeric-input + style: slider + visibility: + - condition: state + entity: number.dimplex_wpm_dhw_setpoint + state_not: unavailable + - type: markdown + content: > + Control is disabled. Enable **Advanced control** in the Dimplex WPM + integration options to expose setpoints, SG Ready, and climate. + visibility: + - condition: state + entity: number.dimplex_wpm_dhw_setpoint + state: unavailable +``` + +--- + +## 7. Implementation notes (for the dashboard team) + +- Ship files under `dashboards/`: `overview.yaml`, `energy.yaml`, + `history.yaml`, `control.yaml`, `diagnostics.yaml`, plus a combined + `dimplex_wpm.yaml` (full multi-view dashboard) for one-shot import. README + documents both raw-config-editor import and `mode: yaml` inclusion. +- Keep **two parallel sections** on View 2 (apexcharts + history-graph) and + tell users to delete one. Don't make apexcharts a hard dependency. +- Prefer per-card `visibility:` (sections) over the legacy `conditional` card + wrapper — cleaner and the 2025/2026 idiom. +- Lock entity ids in one place once the integration's `unique_id`/slug scheme + is final; this doc's slugs are the contract to confirm against the first + real install (run the integration, read `states`, sed-replace). +- Carry forward the *good instincts* from the legacy dashboards: the "2nd heat + generator running" signal (dashboard 1), the COP/flow/estimated-flow cross- + check including an external flow sensor (dashboards 2/3), and the dedicated + calibration page (dashboard 4) — but with native entities, split graphs, + long-term stats, capability gating, and explicit estimated-vs-measured + badging. +``` diff --git a/spec/ENTITY_IDS.md b/spec/ENTITY_IDS.md new file mode 100644 index 0000000..2cbd873 --- /dev/null +++ b/spec/ENTITY_IDS.md @@ -0,0 +1,113 @@ +# Dimplex WPM — entity_id reference (derived scheme) + +> How HA builds these: entities use `has_entity_name = True` with a +> `translation_key`. The entity_id is derived from the **English** entity name +> (`entity_id = .slugify(" ")`), so ids are +> language-independent even though display names are localized (en/pl/de). The +> dashboards in `dashboards/` reference exactly these ids. +> +> **Assumes default naming** — if you rename a device/entity in HA, its +> entity_id changes and you must update the dashboard reference. + +## Device-name → entity_id prefix + +| Device (module) | Prefix | +|---|---| +| Controller (`controller`) | `dimplex_wpm_` | +| Heating circuit 1 (`hc1`) | `heating_circuit_1_` | +| Heating circuits 2/3 (`hc2_3`) | `heating_circuits_2_3_` | +| Domestic hot water (`dhw`) | `domestic_hot_water_` | +| Swimming pool (`pool`) | `swimming_pool_` | +| Ventilation (`vent`) | `ventilation_` | +| Solar (`solar`) | `solar_` | +| Heat source (`source`) | `heat_source_` | +| Passive cooling (`cooling`) | `passive_cooling_` | +| Analytics (`energy`) | `analytics_` | + +Only the Controller carries the `dimplex_wpm_` prefix (it is the hub device, +named "Dimplex WPM"); sub-devices use their own short name as the prefix. This +is standard Home Assistant behaviour with `has_entity_name`. + +## Controller (`sensor.` / `binary_sensor.` / `select.`) +- `sensor.dimplex_wpm_outdoor_temperature` +- `sensor.dimplex_wpm_status` (text) · `sensor.dimplex_wpm_status_code` [diag] +- `sensor.dimplex_wpm_lock` · `sensor.dimplex_wpm_lock_code` [diag] +- `sensor.dimplex_wpm_fault` · `sensor.dimplex_wpm_fault_code` [diag] +- `sensor.dimplex_wpm_sensor_error` · `sensor.dimplex_wpm_sensor_error_code` [diag, L/M only] +- `sensor.dimplex_wpm_operating_mode` · `sensor.dimplex_wpm_party_hours` · `sensor.dimplex_wpm_holiday_days` +- `sensor.dimplex_wpm_inverter_frequency` [diag, RE/cap] +- `sensor.dimplex_wpm_sg_ready_state` · `sensor.dimplex_wpm_sg_ready_code` [diag] +- `sensor.dimplex_wpm_controller_info` [diag; host/profile/capabilities in attrs] +- runtimes [diag]: `sensor.dimplex_wpm_compressor_1_runtime`, `..._compressor_2_runtime`, + `..._primary_pump_fan_runtime`, `..._2nd_heat_generator_runtime`, + `..._immersion_heater_runtime`, `..._auxiliary_circulation_pump_runtime` +- `binary_sensor.dimplex_wpm_fault_active` · `binary_sensor.dimplex_wpm_lock_active` +- inputs [diag]: `binary_sensor.dimplex_wpm_smartgrid_input_1`, `..._smartgrid_input_2`, + `..._utility_evu_lockout`, `..._external_lockout` +- output coils [diag]: `binary_sensor.dimplex_wpm_compressor_1`, `..._compressor_2`, + `..._primary_pump_fan`, `..._2nd_heat_generator`, `..._immersion_heater`, + `..._auxiliary_circulation_pump`, `..._general_fault_output`, + `..._heating_pump_m14`, `..._heating_pump_m20` +- control (only with `enable_control`): `select.dimplex_wpm_sg_ready_mode`, + `select.dimplex_wpm_operating_mode` + +## Heating circuit 1 (`hc1`) +- `sensor.heating_circuit_1_flow_temperature` · `..._return_temperature` · + `..._return_setpoint_temperature` +- `sensor.heating_circuit_1_room_temperature_1` · `..._room_temperature_2` · + `..._room_humidity_1` · `..._room_humidity_2` +- `sensor.heating_circuit_1_heating_pump_m13_runtime` [diag] · + `binary_sensor.heating_circuit_1_heating_pump_m13` [diag] +- control: `number.heating_circuit_1_hc1_room_setpoint`, + `number.heating_circuit_1_hc1_fixed_flow_setpoint`, + `number.heating_circuit_1_hc1_heating_curve_end`, + `number.heating_circuit_1_hc1_curve_offset` +- control: `climate.heating_circuit_1_thermostat` (current = room temp/return, + target = room setpoint) + +## Domestic hot water (`dhw`) +- `sensor.domestic_hot_water_dhw_temperature` · `..._dhw_setpoint_temperature` +- `sensor.domestic_hot_water_dhw_pump_runtime` [diag] · + `binary_sensor.domestic_hot_water_dhw_pump` [diag] +- control: `number.domestic_hot_water_dhw_setpoint`, `..._dhw_setpoint_minimum`, + `..._dhw_setpoint_maximum` +- control: `climate.domestic_hot_water_thermostat` (current = DHW temp, + target = DHW setpoint) + +## Heat source (`source`) +- `sensor.heat_source_source_inlet_temperature` · `..._source_outlet_temperature` + +## Analytics (`energy`) — present when `estimation_possible` (lak9 profile + inverter freq) +- `sensor.analytics_cop_est` (+ `sensor.analytics_cop_measured` with both meters) +- `sensor.analytics_electrical_power` · `sensor.analytics_heat_output` (source attr) +- `sensor.analytics_compressor_power_est` · `..._heater_power_est` · `..._total_electrical_power_est` +- `sensor.analytics_compressor_heat_output_est` · `..._heater_heat_output_est` · + `..._defrost_heat_loss_est` · `..._loop_heat_output_est` +- `sensor.analytics_heat_to_house_est` · `..._heat_to_installation_est` · `..._house_heat_fraction` +- `sensor.analytics_flow_rate` · `sensor.analytics_flow_rate_smoothed` +- `sensor.analytics_temperature_difference` · `sensor.analytics_temperature_difference_rate` +- energy (kWh, total_increasing): `sensor.analytics_electrical_energy`, + `sensor.analytics_heat_energy`, `sensor.analytics_heat_energy_to_house`, + `sensor.analytics_heat_energy_to_installation` +- calibration [config]: `number.analytics_calibration_dhw_power_factor`, + `..._calibration_defrost_power_factor`, `..._calibration_defrost_heat_loss_factor`, + `..._calibration_2nd_source_heater_power`, `..._calibration_main_pump_power`, + `..._calibration_floor_pump_power`, `..._calibration_alpha_base`, + `..._calibration_alpha_sensitivity`, `..._calibration_alpha_deadband` + +With a heat meter: `sensor.analytics_heating_energy_meter`, `..._dhw_energy_meter`, +`..._environmental_energy_meter` (+ `..._pool_energy_meter` with pool module); +the estimated `..._heat_energy` is then suppressed (no double count). +With an electric meter the raw register sensors also appear: +`sensor.analytics_electrical_power_meter` (5170) and +`sensor.analytics_heat_output_power` (5168); the canonical +`sensor.analytics_electrical_power` / `analytics_heat_output` carry the +`source` attribute (measured vs estimated). + +## Optional modules (only when enabled) +- HC2/3: `sensor.heating_circuits_2_3_hc2_temperature`, `..._hc3_temperature`, setpoints… +- Pool: `sensor.swimming_pool_*` · `number.swimming_pool_pool_setpoint` (control) +- Ventilation: `sensor.ventilation_supply_air_temperature`, `..._supply_fan_speed`, … +- Solar: `sensor.solar_collector_temperature`, `sensor.solar_tank_temperature` +- Passive cooling: `sensor.passive_cooling_flow_temperature`, + `..._return_temperature`, `..._primary_return_temperature` diff --git a/spec/REGISTERS.md b/spec/REGISTERS.md new file mode 100644 index 0000000..d41bd7f --- /dev/null +++ b/spec/REGISTERS.md @@ -0,0 +1,237 @@ +# Dimplex NWPM (WPM Touch) — kanoniczna mapa rejestrów Modbus TCP + +> **Jedyne źródło prawdy** dla generowania EntityDescription w komponencie +> `dimplex_wpm`. Scala: oficjalna wiki Dimplex (autorytet) + `DimplexModbusHA` +> + `DimplexModbusHACS` + lokalne YAML. Surowy zrzut wiki: +> [`dimplex_modbus_spec_raw.md`](dimplex_modbus_spec_raw.md). +> Sprzęt: NWPM Touch (art. 378800), Modbus TCP port 502, od WPM Software **M3.3**. +> +> **Status: ZWERYFIKOWANO (2026-06-21)** — adwersaryjna kontrola 4 agentami +> wobec surowego spec, rejestr po rejestrze. Zero błędów krytycznych/poważnych. + +## 0. Reguły ogólne (ważne korekty względem dotychczasowych źródeł) + +1. **Typy obiektów wg FC:** spec wspiera tylko `FC01 Read Coils`, + `FC03 Read Holding`, `FC05/06/15/16 Write`. **Brak FC04 (input registers).** + → Wszystkie wartości analogowe to **holding (FC03)**, cyfrowe to **coil (FC01)**. + Lokalne YAML używało `input_type: input` (FC04) — **do zmiany na holding**. + (To tłumaczy „register_strategy: auto/holding/input" w repo HACS — obejście.) + **Domyślnie czytamy holding (FC03).** +2. **Energia 5096–5129 to grupy CYFR jednego licznika, nie kwartały.** + `Wärmemenge = reg(9-12)·100000000 + reg(5-8)·10000 + reg(1-4)`. Nazwy „1-4 / + 5-8 / 9-12" = pozycje cyfr, **nie** miesiące. Dotychczasowa interpretacja + kwartalna (3 osobne sensory `total_increasing`) jest **błędna** — liczymy + jeden sumaryczny sensor kWh na kategorię. +3. **Adres rejestru status/blokada/błąd zależy od wersji softu** (patrz §1). + L/M: 103/104/105/106 · J: 43/59/42/— · H: 14/94/13/—. Mapy wartości też się + różnią. **Urządzenie użytkownika = software L/M** (potwierdzone: mapy + status/lock/fault z lokalnego YAML zgadzają się 1:1 z kolumną L/M). +4. **Realne rejestry mocy istnieją (WPM M3.5+):** `5170` moc elektryczna, + `5168` moc grzewcza (oddana), `W/10` → kW = `value·0.01`. **Gdy dostępne, + preferujemy odczyt nad estymacją**; estymacja = fallback dla starszego softu + i cross-check. (Uwaga: DPT podany jako uint16, ale zakres ±327670 → traktować + jako int16 W/10 i zweryfikować znak na realnym urządzeniu.) +5. **Częstotliwość inwertera (114, 0.1 Hz) NIE jest w oficjalnym spec** — to + reverse engineering użytkownika. Zostaje, oznaczone `RE/undocumented`; + kluczowe dla LUT mocy (fallback gdy brak 5170). +6. **Konflikt:** stare repo mapowało `9/10` = skraplacz in/out — to **kłóci się** + z oficjalnym `9` = temp. 2. obiegu, `10` = temp. 3. obiegu. Rejestry gazu/ + ssania/parownika (`8/98/108/109`) są prawdopodobne-niedokumentowane → + `RE/verify` na realnym LAK9, poza domyślną mapą. +7. **Rejestr 10 jest współdzielony:** temp. 3. obiegu (R13) **albo** kolektor + solarny (R23) — zależnie od konfiguracji instalacji (profil/moduł). +8. **Stuby z repo HACS rozwiązane:** BMS temp. zewn. `112` (R/W), Sperre Extern + `5130` (R/W), Heartbeat `5063`(W)/`5064`(R). +9. **Programy czasowe multipleksują rejestr `5065`** (selektor 1..12) + `5066–5081` + = parametry wybranego programu. Interfejs stanowy/ekspercki → **odłożone** + (osobny milestone / premium „automatyzacja taryf"). +10. **Nastawy z kodowaniem enum:** Parallelverschiebung `5036`/`5086` (0..38 → + −19..+19 K), Kühlung Raumsoll `5089` (0..30 → 15.0..30.0 °C) — wymagają + dekodera przy wyświetlaniu/zapisie. + +Legenda: **Obj** = coil/holding · **R/W** wg spec · **Sc** = scale · **Ver** = +wersje softu · `†` = do potwierdzenia na realnym urządzeniu. + +--- + +## 1. Status / diagnostyka (adres zależny od wersji!) + +| Wartość | Obj | R/W | Typ | L/M addr | J addr | H addr | Nazwa | +|--:|--|--|--|--:|--:|--:|--| +| Status (Statusmeldungen) | holding | R | uint16 | **103** | 43 | 14 | kod stanu pracy → tekst | +| Blokada (Sperrmeldungen) | holding | R | uint16 | **104** | 59 | 94 | kod blokady → tekst | +| Błąd (Störmeldungen) | holding | R | uint16 | **105** | 42 | 13 | kod usterki → tekst | +| Sensor error (Sensorfehler) | holding | R | uint16 | **106** | — | — | tylko L/M | +| Software Version | holding | R | uint16 | 65 | 65 | 65 | 0:--,1:A…26:Z | +| Software Nummer | holding | R | uint16 | 66 | 66 | 66 | cyfra | +| Software Index | holding | R | uint16 | 67 | 67 | 67 | cyfra | + +Wartości min/max: status 0–30, blokada 1–42, błąd 1–31, sensor error 1–27 (L/M). +(Uwaga: tabela enumów Sensorfehler ma wartości do 30 — niespójność po stronie +wiki; w mapie tekstów przyjmujemy pełen zakres do 30.) + +--- + +## 2. Dane bieżące / temperatury (holding R, int16, scale 0.1 °C — chyba że zazn.) + +| Addr | Nazwa (EN) | Moduł | Uwagi | +|--:|--|--|--| +| 1 | Outdoor temperature (R1) | controller | | +| 2 | Return temperature (R2) | hc1 | | +| 53 | Return setpoint temperature | hc1 | | +| 3 | DHW temperature (R3) | dhw | | +| 58 | DHW setpoint temperature | dhw | | +| 5 | Flow temperature (R9) | hc1 | | +| 6 | Source inlet temp (R24)† | source | gwiazdka w spec (wariant) | +| 7 | Source outlet temp (R6) | source | | +| 54 | HC2 setpoint | hc2_3 | | +| 9 | HC2 temperature (R5) | hc2_3 | konflikt: stare repo=skraplacz-in (błędne) | +| 55 | HC3 setpoint | hc2_3 | | +| 10 | HC3 temperature (R13) **/ Solar collector (R23)** | hc2_3/solar | współdzielony | +| 11 | Room temperature 1 / RT-RTH Econ | hc1 | (R/W w trybie BMS, patrz §13) | +| 12 | Room temperature 2 | hc1 | | +| 13 | Room humidity 1 (0.1 %) | hc1 | (R/W w trybie BMS) | +| 14 | Room humidity 2 (0.1 %) | hc1 | | +| 19 | Passive cooling flow temp (R11) | cooling | | +| 20 | Passive cooling return temp (R4) | cooling | | +| 21 | Passive/active cooling primary return (R24) | cooling | | +| 23 | Solar tank temp (R22) | solar | | +| 120 | Ventilation outdoor air temp | vent | | +| 121 | Ventilation supply air temp | vent | | +| 122 | Ventilation extract air temp | vent | | +| 123 | Ventilation exhaust air temp | vent | | +| 125 | Supply fan speed (1/min) | vent | scale 1 (int16) — **uwaga: coil 125 to inny obiekt** | +| 126 | Extract fan speed (1/min) | vent | scale 1 (int16) | + +**RE/verify (poza domyślną mapą, profil LAK9 do potwierdzenia na sprzęcie):** +114 inverter frequency (0.1 Hz, RE), 8 hot gas†, 98 evaporator out†, +107 indoor humidity†, 108 suction gas†, 109 evaporator mid†. + +--- + +## 3. Tryb pracy / wentylacja (holding R/W) + +| Addr | Nazwa | Zakres | Enum | +|--:|--|--|--| +| 5015 | Operating mode (BA_aktiv) | 0–5 | 0:Sommer 1:Winter 2:Urlaub 3:Party 4:2.WE 5:Kühlen | +| 5016 | Party hours | 0–72 h | | +| 5017 | Holiday days | 0–150 d | | +| 5034 | Ventilation level | 0–5 | | +| 127 | Ventilation boost time | 15–90 | (holding R/W; coil 127≠) | + +--- + +## 4. Runtime'y (holding R, uint16, h, total_increasing) — diagnostic + +72 Verdichter1 · 73 Verdichter2 · 74 Primärpumpe/Ventilator(M11) · +75 2.Wärmeerzeuger(E10) · 76 Heizungspumpe(M13) · 77 Warmwasserpumpe(M18) · +78 Flanschheizung(E9) · 79 Schwimmbadpumpe(M19) · 71 Zusatzumwälzpumpe(M16) `ab L12`. + +--- + +## 5. Energia / ilości ciepła (holding R, uint16, kWh) — grupy cyfr! + +Kategorie: Heizen `5096/5097/5098`, Warmwasser `5099/5100/5101`, +Schwimmbad `5102/5103/5104`, Umwelt(środowiskowa) `5127/5128/5129`. +**Sensor wynikowy/kategoria:** `total = reg(9-12)·1e8 + reg(5-8)·1e4 + reg(1-4)` +→ `device_class: energy`, `state_class: total_increasing`. (NIE 3 osobne kwartały.) + +--- + +## 6. Moc / EMS (holding) + +| Addr | Nazwa | R/W | Typ | Sc/Unit | Uwagi | +|--:|--|--|--|--|--| +| 5168 | Heat output (Leist_Heiz) | R | int16† | ·0.01 kW (W/10) | M3.5+ | +| 5170 | Electrical power (Leist_Elekt) | R | int16† | ·0.01 kW (W/10) | M3.5+, preferowane nad estymacją | +| 5182 | PV surplus (PV_Ueberschuss) | R/W | int16† | ·0.01 kW (W/10) | obecnie tylko rejestracja | +| 112 | BMS outdoor temp (extern) | R/W | int16 | 0.1 °C | wstrzyk. temp. zewn. (stub HACS) | +| 5130 | External lock (Sperre Extern) | R/W | uint16 | 0–11 | 0:HW 10:nieaktywna 11:aktywna | +| 5063 | Heartbeat In | W | uint16 | 0–65535 | watchdog | +| 5064 | Heartbeat Out | R | uint16 | 0–65535 | watchdog | + +--- + +## 7. Nastawy 1. obieg (holding R/W) + +| Addr | Nazwa | Zakres | Unit | Uwagi | +|--:|--|--|--|--| +| 5036 | Parallelverschiebung (curve offset) | 0–38 | enum | dekod: `K = value − 19` (19→0) | +| 46 | Room temperature setpoint | 15.0–30.0 | °C | | +| 5037 | Fixed flow setpoint | 18–60 | °C | | +| 5038 | Heating curve end | 20–70 | °C | | +| 47 | Hysteresis | 0.5–5.0 | K | | +| 5043 | Cooling room setpoint (15°C AT) | 10–35 | °C | (≤L22.9 dynamiczna) | +| 5134 | Cooling room setpoint (35°C AT) | 10–35 | °C | ab L22.9 | + +## 8. Nastawy 2./3. obieg (holding R/W) + +5082 wybór obiegu (2/3) · 5084 curve end (20–70°C) · 5085 fixed temp (20–60°C) · +5086 curve offset (0–38, dekod `K = value − 19`) · 5087 mixer run time (1–6 min) · +93 mixer hysteresis (0.5–2.0 K) · 5088 max temp (30–70°C) · +5089 cooling room setpoint (0–30, dekod `°C = 15.0 + value·0.5`). + +## 9. Nastawy CWU (holding R/W) +5045 hysteresis (2–15 K) · 5047 setpoint (min..85°C) · 5145 setpoint min (10..soll) · +5048 setpoint max (soll..85°C). + +## 10. Nastawy basen / 2. źródło (holding R/W) +Basen: 5049 hysteresis (1–20 K) · 5051 setpoint (5–60°C). +2. źródło: 48 mixer hysteresis (0.5–2.0 K) · 5020 parallel limit temp +(**int16**, −25..35 °C — wartość ujemna ⇒ znakowany, mimo „uint16" w spec) · +5021 mixer run time (30–85 min). + +--- + +## 11. Smart Grid / SG Ready +- `5167` holding R/W (0–13): 0:Hardware 10:gelb 11:grün 12:rot 13:dunkelgrün. +- Coile wejść: `3` SmartGrid1, `4` SmartGrid2 (stan: rot=01, gelb=00, grün=10, + dunkelgrün=11). `5` EVU-Sperre, `6` Sperre Extern (R). + +## 12. Wyjścia (coile R, diagnostic) — `WPM J/L` +41 Verdichter1 · 42 Verdichter2 · 43 Primärpumpe(M11)/Ventilator(M2) · +44 2.Wärmeerzeuger(E10) · 45 Heizungspumpe(M13) · 46 Warmwasserpumpe(M18) · +47/48 Mischer M21 Auf/Zu · 49 Zusatzumwälzpumpe(M16) · 50 Flanschheizung(E9) · +51 Heizungspumpe(M15) · 52/53 Mischer M22 Auf/Zu · 56 Schwimmbadpumpe(M19) · +57 Sammelstörmeldung(H5) · 59 Heizungspumpe(M14) · 60 Kühlpumpe(M17) · +61 Heizungspumpe(M20) · 66 Umschaltung Heizen/Kühlen(N9) · +68 Primärpumpe Kühlen(M12) · 71 Solarpumpe(M23). +Coil R/W: `19` Freigabe Zirkulationspumpe. + +--- + +## 13. Odłożone / eksperckie (osobny milestone) +- **Programy czasowe** (1./2./3. HK Absenk/Anheb, WW Sperre/Desinfektion/ + Zirkulation, Schwimmbad Sperre/Vorrang): multipleks `5065` (selektor) + + `5066–5081`. Stanowe, ryzykowne — premium „automatyzacja taryf". +- **Zeitabgleich** (czas): 5006–5011 + coile 102–107 (set-flags W). +- **Raumtemperaturregelung BMS**: 5065 (adresy 50–79) + 11/13/5081/5164/coil 177. + +--- + +## 14. Enum: wartości statusów (L/M = cel; J/H alternatywnie) + +**Status (103 L/M):** 0 Off · 2 Heating · 3 Pool · 4 DHW · 5 Cooling · +10 Defrost · 11 Flow monitoring · 24 Mode-switch delay · 30 Lock(→104). +**Blokada (104 L/M):** 2 Volumenstrom · 5 Funktionskontrolle · 6 Einsatzgrenze HT · +7 Systemkontrolle · 8 Verzög. Kühlen · 9 Pumpenvorlauf · 10 Mindeststandzeit · +11 Netzbelastung · 12 Schaltspielsperre · 13 WW Nacherwärmung · 14 Regenerativ · +15 EVU-Sperre · 16 Sanftanlasser · 17 Durchfluss · 18 Einsatzgrenze WP · +19 Hochdruck · 20 Niederdruck · 21 Einsatzgrenze Wärmequelle · 23 System Grenze · +24 Last Primärkreis · 25 Sperre Extern · 29 Inverter · 31 Aufwärmen · +33 EvD Init · 34 2.WE freigegeben · 35 Störung(→105). +**Błąd (105 L/M):** 0 brak · 1–4 N17.1–4 · 6 EEV · 10 WPIO · 12 Inverter · +13 WQIF · 15 Sensorfehler(→106) · 16 Niederdruck Sole · 19 !Primärkreis · +20 !Abtauen · 21 !Niederdruck Sole · 22 !Warmwasser · 23 !Last Verdichter · +24 !Codierung · 25 !Niederdruck · 26 !Frostschutz · 28 !Hochdruck · +29 !Temp.Differenz · 30 !Heißgas · 31 !Durchfluss · 32 !Aufwärmen. +**Sensor error (106 L/M):** 1 R1 outdoor · 2 R2 return · 3 R3 DHW · 4 R7 coding · +5 R9 flow · 6 R5 HC2 · 7 R13 HC3 · 8 R13 regen · 9/10 room 1/2 · 11 R6 source out · +12 R24 source in · 14 R23 collector · 15 R25 LP · 16 R26 HP · 17/18 room hum 1/2 · +19 frost-cold · 20 hot gas · 21 R2.1 return · 22 R20 pool · 23 R11 pas.cool flow · +24 R4 pas.cool return · 26 R22 solar tank · 28 R2.2 heat demand · 29 RTM Econ · +30 R39 cool demand. + +> Pełne kolumny J/H oraz blokady H/J — w [`dimplex_modbus_spec_raw.md`](dimplex_modbus_spec_raw.md) +> (sekcje Statusmeldungen/Sperrmeldungen/Störmeldungen). Do komponentu wnosimy +> jako mapy wersjonowane (jak w repo HACS), z **adresami** też wersjonowanymi. diff --git a/spec/dimplex_modbus_spec_raw.md b/spec/dimplex_modbus_spec_raw.md new file mode 100644 index 0000000..bc399b4 --- /dev/null +++ b/spec/dimplex_modbus_spec_raw.md @@ -0,0 +1,812 @@ +# Dimplex NWPM Modbus TCP — RAW spec dump (z oficjalnej wiki) + +Źródło: https://dimplex.atlassian.net/wiki/spaces/DW/pages/3303571457 + + +## Modbus TCP - Anbindung +`id 3303571457` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3303571457 + + +_tabela 1_ + +NWPM Touch-Erweiterung +2 Hard- und Software +Bestellkennzeichen | NWPM Touch +Artikelnummer | 378800 +Betriebsbedingungen | -40 bis 70°C +Ethernet-Schnittstelle | RJ45 10/100BaseT Cat5 max. 100m +Protokoll | Modbus TCP +Modbus TCP Port | 502 +Betriebssystem | Linux 4.11.11 +Einsetzbar ab WPM Software | M3.3 +Auslieferungszustand mit +Firmware ab | A2.1.0 - B2.1.0 +Benutzeroberfläche ab | 0.60.0 +PlugIn Version ab | 5.0.4 + +_tabela 2_ + +Typ | R/W | Funktionscode | Modbus-Funktion +Digital | R | 01 (0x01) | Read Coils +Analog | R | 03 (0x03) | Read Holding Register +Digital | W | 05 (0x05) | Write Single Coil +Analog | W | 06 (0x06) | Write Single Register +Digital | W | 15 (0x15) | Write Multiple Coils +Analog | W | 16 (0x16) | Write Multiple Registers + +_tabela 3_ + +WPM Econ5 | WPM Touch +Wärmepumpenmanager spannungsfrei schalten +Abdeckung des Steckplatzes “Serial Card/BMS Card” mit einem kleinen Schraubendreher entfernen +Einbau der Erweiterung in den vorgesehenen Steckplatz; dabei muss auf den korrekten Sitz geachtet werdenHINWEIS Zum einfachen Einbau die Erweiterung leicht schräg einsetzen, dann aufrecht halten und nach unten Drücken. Anschließend auf festen Sitz achten! +Ausbrechen der vorhandenen Abdeckung +Schließen der Öffnung mittels AbdeckungWärmepumpenmanager mit Spannung versorgen + +_tabela 4_ + +Parameter | Einstellung | Einstellbereich +Netzwerk | Einstellung welche für die Aktivierung der NWPM Touch-Erweiterung vorgenommen werden muss. | Home App + + +## Modbus TCP - Datenpunktliste +`id 3303571683` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3303571683 + +(brak tabel) pages {"id":"b0353a5a-15b1-47ca-9916-a3da59df6ee5","config":{"showTabs":false,"type":"pages","columns":1,"pagination":"none","cardBorderRadius":"1","labelsColor":{"accent":"#000000","text":"#FFFFFF"},"spaceColor":{"accent":"#000000","text":"#FFFFFF"},"activeElements":["description"],"size":12,"tabs":{"t38kX5BODZ":{"enrichment":"dynamic","limit":"10","manuallyPicked":[{"id":"00c3a919-4ad3-40e7-8738-73302c10ecbc","position":0,"contentId":"3303571683"}],"contributors":[],"labels":[],"spaces":[],"excludePersonalSpaces":false,"restrictedToCollections":[],"useCollections":false,"selectedCollections" + +## Modbus TCP - Funktionsbeschreibungen +`id 3341124048` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3341124048 + +(brak tabel) pages {"id":"2b39315d-1c10-446f-9839-e964f800fdb2","config":{"showTabs":false,"type":"pages","columns":1,"pagination":"infinite","cardBorderRadius":3,"labelsColor":{"accent":"#000000","text":"#FFFFFF"},"spaceColor":{"accent":"#000000","text":"#FFFFFF"},"activeElements":["description"],"size":12,"tabs":{"t38kX5BODZ":{"enrichment":"dynamic","limit":6,"manuallyPicked":[],"contributors":[],"labels":[],"spaces":[],"parentPage":"3341124048","excludePersonalSpaces":false,"restrictedToCollections":[],"useCollections":false,"selectedCollections":[]}},"variant":"mini_card","openInNewTab":false},"tabs":[ + +## Modbus TCP - Außentemperatur +`id 3372253185` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3372253185 + +Namen | Address | Datapoint Typ | COIL/REG | R/W | Range | Unit +Min | Max +Aussentemperatur BMS (extern) | 112 | int16 | Register | R/W | -999 | 999 | °C + + +## Modbus TCP - Energiemanagementsysteme / Anbindung +`id 3399811073` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3399811073 + + +_tabela 1_ + +Name | Register | DPT Typ | R/W | Bereich +Min | Max +Smart_Grid | 5167 | uint16 | R/W | 0 | 12 + | 0: Hardwareeingang/ Zustand gelb 11: Zustand grün 12: Zustand rot + +_tabela 2_ + +Name | Register | R/W | DPT Typ | Beschreibung | Einheit | Bereich +Min | Max +P_SW_SOLL | 5047 | R/W | uint16 | Warmwasser-solltemperatur | °C | P_WW_MIN_TEMP | P_WW_SOLLAB + +_tabela 3_ + +Name | Register | R/W | DPT Typ | Beschreibung | Einheit | Bereich +Min | Max +P_WW_SOLLAB | 5048 | R/W | uint16 | Warmwasser Maximal-temperatur | °C | P_SW_SOLL | 85 + +_tabela 4_ + +Name | Register | R/W | DPT Typ | Beschreibung | Einheit | Bereich +Min | Max +P_WW_MIN_TEMP | 5145 | R/W | uint16 | Warmwasser Minimal-temperatur | °C | 10 °C | P_WW_SOLLAB + +_tabela 5_ + +Name | Register | R/W | DPT Typ | Beschreibung | Einheit | Bereich +Min | Max +Leist_Heiz | 5168 | R | uint16 | Wert der aktuell zur Verfügung gestellten Wärmeleistung | W/10 | -327680 | 327670 +Leist_Elekt | 5170 | R | uint16 | Wert der aktuell aufgenommen elektrische Leistung | W/10 | -327680 | 327670 +PV_Ueberschuss | 5182 | R/W | uint16 | PV Überschuss für Smart-Grid von WR/EM (Achtung, dieser Wert ist in der aktuellen Software nur für die Erfassung, es steht noch keine Funktion dahinter) | W/10 | -327680 | 327670 + +_tabela 6_ + +Name | Register | R/W | DPT Typ | Beschreibung | Bereich +Min | Max +Anz_Status_Wert | 103 | R | uint16 | Statusmeldungen | 0 | 30 +Sperr_Wp_Wert_Anz | 104 | R | uint16 | Sperren | 1 | 42 +Stoerung_Wert | 105 | R | uint16 | Störmeldungen | 1 | 31 + +_tabela 7_ + +Name | Register | R/W | DPT Typ | Beschreibung | Einheit | Bereich +Min | Max +BA_aktiv | 5015 | R/W | uint16 | Betriebsmodus | | 0 | 5 + | 0: Sommer 1: Winter 2: Urlaub 3: Party 4: 2.Wärmeerzeuger 5: Kühlen +P_PARTY_HOUR | 5016 | R/W | uint16 | Anzahl Partystunden | H | 0 | 72 +P_URLAUB_TAGE | 5017 | R/W | uint16 | Anzahl Urlaubstage | d | 0 | 150 + +_tabela 8_ + +Name | Register | R/W | DPT Typ | Beschreibung | Erläuterung +SWa_Version | 65 | R | uint16 | Software Version | 0: -- 1: A 2: B …. 26: Z +SWa_Nummer | 66 | R | uint16 | Software Nummer | Ziffer +SWa_Index | 67 | R | uint16 | Software Index | Ziffer + +_tabela 9_ + +Name | Register | R/W | DPT Typ | Beschreibung | Einheit | Bereich +Min | Max +Heartbeat_In | 5063 | W | uint16 | Heartbeat Input | | 0 | 65535 +Heartbeat_Out | 5064 | R | uint16 | Heartbeat Output | | 0 | 65535 + + +## Modbus TCP - Statusmeldungen +`id 3340960438` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3340960438 + +Regsiter Value | Description +L/M-Software | H/J-Software +0 | Aus | Aus +1 | Aus | Wärmepumpe Ein Heizen +2 | Heizen | Wärmepumpe Ein Heizen +3 | Schwimmbad | Wärmepumpe Ein Schwimmbad +4 | Warmwasser | Wärmepumpe Ein Warmwasser +5 | Kühlen | Wärmepumpe Ein Heizen + 2.Wärmeerzeuger +6 | | Wärmepumpe Ein Schwimmbad + 2.Wärmeerzeuger +7 | | Wärmepumpe Ein Warmwasser + 2.Wärmeerzeuger +8 | | Primärpumpenvorlauf +9 | | Heizung Spülen +10 | Abtauen | Sperre (siehe Wert für Sperren J-Software) +11 | Durchflussüberwachung | Untere Einsatzgrenze +12 | | Niederdruckgrenze +13 | | Niederdruckabschaltung +14 | | Hochdrucksicherung +15 | | Schaltspielsperre +16 | | Mindeststandzeit +17 | | Netzbelastung +18 | | Durchflussüberwachung +19 | | 2.Wärmeerzeuger +20 | | Niederdruck Sole +21 | | Wärmepumpe Ein Abtauen +22 | | Obere Einsatzgrenze +23 | | Sperre Extern +24 | Verzögerung Betriebsmodusumschaltung | Betriebsmodus Kühlung +25 | | Frostschutz Kälte +26 | | Vorlaufgrenze +27 | | Taupunktwächter +28 | | Taupunkt +29 | | Kühlen passiv +30 | Sperre (siehe Wert für Sperren L-Software) | + + +## Modbus TCP - Sperrmeldungen +`id 3341091050` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3341091050 + +Register Value | Description +L/M-Software | J-Software | H-Software +0 | | | +1 | | Einsatzgrenze HT | Außentemperatur +2 | Volumenstrom | Einsatzgrenze WP | Bivalent-Alternativ +3 | | Regenerativ | Bivalent-Regenerativ +4 | | | Rücklauf +5 | Funktionskontrolle | Warmwasser Nacherwärmung | Warmwasser +6 | Einsatzgrenze HT | Systemkontrolle | Systemkontrolle +7 | Systemkontrolle | EVU-Sperre | EVU-Sperre +8 | Verzögerung Umschaltung Kühlen | | +9 | Pumpenvorlauf | Hochdruck | +10 | Mindeststandzeit | Niederdruck | +11 | Netzbelastung | Durchfluss | +12 | Schaltspielsperre | Sanftanlasser | +13 | Warmwasser Nacherwärmung | | +14 | Regenerativ | | +15 | EVU-Sperre | | +16 | Sanftanlasser | | +17 | Durchfluss | | +18 | Einsatzgrenze Wärmepumpe | | +19 | Hochdruck | | +20 | Niederdruck | | +21 | Einsatzgrenze Wärmequelle | | +23 | System Grenze | | +24 | Last Primärkreis | | +25 | Sperre Extern | | +29 | Inverter | | +31 | Aufwärmen | | +33 | EvD Initialisierung | | +34 | 2.Wärmeerzeuger freigegeben | | +35 | Störung (siehe Wert für Störmeldungen) | | +36 | | Pumpenvorlauf | +37 | | Mindeststandzeit | +38 | | Netzbelastung | +39 | | Schaltspielsperre | +40 | | Einsatzgrenze Wärmequelle | +41 | | Sperre Extern | +42 | | 2.Wärmeerzeuger | +43 | | Störung (siehe Wert für Störmeldungen) | + + +## Modbus TCP - Zeitprogramm 2./3. Heizkreis Absenk-/Anhebung +`id 3347185666` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3347185666 + + | Register | Datapoint Typ | COIL/REG | R/W | Range | Unit +Name | Min | Max +2.Heizkreis +Absenkung | 5065 | uint16 | Register | R/W | 3 | 3 | +Anhebung | 5065 | uint16 | Register | R/W | 4 | 4 | +3.Heizkreis +Absenkung | 5065 | uint16 | Register | R/W | 5 | 5 | +Anhebung | 5065 | uint16 | Register | R/W | 6 | 6 | +Zeitfunktion +Start Stunde 1 | 5066 | uint16 | Register | R/W | 0 | 23 | hour +Start Minute 1 | 5067 | uint16 | Register | R/W | 0 | 59 | min +Ende Stunde 1 | 5068 | uint16 | Register | R/W | 0 | 23 | hour +Ende Minute 1 | 5069 | uint16 | Register | R/W | 0 | 59 | min +Start Stunde 2 | 5070 | uint16 | Register | R/W | 0 | 23 | hour +Start Minute 2 | 5071 | uint16 | Register | R/W | 0 | 59 | min +Ende Stunde 2 | 5072 | uint16 | Register | R/W | 0 | 23 | hour +Ende Minute 2 | 5073 | uint16 | Register | R/W | 0 | 59 | min +Sonntag | 5074 | uint16 | Register | R/W | 0 | 3 | +Montag | 5075 | uint16 | Register | R/W | 0 | 3 | +Dienstag | 5076 | uint16 | Register | R/W | 0 | 3 | +Mittwoch | 5077 | uint16 | Register | R/W | 0 | 3 | +Donnerstag | 5078 | uint16 | Register | R/W | 0 | 3 | +Freitag | 5079 | uint16 | Register | R/W | 0 | 3 | +Samstag | 5080 | uint16 | Register | R/W | 0 | 3 | + | 0: Ja 1: Nein 2: Zeit 1 3: Zeit 2 | +Absenk- / Anhebwert | 5081 | uint16 | Register | R/W | 0 | 19 | K +Aktiv Zeit 1 | 125 | Boolean | Coil | R | 0 | 1 | no +Aktiv Zeit 2 | 126 | Boolean | Coil | R | 0 | 1 | no + | 0: inaktiv 1: aktiv | + + +## Modbus TCP - Zeitprogramm Warmwasser Sperre +`id 3340959978` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3340959978 + + | Register | Datapoint Typ | COIL/REG | R/W | Range | Unit +Name | Min | Max +Warmwasser Sperre | 5065 | uint16 | Register | R/W | 7 | 7 | +Zeitfunktion +Start Stunde 1 | 5066 | uint16 | Register | R/W | 0 | 23 | hour +Start Minute 1 | 5067 | uint16 | Register | R/W | 0 | 59 | min +Ende Stunde 1 | 5068 | uint16 | Register | R/W | 0 | 23 | hour +Ende Minute 1 | 5069 | uint16 | Register | R/W | 0 | 59 | min +Start Stunde 2 | 5070 | uint16 | Register | R/W | 0 | 23 | hour +Start Minute 2 | 5071 | uint16 | Register | R/W | 0 | 59 | min +Ende Stunde 2 | 5072 | uint16 | Register | R/W | 0 | 23 | hour +Ende Minute 2 | 5073 | uint16 | Register | R/W | 0 | 59 | min +Sonntag | 5074 | uint16 | Register | R/W | 0 | 3 | +Montag | 5075 | uint16 | Register | R/W | 0 | 3 | +Dienstag | 5076 | uint16 | Register | R/W | 0 | 3 | +Mittwoch | 5077 | uint16 | Register | R/W | 0 | 3 | +Donnerstag | 5078 | uint16 | Register | R/W | 0 | 3 | +Freitag | 5079 | uint16 | Register | R/W | 0 | 3 | +Samstag | 5080 | uint16 | Register | R/W | 0 | 3 | + | 0: Ja 1: Nein 2: Zeit 1 3: Zeit 2 | +Aktiv Zeit 1 | 125 | Boolean | Coil | R | 0 | 1 | no +Aktiv Zeit 2 | 126 | Boolean | Coil | R | 0 | 1 | no + | 0: inaktiv 1: aktiv | + + +## Modbus TCP - Störmeldungen +`id 3340960678` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3340960678 + +Register Value | Description +L/M-Software | H/J-Software +0 | kein Fehler | kein Fehler +1 | Fehler N17.1 | +2 | Fehler N17.2 | +3 | Fehler N17.3 | Last Verdichter +4 | Fehler N17.4 | Codierung +5 | | Niederdruck +6 | Elektronisches Ex.Ventil | Frostschutz +7 | | Aussenfühler Kurzschluss oder Bruch +8 | | Rücklauffühler Kurzschluss oder Bruch +9 | | Warmwasserfühler Kurzschluss oder Bruch +10 | WPIO | Frostschutzfühler Kurzschluss oder Bruch +11 | | 2.Heizkreis Fühler Kurzschluss oder Bruch +12 | Inverter | Eingefrierschutzfühler Kurzschluss oder Bruch +13 | WQIF | Niederdruck Sole +14 | | Motorschutz Primär +15 | Sensorfehler | Durchfluss +16 | Niederdruck Sole | Warmwasser +17 | | Hochdruck +19 | !Primärkreis | Heissgasthermostat +20 | !Abtauen | Einsatzgrenze Kühlung +21 | !Niederdruck Sole | +22 | !Warmwasser | +23 | !Last Verdichter | Temperatur Differenz +24 | !Codierung | +25 | !Niederdruck | +26 | !Frostschutz | +28 | !Hochdruck | +29 | !Temperatur Differenz | +30 | !Heisgasthermostat | +31 | !Durchfluss | +32 | !Aufwärmen | + + +## Modbus TCP - Sensorfehler +`id 3341091290` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3341091290 + +RegisterValue | Description +L/M-Software +1 | Außenfühler (R1) +2 | Rücklauffühler (R2) +3 | Warmwasserfühler (R3) +4 | Codierung (R7) +5 | Vorlauffühler (R9) +6 | 2.Heizkreisfühler (R5) +7 | 3.Heizkreisfühler (R13) +8 | Regenerativfühler (R13) +9 | Raumfühler 1 +10 | Raumfühler 2 +11 | Fühler Wärmequellenaustritt (R6) +12 | Fühler Wärmequelleneintritt (R24)* +14 | Kollektorfühler (R23) +15 | Niederdrucksensor (R25) +16 | Hochdrucksensor (R26) +17 | Raumfeuchte 1 +18 | Raumfeuchte 2 +19 | Fühler Frostschutz-Kälte +20 | Heißgas +21 | Rücklauffühler (R2.1) +22 | Schwimmbadfühler (R20) +23 | Vorlauffühler Kühlen Passiv (R11) +24 | Rücklauffühler Kühlen Passiv (R4) +26 | Fühler Solarspeicher (R22) +28 | Anforderungsfühler Heizen (R2.2) +29 | RTM Econ +30 | Anforderungsfühler Kühlen (R39) + + +## Modbus TCP - Raumtemperaturregelung +`id 3372220879` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3372220879 + + +_tabela 1_ + +Parameter | Einstellung | Einstellwert +1./2./3.Kreis + | 1./2./3.Kreis Regelung | Welche Regelungsmöglichkeit soll für den 1./2./3.Kreis genutzt werden? | Raumtemperatur + | 1./2./3.Kreis Raumregelung | Welche Hardware wird für die Raumregelung Heizen/Kühlen verwendet? | BMS + | 1./2./3.Kreis Anzahl RTM | Wie viele Raumregler werden mit der BMS Schnittstelle für den 1./2./3.Kreis verwendet? | 1 … 10 + +_tabela 2_ + +Name | Address | Datapoint Typ | COIL/REG | R/W | Range | Unit +Min. | Max. +Raumadressen 1.Heiz/Kühlkreis | 5065 | uint16 | Register | R/W | 50 | 59 | no +Raumadressen 2.Heiz/Kühlkreis | 5065 | uint16 | Register | R/W | 60 | 69 | no +Raumadressen 3.Heiz/Kühlkreis | 5065 | uint16 | Register | R/W | 70 | 79 | no +Raumtemperatur 50-79 BMS | 11 | uint16 | Register | R/W | 100 | 500 | 0.1 °C +Raumfeuchte 50-79 BMS | 13 | uint16 | Register | R/W | 200 | 900 | 0.1 % +Raumsolltemperatur 50-79 BMS | 5081 | uint16 | Register | R/W | 100 | 300 | 0.1 °C +Raumfreigabe 50-79 BMS | 5164 | uint16 | Register | R/W | 1 | 3 | no + | 1: Heizen (Kühlen gesperrt) 3: Heizen und Kühlen +Zustand Stellventil | 177 | boolean | Coil | R | 0 | 1 | no + | 0: geschlossen 1: geöffnet + +_tabela 3_ + +Name | e.g. Group Address +Anzahl Räume lesen | 14/5/1 (Sensor) +Raumadresse 50 - 59 umschalten | 14/5/2 (Aktor) +Raum-Ist-Temperatur RIT Adr. 50 - 59 schreiben | 14/5/4 (Aktor) +Raum-Ist-Feuchte RIF Adr. 50 - 59 schreiben | 14/5/6 (Aktor) +Raum-Soll-Temperatur RST Adr. 50 - 59 schreiben | 14/5/8 (Aktor) +Raum-Freigabe RFG Adr. 50 - 59 schreiben | 14/5/10 (Aktor) + + +## Modbus TCP - Smart Grid / SG Ready +`id 3372220648` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3372220648 + + +_tabela 1_ + +Parameter | Einstellung | Einstellwert +Flexeingang N1/J5-ID1+ID2 | Wird der Digitaleingang ID1 + ID2 verwendet? WEinstellung welche für die Aktivierung der NWPM Touch-Erweiterung vorgenommen werden muss. | Smart Grid + +_tabela 2_ + +Color | Description | Detail +rot | In diesem Zustand läuft die Wärmepumpe im abgesenkten Betrieb für die Raumheizung, Warmwasser- und Schwimmbadbereitung. | für die Raumheizung gilt der einstellbare Absenkwert des jeweiligen Heizkreisesfür die Warmwasser- und Schwimmbadbereitung gilt die jeweilige einstellbare minimale Temperatur +gelb | In diesem Zustand läuft die Wärmepumpe im eingestellten Normalbetrieb. | +grün | In diesem Zustand läuft die Wärmepumpe im verstärkten Betrieb für die Raumheizung, Warmwasser- und Schwimmbadbereitung.Bei regenerativen Anlagen wird die Wärmepumpe nicht gesperrt, die Wärmepumpe erhält in diesem Zustand Priorität.Der regenerative Speicher wird in der Zeit nicht entladen | für die Raumheizung gilt der einstellbare Anhebwert des jeweiligen Heizkreisesfür die Warmwasser-* und Schwimmbadbereitung* gilt die jeweilige einstellbare maximale Temperatur +dunkelgrün | In diesem Zustand läuft die Wärmepumpe in die Leistungsstufe 3 versetzt. Bedeutet es wird die Wärmepumpe als auch die elektrische Wärmeerzeuger (elektrischer Tauchheizkörper, elektrische Flanschheizung) im verstärkten Betrieb für die Raumheizung, Warmwasser- und Schwimmbadbereitung angefordert. | für die Raumheizung gilt der einstellbare Anhebwert des jeweiligen Heizkreisesfür die Warmwasser*- und Schwimmbadbereitung* gilt die jeweilige einstellbare maximale Temperatur + +_tabela 3_ + +Description | Smart Grid 1 | Smart Grid 2 +Addresss | 3 | 4 +Color | State +rot | 0 | 1 +gelb | 0 | 0 +grün | 1 | 0 +dunkelgrün | 1 | 1 + +_tabela 4_ + +Name | Address | Datapoint Typ | COIL/REG | R/W | Range | Unit +Min | Max +Smart Grid | 5167 | uint16 | Register | R/W | 0 | 13 | no + | 0: Hardwareeingang 10: Zustand gelb 11: Zustand grün 12: Zustand rot 13: Zustand dunkelgrün + + +## Modbus TCP - Sperre Extern +`id 3372221110` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3372221110 + +Bezeichnung | Address | Datapoint Typ | COIL/REG | R/W | Range | Unit +Min | Max +Sperre Extern | 5130 | uint16 | Register | R/W | 0 | 11 | no +Im Wärmepumpenmanager können 4 unterschiedliche Funktionen der Sperre Extern eingestellt werden.FrostschutzBetriebsmodus UrlaubSperre WarmwasserBetriebsmodus Sommer | 0: Hardwareeingang 10: Sperre nicht aktiv 11: Sperre aktiv + + +## Modbus TCP - Zeitabgleich +`id 3372220417` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3372220417 + + | Address | Datapoint Typ | COIL/REG | R/W | Range | Unit +Name | Min. | Max. +Stunde | 5006 | uint16 | Register | R/W | 0 | 23 | hour +set Stunde | 102 | boolean | Coil | W | | +Minute | 5007 | uint16 | Register | R/W | 0 | 59 | min +set Minute | 103 | boolean | Coil | W | | +Monat | 5008 | uint16 | Register | R/W | 1 | 12 | month +set Monat | 105 | boolean | Coil | W | | +Wochentag | 5009 | uint16 | Register | R/W | 1 | 7 | + | | | | | 1: Montag 2: Dienstag 3: Mittwoch 4: Donnerstag 5: Freitag 6: Samstag 7: Sonntag | +set Wochentag | 107 | boolean | Coil | W | | +Tag | 5010 | uint16 | Register | R/W | 1 | 31 | day +set Tag | 104 | boolean | Coil | W | | +Jahr | 5011 | uint16 | Register | R/W | 0 | 99 | year +set Jahr | 106 | boolean | Coil | W | | + + +## Modbus TCP - Zeitprogramm 1. Heizkreis Absenk-/Anhebung +`id 3347087594` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3347087594 + + | Register | Datapoint Typ | COIL/REG | R/W | Range | Unit +Name | Min | Max +1.Heizkreis +Absenkung | 5065 | uint16 | Register | R/W | 1 | 1 | +Anhebung | 5065 | uint16 | Register | R/W | 2 | 2 | +Zeitfunktion +Start Stunde 1 | 5066 | uint16 | Register | R/W | 0 | 23 | hour +Start Minute 1 | 5067 | uint16 | Register | R/W | 0 | 59 | min +Ende Stunde 1 | 5068 | uint16 | Register | R/W | 0 | 23 | hour +Ende Minute 1 | 5069 | uint16 | Register | R/W | 0 | 59 | min +Start Stunde 2 | 5070 | uint16 | Register | R/W | 0 | 23 | hour +Start Minute 2 | 5071 | uint16 | Register | R/W | 0 | 59 | min +Ende Stunde 2 | 5072 | uint16 | Register | R/W | 0 | 23 | hour +Ende Minute 2 | 5073 | uint16 | Register | R/W | 0 | 59 | min +Sonntag | 5074 | uint16 | Register | R/W | 0 | 3 | +Montag | 5075 | uint16 | Register | R/W | 0 | 3 | +Dienstag | 5076 | uint16 | Register | R/W | 0 | 3 | +Mittwoch | 5077 | uint16 | Register | R/W | 0 | 3 | +Donnerstag | 5078 | uint16 | Register | R/W | 0 | 3 | +Freitag | 5079 | uint16 | Register | R/W | 0 | 3 | +Samstag | 5080 | uint16 | Register | R/W | 0 | 3 | + | 0: Ja 1: Nein 2: Zeit 1 3: Zeit 2 | +Absenk- / Anhebwert | 5081 | uint16 | Register | R/W | 0 | 19 | K +Aktiv Zeit 1 | 125 | Boolean | Coil | R | 0 | 1 | no +Aktiv Zeit 2 | 126 | Boolean | Coil | R | 0 | 1 | no + | 0: inaktiv 1: aktiv | + + +## Modbus TCP - Zeitprogramm Schwimmbad Sperre +`id 3347185897` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3347185897 + + | Register | Datapoint Typ | COIL/REG | R/W | Range | Unit +Name | Min | Max +Schwimmbad Sperre | 5065 | uint16 | Register | R/W | 9 | 9 | +Zeitfunktion +Start Stunde 1 | 5066 | uint16 | Register | R/W | 0 | 23 | hour +Start Minute 1 | 5067 | uint16 | Register | R/W | 0 | 59 | min +Ende Stunde 1 | 5068 | uint16 | Register | R/W | 0 | 23 | hour +Ende Minute 1 | 5069 | uint16 | Register | R/W | 0 | 59 | min +Start Stunde 2 | 5070 | uint16 | Register | R/W | 0 | 23 | hour +Start Minute 2 | 5071 | uint16 | Register | R/W | 0 | 59 | min +Ende Stunde 2 | 5072 | uint16 | Register | R/W | 0 | 23 | hour +Ende Minute 2 | 5073 | uint16 | Register | R/W | 0 | 59 | min +Sonntag | 5074 | uint16 | Register | R/W | 0 | 3 | +Montag | 5075 | uint16 | Register | R/W | 0 | 3 | +Dienstag | 5076 | uint16 | Register | R/W | 0 | 3 | +Mittwoch | 5077 | uint16 | Register | R/W | 0 | 3 | +Donnerstag | 5078 | uint16 | Register | R/W | 0 | 3 | +Freitag | 5079 | uint16 | Register | R/W | 0 | 3 | +Samstag | 5080 | uint16 | Register | R/W | 0 | 3 | + | 0: Ja 1: Nein 2: Zeit 1 3: Zeit 2 | +Aktiv Zeit 1 | 125 | Boolean | Coil | R | 0 | 1 | no +Aktiv Zeit 2 | 126 | Boolean | Coil | R | 0 | 1 | no + | 0: inaktiv 1: aktiv | + + +## Modbus TCP - Zeitprogramm Warmwasser Thermische Desinfektion +`id 3341123818` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3341123818 + + | Register | Datapoint Typ | COIL/REG | R/W | Range | Unit +Name | Min. | Max. +Thermische Desinfektion | 5065 | uint16 | Register | R/W | 8 | 8 | +Zeitfunktion +Start Stunde | 5066 | uint16 | Register | R/W | 0 | 23 | hour +Start Minute | 5067 | uint16 | Register | R/W | 0 | 59 | min +Sonntag | 5074 | uint16 | Register | R/W | 0 | 1 | +Montag | 5075 | uint16 | Register | R/W | 0 | 1 | +Dienstag | 5076 | uint16 | Register | R/W | 0 | 1 | +Mittwoch | 5077 | uint16 | Register | R/W | 0 | 1 | +Donnerstag | 5078 | uint16 | Register | R/W | 0 | 1 | +Freitag | 5079 | uint16 | Register | R/W | 0 | 1 | +Samstag | 5080 | uint16 | Register | R/W | 0 | 1 | + | 0: Ja 1: Nein | +Temperatur | 5081 | uint16 | Register | R/W | 60 | 85 | °C +Aktiv | 125 | boolean | Coil | R | 0 | 1 | no + | 0: inaktiv 1: aktiv | + + +## Modbus TCP - Zeitprogramm Schwimmbad Vorrang +`id 3347186127` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3347186127 + + | Register | Datapoint Typ | COIL/REG | R/W | Range | Unit +Name | Min. | Max. +Schwimmbad Vorrang | 5065 | uint16 | Register | R/W | 10 | 10 | +Zeitfunktion +Start Stunde | 5066 | uint16 | Register | R/W | 0 | 23 | hour +Start Minute | 5067 | uint16 | Register | R/W | 0 | 59 | min +Sonntag | 5074 | uint16 | Register | R/W | 0 | 1 | +Montag | 5075 | uint16 | Register | R/W | 0 | 1 | +Dienstag | 5076 | uint16 | Register | R/W | 0 | 1 | +Mittwoch | 5077 | uint16 | Register | R/W | 0 | 1 | +Donnerstag | 5078 | uint16 | Register | R/W | 0 | 1 | +Freitag | 5079 | uint16 | Register | R/W | 0 | 1 | +Samstag | 5080 | uint16 | Register | R/W | 0 | 1 | + | 0: Ja 1: Nein | +Vorrang | 5081 | uint16 | Register | R/W | 1 | 10 | h +Aktiv | 125 | boolean | Coil | R | 0 | 1 | no + | 0: inaktiv 1: aktiv | + + +## Modbus TCP - Zeitprogramm Warmwasser Zirkulationspumpe +`id 3340960208` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3340960208 + + +_tabela 1_ + +Parameter | Einstellung | Einstellbereich +Zirkulation Ausschaltverzögerung | Die Zirkulationspumpe wird z.B. durch einen Paddelschalter gestartet. Schaltet der Paddelschalter wieder zurück, dann läuft die Zirkulationspumpe die eingestellte Zeit nach. | 1 ... 5 Minuten … 15 + +_tabela 2_ + + | Register | Datapoint Typ | COIL/REG | R/W | Range | Unit +Name | Min | Max +Freigabe Zirkulationspumpe | 19 | boolean | Coil | R/W | 0 | 1 | no + | 1: Freigabe | + +_tabela 3_ + + | Register | Datapoint Typ | COIL/REG | R/W | Range | Unit +Name | Min | Max +Zirkulationspumpe | 5065 | uint16 | Register | R/W | 12 | 12 | +Zeitfunktion +Start Stunde 1 | 5066 | uint16 | Register | R/W | 0 | 23 | hour +Start Minute 1 | 5067 | uint16 | Register | R/W | 0 | 59 | min +Ende Stunde 1 | 5068 | uint16 | Register | R/W | 0 | 23 | hour +Ende Minute 1 | 5069 | uint16 | Register | R/W | 0 | 59 | min +Start Stunde 2 | 5070 | uint16 | Register | R/W | 0 | 23 | hour +Start Minute 2 | 5071 | uint16 | Register | R/W | 0 | 59 | min +Ende Stunde 2 | 5072 | uint16 | Register | R/W | 0 | 23 | hour +Ende Minute 2 | 5073 | uint16 | Register | R/W | 0 | 59 | min +Sonntag | 5074 | uint16 | Register | R/W | 0 | 3 | +Montag | 5075 | uint16 | Register | R/W | 0 | 3 | +Dienstag | 5076 | uint16 | Register | R/W | 0 | 3 | +Mittwoch | 5077 | uint16 | Register | R/W | 0 | 3 | +Donnerstag | 5078 | uint16 | Register | R/W | 0 | 3 | +Freitag | 5079 | uint16 | Register | R/W | 0 | 3 | +Samstag | 5080 | uint16 | Register | R/W | 0 | 3 | + | 0: Ja 1: Nein 2: Zeit 1 3: Zeit 2 | +Aktiv Zeit 1 | 125 | boolean | Coil | R | 0 | 1 | no +Aktiv Zeit 2 | 126 | boolean | Coil | R | 0 | 1 | no + | 0: inaktiv 1: aktiv | + + +## Modbus TCP - Systemstatus +`id 3303833601` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3303833601 + + | Register | Datapoint Typ | COIL/REG | R/W | Range +Name | WPM-Software L/M | WPM-Software J | WPM-Software H | Min | Max +Statusmeldungen | 103 | 43 | 14 | uint16 | Register | R | 0 | 30 +Sperrmeldungen | 104 | 59 | 94 | uint16 | Register | R | 1 | 42 +Störmeldungen | 105 | 42 | 13 | uint16 | Register | R | 1 | 31 +Sensorfehler | 106 | - | - | uint16 | Register | R | 1 | 27 + + +## Modbus TCP - Betriebsmodus +`id 3303572150` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3303572150 + + | Register | Datapoint Typ | COIL/REG | R/W | Range | Unit +Name | WPM-Software J/L/M | Min | Max +Betriebsmodus | 5015 | uint16 | Register | R/W | 0 | 5 | + | 0: Sommer 1: Winter 2: Urlaub 3: Party 4: 2.Wärmeerzeuger 5: Kühlen | +Anzahl Partystunden | 5016 | uint16 | Register | R/W | 0 | 72 | hour +Anzahl Urlaubstage | 5017 | uint16 | Register | R/W | 0 | 150 | day +Lüftung +Stufen | 5034 | uint16 | Register | R/W | 0 | 5 | +Zeitwert Stoßlüften | 127 | uint16 | Register | R/W | 15 | 90 | + + +## Modbus TCP - Betriebsdaten +`id 3303571917` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3303571917 + + | Register | Datapoint Typ | COIL/REG | R/W | Unit +Name | WPM-Software J/L/M +Außentemperatur (R1) | 1 | int16 | Register | R | 0.1 °C +Temperatur Ruecklauf (R2) | 2 | int16 | Register | R | 0.1 °C +Temperatur Rücklaufsoll | 53 | int16 | Register | R | 0.1 °C +Temperatur Warmwasser (R3) | 3 | int16 | Register | R | 0.1 °C +Temperatur Warmwassersoll | 58 | int16 | Register | R | 0.1 °C +Temperatur Vorlauf (R9) | 5 | int16 | Register | R | 0.1 °C +Temperatur Wärmequelleneintritt (R24)* | 6 | int16 | Register | R | 0.1 °C +Temperatur Wärmequellenaustritt (R6) | 7 | int16 | Register | R | 0.1 °C +Solltemperatur 2.Heizkreis | 54 | int16 | Register | R | 0.1 °C +Temperatur 2.Heizkreis (R5) | 9 | int16 | Register | R | 0.1 °C +Solltemperatur 3.Heizkreis | 55 | int16 | Register | R | 0.1 °C +Temperatur 3.Heizkreis (R13) | 10 | int16 | Register | R | 0.1 °C +Raumtemperatur 1 / RT-RTH Econ | 11 | int16 | Register | R | 0.1 °C +Raumtemperatur 2 | 12 | int16 | Register | R | 0.1 °C +Raumfeuchte 1 / RT-RTH Econ | 13 | int16 | Register | R | 0.1 r.F. +Raumfeuchte 2 | 14 | int16 | Register | R | 0.1 r.F. +Passiv Kühlen +Vorlauftemperatur (R11) | 19 | int16 | Register | R | 0.1 °C +Rücklauftemperatur (R4) | 20 | int16 | Register | R | 0.1 °C +Passiv/Aktiv Kühlen +Rücklauftemp. gem. Primärkreis (R24) | 21 | int16 | Register | R | 0.1 °C +Solar +Kollektorfühler (R23) | 10 | int16 | Register | R | 0.1 °C +Solarspeicher (R22) | 23 | int16 | Register | R | 0.1 °C +Lüftung +Außenlufttemperatur | 120 | int16 | Register | R | 0.1 °C +Zulufttemperatur | 121 | int16 | Register | R | 0.1 °C +Ablufttemperatur | 122 | int16 | Register | R | 0.1 °C +Fortlufttemperatur | 123 | int16 | Register | R | 0.1 °C +Drehzahl Zuluftventilator | 125 | int16 | Register | R | 1/min +Drehzahl Abluftventilator | 126 | int16 | Register | R | 1/min + + +## Modbus TCP - Laufzeiten +`id 3341451265` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3341451265 + + | Register | Datapoint Typ | COIL/REG | R/W | Unit +Name | WPM-Software J/L/M +Verdichter 1 | 72 | uint16 | Register | R | hour +Verdichter 2 | 73 | uint16 | Register | R | hour +Primärpumpe / Ventilator (M11) | 74 | uint16 | Register | R | hour +2.Wärmeerzeuger (E10) | 75 | uint16 | Register | R | hour +Heizungspumpe (M13) | 76 | uint16 | Register | R | hour +Warmwasserpumpe (M18) | 77 | uint16 | Register | R | hour +Flanschheizung (E9) | 78 | uint16 | Register | R | hour +Schwimmbadpumpe (M19) | 79 | uint16 | Register | R | hour +Zusatzumwälzpumpe (M16) | 71 ab L12 | uint16 | Register | R | hour + + +## Modbus TCP - Wärme- und Energiemengen +`id 3341124281` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3341124281 + + +_tabela 1_ + + | Register | Datapoint Typ | COIL/REG | R/W | Unit +Name | WPM-Software J/L/M +Wärmemenge* Heizen 1-4 | 5096 | uint16 | Register | R | kWh +Wärmemenge* Heizen 5-8 | 5097 | uint16 | Register | R | kWh +Wärmemenge* Heizen 9-12 | 5098 | uint16 | Register | R | kWh +Wärmemenge* Warmwasser 1-4 | 5099 | uint16 | Register | R | kWh +Wärmemenge* Warmwasser 5-8 | 5100 | uint16 | Register | R | kWh +Wärmemenge* Warmwasser 9-12 | 5101 | uint16 | Register | R | kWh +Wärmemenge* Schwimmbad 1-4 | 5102 | uint16 | Register | R | kWh +Wärmemenge* Schwimmbad 5-8 | 5103 | uint16 | Register | R | kWh +Wärmemenge* Schwimmbad 9-12 | 5104 | uint16 | Register | R | kWh +Umweltenergie 1-4 | 5127 | uint16 | Register | R | kWh +Umweltenergie 5-8 | 5128 | uint16 | Register | R | kWh +Umweltenergie 9-12 | 5129 | uint16 | Register | R | kWh + +_tabela 2_ + +Wärmemenge Heizen = (Wärmemenge Heizen 9-12 * 100000000) + (Wärmemenge Heizen 5-8 * 10000) + Wärmemenge Heizen 1-4 + + +## Modbus TCP - Eingänge +`id 3342204929` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3342204929 + + | Register | Datapoint Typ | COIL/REG | R/W +Name | WPM-Software J/L/M | +Bezeichnung | | +SmartGrid 1 | 3 | boolean | Coil | R +SmartGrid 2 | 4 | boolean | Coil | R +EVU-Sperre | 5 | boolean | Coil | R +Sperre Extern | 6 | boolean | Coil | R + + +## Modbus TCP - Ausgänge +`id 3342205162` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3342205162 + + | Register | Datapoint Typ | COIL/REG | R/W +Name | WPM-Software J/L +Verdichter 1 | 41 | Boolean | Coil | R +Verdichter 2 | 42 | Boolean | Coil | R +Primärpumpe (M11) / Ventilator (M2) | 43 | Boolean | Coil | R +2.Wärmeerzeuger (E10) | 44 | Boolean | Coil | R +Heizungspumpe (M13) | 45 | Boolean | Coil | R +Warmwasserpumpe (M18) | 46 | Boolean | Coil | R +Mischer (M21) Auf | 47 | Boolean | Coil | R +Mischer (M21) ZU | 48 | Boolean | Coil | R +Zusatzumwälzpumpe (M16) | 49 | Boolean | Coil | R +Flanschheizung (E9) | 50 | Boolean | Coil | R +Heizungspumpe (M15) | 51 | Boolean | Coil | R +Mischer (M22) Auf | 52 | Boolean | Coil | R +Mischer (M22) Zu | 53 | Boolean | Coil | R +Schwimmbadpumpe (M19) | 56 | Boolean | Coil | R +Sammelstörmeldung (H5) | 57 | Boolean | Coil | R +Heizungspumpe (M14) | 59 | Boolean | Coil | R +Kühlpumpe (M17) | 60 | Boolean | Coil | R +Heizungspumpe (M20) | 61 | Boolean | Coil | R +Umschaltung Raumthermostate Heizen/Kühlen (N9) | 66 | Boolean | Coil | R +Primärpumpe Kühlen (M12) | 68 | Boolean | Coil | R +Solarpumpe (M23) | 71 | Boolean | Coil | R + + +## Modbus TCP - Einstellungen 1. Heiz-/Kühlkreis +`id 3341090817` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3341090817 + + | Register | Datapoint Typ | COIL/REG | R/W | Range | Unit +Name | WPM-Software J/L/M | Min | Max +Parallelverschiebung | 5036 | uint16 | Register | R/W | 0 | 38 | + | 0: -19 1: -18 2: -17 3: -16 4: -15 5: -14 6: -13 7: -12 8: -11 9: -10 10: -9 11: -8 12: -7 13: -6 14: -5 15: -4 16: -3 17: -2 18: -1 19: 0 | 20: 1 21: 2 22: 3 23: 4 24: 5 25: 6 26: 7 27: 8 28: 9 29: 10 30: 11 31: 12 32: 13 33: 14 34: 15 35: 16 36: 17 37: 18 38: 19 | +Raumtemperatur | 46 | uint16 | Register | R/W | 15.0 | 30.0 | °C +Festwertsolltemperatur | 5037 | uint16 | Register | R/W | 18 | 60 | °C +Heizkurvenendpunkt | 5038 | uint16 | Register | R/W | 20 | 70 | °C +Hysterese | 47 | uint16 | Register | R/W | 0.5 | 5.0 | K +Solltemp. dyn. Kühlung (bis L22.9) | 5043 | uint16 | Register | R/W | 10 | 35 | °C +Solltemp. dyn. Kühlung bei 15°C AT (ab L22.9) | 5043 | uint16 | Register | R/W | 10 | 35 | °C +Solltemp. dyn. Kühlung bei 35°C AT (ab L22.9) | 5134 | uint16 | Register | R/W | 10 | 35 | °C + + +## Modbus TCP - Einstellungen 2./3. Heiz-/Kühlkreis +`id 3341123585` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3341123585 + + | Address | Datapoint Typ | COIL/REG | R/W | Range | Unit +Name | Min. | Max. | +Auswahl Heizkreis 2 | 5082 | uint16 | Register | R/W | 2 | 2 | +Auswahl Heizkreis 3 | 5082 | uint16 | Register | R/W | 3 | 3 | + | 2: 2.Heizkreis 3: 3.Heizkreis | +Heizkurvenendpunkt | 5084 | uint16 | Register | R/W | 20 | 70 | °C +Festwertemperatur | 5085 | uint16 | Register | R/W | 20 | 60 | °C +Parallelverschiebung | 5086 | uint16t | Register | R/W | 0 | 38 | + | 0: -19 1: -18 2: -17 3: -16 4: -15 5: -14 6: -13 7: -12 8: -11 9: -10 10: -9 11: -8 12: -7 13: -6 14: -5 15: -4 16: -3 17: -2 18: -1 19: 0 | 20: 1 21: 2 22: 3 23: 4 24: 5 25: 6 26: 7 27: 8 28: 9 29: 10 30: 11 31: 12 32: 13 33: 14 34: 15 35: 16 36: 17 37: 18 38: 19 | +Mischerlaufzeit | 5087 | uint16 | Register | R/W | 1 | 6 | Min +Mischerhysterese | 93 | uint16 | Register | R/W | 0.5 | 2.0 | K +Maximale Temperatur | 5088 | uint16 | Register | R/W | 30 | 70 | °C +Kühlung Raumsolltemperatur | 5089 | uint16 | Register | R/W | 0 | 30 | + | 0: 15.0 1: 15.5 2: 16.0 3: 16.5 4: 17.0 5: 17.5 6: 18.0 7: 18.5 8: 19.0 9: 19.5 10: 20.0 11: 20.5 12: 21.0 13: 21.5 14: 22.0 15: 22.5 | 16: 23.0 17: 23.5 18: 24.0 19: 24.5 20: 25.0 21: 25.5 22: 26.0 23: 26.5 24: 27.0 25: 27.5 26: 28.0 27: 28.5 28: 29.0 29: 29.5 30: 30.0 | °C + + +## Modbus TCP - Einstellungen Warmwasser +`id 3303833834` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3303833834 + + | Register | Datapoint Typ | COIL/REG | R/W | Range | Unit +Parameter | WPM-Software J/L/M | Min | Max +Hysterese | 5045 | uint16 | Register | R/W | 2 | 15 | K +Solltemperatur | 5047 | uint16 | Register | R/W | Solltemp. Min. | 85 | °C +Solltemperatur Minimal | 5145 | uint16 | Register | R/W | 10 | Soll. | °C +Solltemperatur Maximal | 5048 | uint16 | Register | R/W | Soll. | 85 | °C + + +## Modbus TCP - Einstellungen Schwimmbad +`id 3340959745` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3340959745 + + | Register | Datapoint Typ | COIL/REG | R/W | Range | Unit +Name | WPM-Software J/L/M | Min | Max +Hysterese | 5049 | uint16 | Register | R/W | 1 | 20 | K +Solltemperatur | 5051 | uint16 | Register | R/W | 5 | 60 | °C + + +## Modbus TCP - Einstellungen 2. Wärmeerzeuger +`id 3347087361` | https://dimplex.atlassian.net/wiki/spaces/DW/pages/3347087361 + + | Register | Datapoint Typ | COIL/REG | R/W | Range | Unit +Name | WPM-Software J/L/M | Min | Max +Mischer Hysterese | 48 | uint16 | Register | R/W | 0.5 | 2.0 | K +Grenztemperatur parallel | 5020 | uint16 | Register | R/W | -25 | 35 | °C +Mischerlaufzeit | 5021 | uint16 | Register | R/W | 30 | 85 | Min + diff --git a/tests/test_estimation.py b/tests/test_estimation.py new file mode 100644 index 0000000..8d4affa --- /dev/null +++ b/tests/test_estimation.py @@ -0,0 +1,101 @@ +"""Pure-Python tests for the estimation engine (no Home Assistant needed). + +Run: python3 tests/test_estimation.py | pytest tests/test_estimation.py +""" + +from __future__ import annotations + +import importlib.util +import os +import sys + +_HERE = os.path.dirname(__file__) +_PATH = os.path.abspath( + os.path.join(_HERE, "..", "custom_components", "dimplex_wpm", "estimation.py") +) + + +def _load(): + spec = importlib.util.spec_from_file_location("dwpm_estimation", _PATH) + mod = importlib.util.module_from_spec(spec) + assert spec and spec.loader + sys.modules["dwpm_estimation"] = mod + spec.loader.exec_module(mod) + return mod + + +est = _load() + +LUT = ((32, 1050), (35, 1110), (39, 1190), (83, 2550)) +COP = { + -7: {35: 2.40, 45: 2.24, 55: 1.72}, + 2: {35: 3.60, 45: 2.96, 55: 2.44}, + 7: {35: 4.80, 45: 3.30, 55: 2.86}, + 10: {35: 5.10, 45: 3.57, 55: 2.98}, +} + + +def test_interp_lut_clamp_and_mid(): + assert est.interp_lut(LUT, 10) == 1050 # below range -> clamp low + assert est.interp_lut(LUT, 999) == 2550 # above range -> clamp high + assert est.interp_lut(LUT, 33.5) == 1080.0 # 1050 + 60*(1.5/3) + + +def test_compressor_power_status_aware(): + assert est.compressor_power_w(0, 2, LUT) == 0.0 # hz<=0 + assert est.compressor_power_w(35, 0, LUT) == 0.0 # off + assert est.compressor_power_w(35, 30, LUT) == 0.0 # lock + assert est.compressor_power_w(35, 2, LUT) == 1110 # heating -> base + assert est.compressor_power_w(35, 4, LUT, k_dhw=1.1) == round(1110 * 1.1) + assert est.compressor_power_w(35, 10, LUT, k_defrost=1.05) == round(1110 * 1.05) + + +def test_cop_grid_and_bilinear(): + assert est.cop_en14511(2, 35, COP) == 3.6 # exact grid point + assert est.cop_en14511(2, 45, COP) == 2.96 + assert est.cop_en14511(4.5, 45, COP) == 3.13 # midpoint in A + assert est.cop_en14511(-20, 35, COP) == 2.40 # clamp A low + assert est.cop_en14511(2, 100, COP) == 2.44 # clamp W high + assert est.cop_en14511(None, 45, COP) == 0.0 + assert est.cop_en14511(2, 45, {}) == 0.0 + + +def test_thermal_and_defrost(): + assert est.thermal_power_compressor_w(1000, 4.0, 2) == 4000 + assert est.thermal_power_compressor_w(1000, 4.0, 0) == 0.0 # not heating + assert est.thermal_power_defrost_loss_w(1000, 2.35, 10) == round(1000 * 2.35) + assert est.thermal_power_defrost_loss_w(1000, 2.35, 2) == 0.0 + assert est.thermal_power_loop_w(4000, 6000, 1000) == 9000 + + +def test_alpha_house(): + assert est.alpha_house(0.0, 0.85, 0.4, 0.03, enabled=False) == 1.0 + assert est.alpha_house(0.01, 0.85, 0.4, 0.03) == 0.85 # within deadband -> d=0 + assert est.alpha_house(1.0, 0.85, 0.4, 0.03) == 0.45 # 0.85 - 0.4*1.0 + assert est.alpha_house(-10, 0.85, 0.4, 0.03) == 1.0 # clamp high + + +def test_estimated_flow(): + assert est.estimated_flow_m3h(4000, 0.0, 2) == 0.0 # dt too small + assert est.estimated_flow_m3h(4000, 5.0, 0) == 0.0 # not heating + # 4000 W, 5 K: 4000/(4180*5)*3.6 = 0.689... + assert est.estimated_flow_m3h(4000, 5.0, 2) == 0.69 + assert est.estimated_flow_m3h(999999, 5.0, 2) == 3.8 # clamp + + +def _run(): + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + failed = 0 + for t in tests: + try: + t() + print(f"PASS {t.__name__}") + except Exception as exc: # noqa: BLE001 + failed += 1 + print(f"FAIL {t.__name__}: {exc!r}") + print(f"\n{len(tests) - failed}/{len(tests)} passed") + return failed + + +if __name__ == "__main__": + raise SystemExit(_run()) diff --git a/tests/test_registers.py b/tests/test_registers.py new file mode 100644 index 0000000..cbe455f --- /dev/null +++ b/tests/test_registers.py @@ -0,0 +1,172 @@ +"""Pure-Python tests for the register table logic (no Home Assistant needed). + +Run directly: python3 tests/test_registers.py +Or via pytest (in CI with HA installed): pytest tests/test_registers.py +""" + +from __future__ import annotations + +import importlib.util +import os +import sys + +_HERE = os.path.dirname(__file__) +_REG_PATH = os.path.abspath( + os.path.join(_HERE, "..", "custom_components", "dimplex_wpm", "registers.py") +) + + +def _load_registers(): + spec = importlib.util.spec_from_file_location("dwpm_registers", _REG_PATH) + mod = importlib.util.module_from_spec(spec) + assert spec and spec.loader + sys.modules["dwpm_registers"] = mod # needed for dataclass annotation resolution + spec.loader.exec_module(mod) + return mod + + +reg = _load_registers() + + +def test_decode_signed_and_scale(): + # int16 negative outdoor temp: 0xFFF6 = -10 raw -> -1.0 °C + spec = reg.RegisterSpec("t", 1, signed=True, scale=0.1) + assert reg.decode_value(spec, 0xFFF6) == -1.0 + assert reg.decode_value(spec, 215) == 21.5 + # power W/10 -> kW (0.01) + p = reg.RegisterSpec("p", 5170, signed=True, scale=0.01) + assert reg.decode_value(p, 150) == 1.5 + assert reg.decode_value(p, 0xFFFF) == -0.01 + # plain code (scale 1) returns int unchanged + c = reg.RegisterSpec("c", 103) + assert reg.decode_value(c, 30) == 30 + + +def test_energy_digit_groups(): + # total = 9-12*1e8 + 5-8*1e4 + 1-4 + assert reg.energy_total_kwh(1234, 56, 7) == 7 * 100_000_000 + 56 * 10_000 + 1234 + + +def test_version_dependent_address(): + spec = next(s for s in reg.REGISTERS if s.key == "status_text") + assert spec.resolve_address("M") == 103 + assert spec.resolve_address("J") == 43 + assert spec.resolve_address("H") == 14 + se = next(s for s in reg.REGISTERS if s.key == "sensor_error_text") + assert se.resolve_address("M") == 106 + assert se.resolve_address("H") is None # not available on H + + +def test_active_registers_filtering(): + # L/M, only core modules, no meters, with RE -> inverter freq present, + # power/energy registers absent, optional-module entities absent. + core = frozenset({"controller", "hc1", "dhw", "source", "energy"}) + specs = reg.active_registers( + version="M", enabled_modules=core, + capabilities=frozenset({"inverter_freq"}), include_re=True, + ) + keys = {s.key for s in specs} + assert "inverter_frequency" in keys # RE + capability present + assert "electrical_power" not in keys # needs electric_meter capability + assert "hc2_temperature" not in keys # hc2_3 module not enabled + assert "sensor_error_text" in keys # available on M + # Drop RE -> inverter frequency disappears + specs2 = reg.active_registers( + version="M", enabled_modules=core, + capabilities=frozenset({"inverter_freq"}), include_re=False, + ) + assert "inverter_frequency" not in {s.key for s in specs2} + # H firmware -> sensor_error_text address is None -> excluded + specs_h = reg.active_registers( + version="H", enabled_modules=core, + capabilities=frozenset(), include_re=False, + ) + assert "sensor_error_text" not in {s.key for s in specs_h} + + +def test_read_plan_clusters_and_caps(): + core = frozenset({"controller", "hc1", "dhw", "source", "energy"}) + specs = reg.active_registers( + version="M", enabled_modules=core, + capabilities=frozenset({"heat_meter", "electric_meter", "inverter_freq"}), + include_re=True, + ) + groups = reg.active_energy_groups( + enabled_modules=core, capabilities=frozenset({"heat_meter"}) + ) + plan = reg.build_read_plan(specs, groups, "M") + assert plan, "plan must not be empty" + # No chunk exceeds the max read size, all counts positive. + for obj, start, count in plan: + assert obj in (reg.HOLDING, reg.COIL) + assert 1 <= count <= reg.MAX_READ_CHUNK + assert start >= 1 + # Energy registers (5096-5098) must be covered by some holding chunk. + covered = any( + obj == reg.HOLDING and start <= 5096 and 5098 <= start + count - 1 + for obj, start, count in plan + ) + assert covered, "energy registers not covered by read plan" + + +def test_no_duplicate_keys(): + keys = [s.key for s in reg.REGISTERS] + assert len(keys) == len(set(keys)), "duplicate register keys" + gkeys = [g.key for g in reg.ENERGY_GROUPS] + assert len(gkeys) == len(set(gkeys)) + wkeys = [w.key for w in reg.WRITE_REGISTERS] + assert len(wkeys) == len(set(wkeys)), "duplicate write keys" + + +def test_write_direct_encode_clamp(): + dhw = next(w for w in reg.WRITE_REGISTERS if w.key == "set_dhw_setpoint") + assert dhw.to_raw(50) == 50 + assert dhw.from_raw(50) == 50 + assert dhw.to_raw(200) == 85 # clamp to max + assert dhw.to_raw(-5) == 10 # clamp to min + + +def test_write_offset19_encode(): + off = next(w for w in reg.WRITE_REGISTERS if w.key == "set_hc1_curve_offset") + assert off.to_raw(0) == 19 + assert off.to_raw(-19) == 0 + assert off.to_raw(19) == 38 + assert off.to_raw(100) == 38 # clamp then encode + assert off.from_raw(19) == 0 + assert off.from_raw(38) == 19 + + +def test_write_cool_setpoint_encoder(): + ws = reg.WriteSpec("x", 5089, "cool", reg.M_HC2_3, min_value=15, max_value=30, + encode="cool_setpoint") + assert ws.to_raw(15) == 0 + assert ws.to_raw(30) == 30 + assert ws.to_raw(22.5) == 15 + assert ws.from_raw(30) == 30.0 + assert ws.from_raw(0) == 15.0 + + +def test_active_write_registers_module_gate(): + no_pool = reg.active_write_registers(enabled_modules=frozenset({"controller", "hc1", "dhw"})) + assert "set_pool_setpoint" not in {w.key for w in no_pool} + with_pool = reg.active_write_registers(enabled_modules=frozenset({"controller", "pool"})) + assert "set_pool_setpoint" in {w.key for w in with_pool} + assert "set_dhw_setpoint" in {w.key for w in no_pool} # ungated always present + + +def _run(): + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + failed = 0 + for t in tests: + try: + t() + print(f"PASS {t.__name__}") + except Exception as exc: # noqa: BLE001 + failed += 1 + print(f"FAIL {t.__name__}: {exc!r}") + print(f"\n{len(tests) - failed}/{len(tests)} passed") + return failed + + +if __name__ == "__main__": + raise SystemExit(_run())