From 67dbc7d4b2eb7210133e0a9d1bc18e022db48ca4 Mon Sep 17 00:00:00 2001 From: nb3rt Date: Sun, 21 Jun 2026 18:19:14 +0200 Subject: [PATCH 01/13] Add redesign foundation: design doc + canonical Modbus register map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design artifacts for the full HACS integration redesign (dimplex_wpm): - DESIGN.md: architecture, entity model + medium device tree, measurement source matrix (measured vs estimated per quantity), capabilities, native estimation engine, write-safety gating, device profiles, roadmap. - spec/REGISTERS.md: canonical, adversarially-verified register map (single source of truth) — version-aware status/lock/fault addresses (L/M target), holding-only (FC03) rule, digit-group energy formula, real power vs estimation, SG Ready, settings, enums. - spec/dimplex_modbus_spec_raw.md: verbatim dump of the official Dimplex NWPM Modbus TCP wiki (32 pages) used as the authoritative source. - existing integration/: reference YAML (working estimator "sauce") + dashboards to be ported. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 14 + DESIGN.md | 369 +++++++ existing integration/dashboards/dashboard 1 | 75 ++ existing integration/dashboards/dashboard 2 | 33 + existing integration/dashboards/dashboard 3 | 45 + existing integration/dashboards/dashboard 4 | 23 + existing integration/dimplex_lak9 (2).yaml | 997 ++++++++++++++++++ .../dimplex_lak9_energy_estimator (1).yaml | 292 +++++ ...lex_lak9_thermal_energy_estimator (1).yaml | 330 ++++++ spec/REGISTERS.md | 237 +++++ spec/dimplex_modbus_spec_raw.md | 812 ++++++++++++++ 11 files changed, 3227 insertions(+) create mode 100644 .gitignore create mode 100644 DESIGN.md create mode 100644 existing integration/dashboards/dashboard 1 create mode 100644 existing integration/dashboards/dashboard 2 create mode 100644 existing integration/dashboards/dashboard 3 create mode 100644 existing integration/dashboards/dashboard 4 create mode 100644 existing integration/dimplex_lak9 (2).yaml create mode 100644 existing integration/dimplex_lak9_energy_estimator (1).yaml create mode 100644 existing integration/dimplex_lak9_thermal_energy_estimator (1).yaml create mode 100644 spec/REGISTERS.md create mode 100644 spec/dimplex_modbus_spec_raw.md 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/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/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 + From 4d708440d2644d089feff3eebb34e5a56118991e Mon Sep 17 00:00:00 2001 From: nb3rt Date: Sun, 21 Jun 2026 18:52:27 +0200 Subject: [PATCH 02/13] M0: register-table-driven foundation (read entities) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuild the component around a single canonical register table instead of hand-written entities. No writes yet (read-only foundation). Core: - registers.py: canonical RegisterSpec/EnergyGroup table (HA-free, unit-tested) with version-dependent addresses (status/lock/fault/sensor-error per H/J/L/M), holding-only (FC03) reads + coils, scale/sign decode, digit-group energy formula, read-plan clustering (≤100 regs/chunk), module + capability gating. - profiles.py: DeviceProfile (LAK9 calibration LUT/COP for M1 + generic_wpm), per-model default modules/capabilities. - coordinator.py: rewritten to read the active register set, decode, resolve enum text (version-aware), combine energy groups, derive fault/lock flags. - entity.py: shared mixin (device_info on the medium device tree, unique_id, value-presence availability). - sensor.py / binary_sensor.py: entities generated from coordinator.specs + energy groups + controller_info; coils + derived problem flags. - const.py: medium device-tree module names, operating-mode map, enum resolver, profile/module/capability config keys + resolvers; default firmware L/M. - config_flow.py: two-step flow (connection+profile → modules+metering with device-probe auto-hint); validation now reads a holding register (FC03). - modbus_client.py: add read_coils. - manifest.json: correct docs/issue URLs, codeowner @nb3rt, integration_type hub. - strings/en translations for the new flow. Tests: tests/test_registers.py (pure-python, 6 cases) — decode, energy digit groups, version addresses, capability/module filtering, read-plan clustering. Also adds spec/DASHBOARD_DESIGN.md (background design output, for M3). Co-Authored-By: Claude Opus 4.8 --- custom_components/dimplex_wpm/__init__.py | 66 +- .../dimplex_wpm/binary_sensor.py | 104 +-- custom_components/dimplex_wpm/config_flow.py | 190 +++-- custom_components/dimplex_wpm/const.py | 115 ++- custom_components/dimplex_wpm/coordinator.py | 197 +++-- custom_components/dimplex_wpm/device.py | 35 +- custom_components/dimplex_wpm/entity.py | 53 ++ custom_components/dimplex_wpm/manifest.json | 10 +- .../dimplex_wpm/modbus_client.py | 19 + custom_components/dimplex_wpm/profiles.py | 74 ++ custom_components/dimplex_wpm/registers.py | 438 +++++++++++ custom_components/dimplex_wpm/select.py | 39 +- custom_components/dimplex_wpm/sensor.py | 353 +++------ custom_components/dimplex_wpm/strings.json | 105 +-- .../dimplex_wpm/translations/en.json | 105 +-- spec/DASHBOARD_DESIGN.md | 678 ++++++++++++++++++ tests/test_registers.py | 134 ++++ 17 files changed, 2060 insertions(+), 655 deletions(-) create mode 100644 custom_components/dimplex_wpm/entity.py create mode 100644 custom_components/dimplex_wpm/profiles.py create mode 100644 custom_components/dimplex_wpm/registers.py create mode 100644 spec/DASHBOARD_DESIGN.md create mode 100644 tests/test_registers.py diff --git a/custom_components/dimplex_wpm/__init__.py b/custom_components/dimplex_wpm/__init__.py index 066e747..e0ec1d2 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,11 +9,11 @@ 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_INCLUDE_RE_REGISTERS, + CONF_PROFILE, CONF_SCAN_INTERVAL, CONF_SOFTWARE_VERSION, CONF_TIMEOUT, @@ -24,42 +24,60 @@ 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 - - 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)) + + 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 + ) 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, host=host, port=port, unit_id=unit_id, - software_version=software_version, + profile_name=profile.key, ) await coordinator.async_config_entry_first_refresh() @@ -71,18 +89,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 +111,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..5c25f83 100644 --- a/custom_components/dimplex_wpm/binary_sensor.py +++ b/custom_components/dimplex_wpm/binary_sensor.py @@ -1,30 +1,34 @@ -"""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, -) +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 +37,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/config_flow.py b/custom_components/dimplex_wpm/config_flow.py index b09bb03..19b6323 100644 --- a/custom_components/dimplex_wpm/config_flow.py +++ b/custom_components/dimplex_wpm/config_flow.py @@ -2,34 +2,76 @@ from __future__ import annotations +from typing import Any + import voluptuous as vol 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 -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,53 +79,76 @@ 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 @@ -92,43 +157,58 @@ def async_get_options_flow(config_entry): class DimplexOptionsFlow(config_entries.OptionsFlow): - """Handle options for the integration.""" + """Options: tuning, modules, capabilities, and the M2 write gate.""" - def __init__(self, config_entry): + def __init__(self, config_entry) -> None: self.config_entry = config_entry - 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..109c747 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, + ENUM_FAULT, + ENUM_LOCK, + ENUM_OPERATING_MODE, + ENUM_SENSOR_ERROR, + ENUM_SG_READY, + ENUM_STATUS, + CORE_MODULES, + 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 @@ -382,17 +409,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: "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..d4569de 100644 --- a/custom_components/dimplex_wpm/coordinator.py +++ b/custom_components/dimplex_wpm/coordinator.py @@ -1,4 +1,4 @@ -"""Data update coordinator for the integration.""" +"""Data update coordinator — register-table driven.""" from __future__ import annotations @@ -11,46 +11,23 @@ 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 .const import DEFAULT_SCAN_INTERVAL, get_enum_map from .modbus_client import DimplexModbusClient +from .registers import ( + COIL, + HOLDING, + active_energy_groups, + active_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 +35,14 @@ 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, host: str | None = None, port: int | None = None, unit_id: int | None = None, - software_version: str | None = None, + profile_name: str | None = None, ) -> None: super().__init__( hass, @@ -71,70 +51,67 @@ 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.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._plan = build_read_plan(self.specs, self.energy_groups, software_version) + self._connection_info = { "host": host, "port": port, "unit_id": unit_id, - "register_strategy": register_strategy, "software_version": software_version, + "profile": profile_name, } - 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]) - - 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"] - ) - - 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 + values: dict[str, Any] = {"controller_info": "online"} + + 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) + + 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] + + # 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 meta = { "last_update": dt_util.utcnow().isoformat(), @@ -142,34 +119,30 @@ 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} + + 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 {"raw": raw, "derived": derived, "meta": meta} - - 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), - ] - - strategy = "holding" if self._register_strategy == "holding" else "input" - - 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") - - 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..adb489d 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,20 @@ 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}")} + + if module == MODULE_ROOT: + name = f"{DEVICE_NAME} ({host})" if host else DEVICE_NAME + else: + name = MODULE_NAME_MAP.get(module, module) + device_info: dict[str, Any] = { "identifiers": identifiers, "manufacturer": DEVICE_MANUFACTURER, @@ -31,10 +44,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/entity.py b/custom_components/dimplex_wpm/entity.py new file mode 100644 index 0000000..0310581 --- /dev/null +++ b/custom_components/dimplex_wpm/entity.py @@ -0,0 +1,53 @@ +"""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}" + if name is not None: + self._attr_name = name + 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/manifest.json b/custom_components/dimplex_wpm/manifest.json index 9a48832..6ba6f70 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", + "version": "0.2.0-dev", "config_flow": true, - "documentation": "https://github.com/DimplexModbusHACS/DimplexModbusHACS", + "documentation": "https://github.com/nb3rt/DimplexModbusHACS", + "issue_tracker": "https://github.com/nb3rt/DimplexModbusHACS/issues", "requirements": [ "pymodbus>=3.6.8" ], "codeowners": [ - "@DimplexModbusHACS" + "@nb3rt" ], - "iot_class": "local_polling" + "iot_class": "local_polling", + "integration_type": "hub" } diff --git a/custom_components/dimplex_wpm/modbus_client.py b/custom_components/dimplex_wpm/modbus_client.py index 641fca2..a4757c9 100644 --- a/custom_components/dimplex_wpm/modbus_client.py +++ b/custom_components/dimplex_wpm/modbus_client.py @@ -70,6 +70,25 @@ 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 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/registers.py b/custom_components/dimplex_wpm/registers.py new file mode 100644 index 0000000..de2a0dd --- /dev/null +++ b/custom_components/dimplex_wpm/registers.py @@ -0,0 +1,438 @@ +"""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", + 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, "Passive cooling flow temperature", flag=M_COOLING), + _temp("cooling_return_temperature", 20, M_COOLING, "Passive cooling return temperature", flag=M_COOLING), + _temp("cooling_primary_return_temperature", 21, M_COOLING, "Cooling primary return temperature", flag=M_COOLING), + + # ===== Solar (optional) — collector shares reg 10 with HC3 by config ===== + _temp("solar_collector_temperature", 10, M_SOLAR, "Solar collector temperature", flag=M_SOLAR, re=False), + _temp("solar_tank_temperature", 23, M_SOLAR, "Solar tank temperature", flag=M_SOLAR), + + # ===== Ventilation (optional) ===== + _temp("vent_outdoor_air_temperature", 120, M_VENT, "Ventilation outdoor air temperature", flag=M_VENT), + _temp("vent_supply_air_temperature", 121, M_VENT, "Ventilation supply air temperature", flag=M_VENT), + _temp("vent_extract_air_temperature", 122, M_VENT, "Ventilation extract air temperature", flag=M_VENT), + _temp("vent_exhaust_air_temperature", 123, M_VENT, "Ventilation 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="Ventilation 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="Ventilation extract fan speed", icon="mdi:fan"), + RegisterSpec("vent_level", 5034, state_class="measurement", entity_category="diagnostic", + module=M_VENT, module_flag=M_VENT, name="Ventilation 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"), + RegisterSpec("pv_surplus", 5182, signed=True, scale=0.01, unit="kW", + device_class="power", state_class="measurement", module=M_ENERGY, + entity_category="diagnostic", 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"), + EnergyGroup("energy_dhw", 5099, 5100, 5101, "DHW energy"), + EnergyGroup("energy_pool", 5102, 5103, 5104, "Pool energy", module=M_POOL), + EnergyGroup("energy_environment", 5127, 5128, 5129, "Environmental energy"), +) + + +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, +) -> 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`. + """ + holding: set[int] = set() + 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 diff --git a/custom_components/dimplex_wpm/select.py b/custom_components/dimplex_wpm/select.py index 89826ab..9b8f155 100644 --- a/custom_components/dimplex_wpm/select.py +++ b/custom_components/dimplex_wpm/select.py @@ -15,9 +15,8 @@ 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 @@ -35,7 +34,16 @@ async def async_setup_entry( if allow_write: async_add_entities( - [DimplexSGReadySelect(coordinator, entry, allow_write)], + [ + DimplexSGReadySelect( + coordinator, + entry, + allow_write, + host=data.get("host"), + software_version=data.get("software_version"), + model=data.get("model"), + ) + ], ) @@ -45,18 +53,35 @@ 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") + return self.coordinator.data.get("values", {}).get("sg_ready_text") @property def options(self) -> list[str]: diff --git a/custom_components/dimplex_wpm/sensor.py b/custom_components/dimplex_wpm/sensor.py index eb6945d..eb2bff4 100644 --- a/custom_components/dimplex_wpm/sensor.py +++ b/custom_components/dimplex_wpm/sensor.py @@ -1,195 +1,40 @@ -"""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 ( SensorDeviceClass, SensorEntity, - SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, UnitOfTemperature +from homeassistant.const import EntityCategory, UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -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 .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, - ), -) +from .const import DOMAIN, MODULE_ROOT +from .entity import DimplexEntityMixin +from .registers import HOLDING, RegisterSpec, EnergyGroup + +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, +} async def async_setup_entry( @@ -197,78 +42,108 @@ 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) + ) - 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), - } + async_add_entities(entities) - 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)) - async_add_entities(entities) +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 + + @property + def native_value(self) -> Any: + return (self.coordinator.data or {}).get("values", {}).get(self._spec.key) -class DimplexSensor(CoordinatorEntity, SensorEntity): - """Representation of a Dimplex sensor.""" - entity_description: DimplexSensorEntityDescription +class DimplexEnergySensor(DimplexEntityMixin, CoordinatorEntity, SensorEntity): + """A combined digit-group energy counter (kWh).""" - def __init__( - self, - coordinator, - entry: ConfigEntry, - description: DimplexSensorEntityDescription, - integration_flags: dict[str, Any], - ) -> 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"), + _attr_device_class = SensorDeviceClass.ENERGY + _attr_state_class = SensorStateClass.TOTAL_INCREASING + _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR + + 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, ) - self._attr_unique_id = f"{entry.entry_id}_{description.module}_{description.key}" @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) -> Any: + return (self.coordinator.data or {}).get("values", {}).get("controller_info") @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 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"), + "consecutive_failures": meta.get("consecutive_failures"), + "enabled_modules": sorted(self.coordinator.enabled_modules), + "capabilities": sorted(self.coordinator.capabilities), + } diff --git a/custom_components/dimplex_wpm/strings.json b/custom_components/dimplex_wpm/strings.json index 52b4f42..c8d1b97 100644 --- a/custom_components/dimplex_wpm/strings.json +++ b/custom_components/dimplex_wpm/strings.json @@ -3,102 +3,55 @@ "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": { - "controller_info": { - "name": "Controller info" - }, - "outdoor_temperature": { - "name": "Outdoor temperature" - }, - "return_temperature": { - "name": "Return temperature" - }, - "return_setpoint_temperature": { - "name": "Return setpoint temperature" - }, - "flow_temperature": { - "name": "Flow temperature" - }, - "dhw_temperature": { - "name": "DHW temperature" - }, - "status_code": { - "name": "Status code" - }, - "status_text": { - "name": "Status" - }, - "lock_code": { - "name": "Lock code" - }, - "lock_text": { - "name": "Lock" - }, - "fault_code": { - "name": "Fault code" - }, - "fault_text": { - "name": "Fault" - }, - "sensor_error_code": { - "name": "Sensor error code" - }, - "sensor_error_text": { - "name": "Sensor error" - }, - "sg_ready_code": { - "name": "SG Ready code" - }, - "sg_ready_text": { - "name": "SG Ready state" - } - }, - "binary_sensor": { - "fault_active": { - "name": "Fault active" - }, - "lock_active": { - "name": "Lock active" - } - }, - "select": { - "sg_ready_mode": { - "name": "SG Ready mode" - } - } } } diff --git a/custom_components/dimplex_wpm/translations/en.json b/custom_components/dimplex_wpm/translations/en.json index f36eca5..c8d1b97 100644 --- a/custom_components/dimplex_wpm/translations/en.json +++ b/custom_components/dimplex_wpm/translations/en.json @@ -3,102 +3,55 @@ "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": { - "controller_info": { - "name": "Controller info" - }, - "outdoor_temperature": { - "name": "Outdoor temperature" - }, - "return_temperature": { - "name": "Return temperature" - }, - "return_setpoint_temperature": { - "name": "Return setpoint temperature" - }, - "flow_temperature": { - "name": "Flow temperature" - }, - "dhw_temperature": { - "name": "DHW temperature" - }, - "status_code": { - "name": "Status code" - }, - "status_text": { - "name": "Status" - }, - "lock_code": { - "name": "Lock code" - }, - "lock_text": { - "name": "Lock" - }, - "fault_code": { - "name": "Fault code" - }, - "fault_text": { - "name": "Fault" - }, - "sensor_error_code": { - "name": "Sensor error code" - }, - "sensor_error_text": { - "name": "Sensor error" - }, - "sg_ready_code": { - "name": "SG Ready code" - }, - "sg_ready_text": { - "name": "SG Ready state" - } - }, - "binary_sensor": { - "fault_active": { - "name": "Fault active" - }, - "lock_active": { - "name": "Lock active" - } - }, - "select": { - "sg_ready_mode": { - "name": "SG Ready mode" - } - } } } diff --git a/spec/DASHBOARD_DESIGN.md b/spec/DASHBOARD_DESIGN.md new file mode 100644 index 0000000..7bae433 --- /dev/null +++ b/spec/DASHBOARD_DESIGN.md @@ -0,0 +1,678 @@ +# Dimplex WPM — Dashboard Design + +> **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/tests/test_registers.py b/tests/test_registers.py new file mode 100644 index 0000000..d325f95 --- /dev/null +++ b/tests/test_registers.py @@ -0,0 +1,134 @@ +"""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)) + + +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()) From 4beb971d41651bb8847c3d63d95b04c6af13e312 Mon Sep 17 00:00:00 2001 From: nb3rt Date: Sun, 21 Jun 2026 19:05:23 +0200 Subject: [PATCH 03/13] M1: native estimation engine + energy + calibration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the estimation "sauce" natively (used when a meter is absent; see the measurement-source matrix). All formulas are pure/unit-tested. - estimation.py: Hz→W LUT (status-aware: DHW/defrost factors), EN14511 2D-COP interpolation (fixes the YAML round() bracketing bug), compressor/heater/total power, thermal compressor/heater/defrost-loss/loop, house/installation split via alpha(dΔT/dt), hydraulic flow estimate. - coordinator.py: computes derived power/COP/heat/flow each cycle, ΔT + its time derivative, and measured-vs-estimated selection (electrical_power_best / heat_output_best, cop_measured). Live tunables dict seeded from the profile. - sensor.py: Analytics device gains computed sensors (each tagged source= measured|estimated) and trapezoidal kWh integration sensors (RestoreSensor): electrical energy, heat energy, heat-to-house, heat-to-installation. Gated by capability / estimation_possible. - number.py (new platform): live calibration entities (k_dhw, k_defrost, k_defrost_loss, heater/pump power, alpha base/sensitivity/deadband) → coordinator.tunables, RestoreNumber-persisted, NOT behind the write gate. - __init__.py: pass the profile object + flow-sensor entity; add NUMBER platform. - const.py: drop dead register-strategy / EMS-BMS-lock stubs (superseded by capabilities). Tests: tests/test_estimation.py (6 cases) — LUT, status-aware power, COP grid+bilinear+clamp, thermal/defrost, alpha deadband/clamp, flow clamp. Total 12/12 pure-python tests pass. Co-Authored-By: Claude Opus 4.8 --- custom_components/dimplex_wpm/__init__.py | 11 +- custom_components/dimplex_wpm/const.py | 15 +- custom_components/dimplex_wpm/coordinator.py | 124 ++++++++++++- custom_components/dimplex_wpm/estimation.py | 145 +++++++++++++++ custom_components/dimplex_wpm/number.py | 107 +++++++++++ custom_components/dimplex_wpm/sensor.py | 182 ++++++++++++++++++- tests/test_estimation.py | 101 ++++++++++ 7 files changed, 665 insertions(+), 20 deletions(-) create mode 100644 custom_components/dimplex_wpm/estimation.py create mode 100644 custom_components/dimplex_wpm/number.py create mode 100644 tests/test_estimation.py diff --git a/custom_components/dimplex_wpm/__init__.py b/custom_components/dimplex_wpm/__init__.py index e0ec1d2..2679334 100644 --- a/custom_components/dimplex_wpm/__init__.py +++ b/custom_components/dimplex_wpm/__init__.py @@ -12,6 +12,7 @@ CAPABILITY_CONF_MAP, CONF_ENABLE_WRITE_ENTITIES, CONF_ENABLED_MODULES, + CONF_FLOW_SENSOR_ENTITY, CONF_INCLUDE_RE_REGISTERS, CONF_PROFILE, CONF_SCAN_INTERVAL, @@ -34,7 +35,12 @@ LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.SELECT] +PLATFORMS = [ + Platform.SENSOR, + Platform.BINARY_SENSOR, + Platform.NUMBER, + Platform.SELECT, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -74,10 +80,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: enabled_modules=enabled_modules, capabilities=capabilities, include_re=include_re, + profile=profile, + flow_sensor_entity=source.get(CONF_FLOW_SENSOR_ENTITY) or None, host=host, port=port, unit_id=unit_id, - profile_name=profile.key, ) await coordinator.async_config_entry_first_refresh() diff --git a/custom_components/dimplex_wpm/const.py b/custom_components/dimplex_wpm/const.py index 109c747..2fed40f 100644 --- a/custom_components/dimplex_wpm/const.py +++ b/custom_components/dimplex_wpm/const.py @@ -49,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"] diff --git a/custom_components/dimplex_wpm/coordinator.py b/custom_components/dimplex_wpm/coordinator.py index d4569de..4b197c7 100644 --- a/custom_components/dimplex_wpm/coordinator.py +++ b/custom_components/dimplex_wpm/coordinator.py @@ -11,9 +11,15 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util +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, @@ -39,10 +45,11 @@ def __init__( enabled_modules: frozenset[str], capabilities: frozenset[str], include_re: bool, + profile: DeviceProfile, + flow_sensor_entity: str | None = None, host: str | None = None, port: int | None = None, unit_id: int | None = None, - profile_name: str | None = None, ) -> None: super().__init__( hass, @@ -54,6 +61,28 @@ def __init__( 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.specs = active_registers( version=software_version, @@ -71,7 +100,7 @@ def __init__( "port": port, "unit_id": unit_id, "software_version": software_version, - "profile": profile_name, + "profile": profile.key, } self._consecutive_failures = 0 @@ -113,6 +142,8 @@ async def _async_update_data(self) -> dict[str, Any]: if "lock_code" in values: values["lock_active"] = values["lock_code"] != 0 + self._add_estimation(values) + meta = { "last_update": dt_util.utcnow().isoformat(), "update_success": True, @@ -121,6 +152,95 @@ async def _async_update_data(self) -> dict[str, Any]: } 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 + + # 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"] + + 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"] + + 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) + + 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 + async def _execute_plan(self) -> tuple[dict[int, int], dict[int, bool]]: holding: dict[int, int] = {} coils: dict[int, bool] = {} 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/number.py b/custom_components/dimplex_wpm/number.py new file mode 100644 index 0000000..5154296 --- /dev/null +++ b/custom_components/dimplex_wpm/number.py @@ -0,0 +1,107 @@ +"""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 homeassistant.components.number import NumberMode, RestoreNumber +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .entity import DimplexEntityMixin +from .registers import M_ENERGY + + +@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"] + if not coordinator.estimation_possible: + return + + host = data.get("host") + version = coordinator.software_version + model = data.get("model") + async_add_entities( + DimplexCalibrationNumber(coordinator, entry, spec, host=host, version=version, model=model) + for spec in CALIBRATION + ) + + +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() diff --git a/custom_components/dimplex_wpm/sensor.py b/custom_components/dimplex_wpm/sensor.py index eb2bff4..2ead0b6 100644 --- a/custom_components/dimplex_wpm/sensor.py +++ b/custom_components/dimplex_wpm/sensor.py @@ -4,20 +4,32 @@ from typing import Any +from dataclasses import dataclass + from homeassistant.components.sensor import ( + RestoreSensor, SensorDeviceClass, SensorEntity, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfEnergy -from homeassistant.core import HomeAssistant +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 DOMAIN, MODULE_ROOT from .entity import DimplexEntityMixin -from .registers import HOLDING, RegisterSpec, EnergyGroup +from .registers import ( + CAP_ELECTRIC_METER, + CAP_FLOW_SENSOR, + CAP_HEAT_METER, + HOLDING, + M_ENERGY, + EnergyGroup, + RegisterSpec, +) DEVICE_CLASS_MAP = { "temperature": SensorDeviceClass.TEMPERATURE, @@ -37,6 +49,30 @@ } +@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"), +) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, @@ -62,6 +98,65 @@ async def async_setup_entry( 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", "ΔT (flow − return)", "K", None, "mdi:delta", "measured") + if coordinator.estimation_possible: + computed("ddelta_t_dt", "ΔT 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) + 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", + ) + + # ----- 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: + energy( + "electrical_energy_kwh", "electrical_power_best", "Electrical energy", + "measured" if CAP_ELECTRIC_METER in caps else "estimated", + ) + if CAP_HEAT_METER in caps or coordinator.estimation_possible: + energy( + "heat_energy_kwh", "heat_output_best", "Heat energy", + "measured" if CAP_HEAT_METER in caps else "estimated", + ) + if coordinator.estimation_possible: + energy("heat_energy_to_house_kwh", "thermal_power_to_house", "Heat energy to house", "estimated") + energy( + "heat_energy_to_installation_kwh", "thermal_power_to_installation", + "Heat energy to installation", "estimated", + ) + async_add_entities(entities) @@ -144,6 +239,89 @@ def extra_state_attributes(self) -> dict[str, Any]: "profile": meta.get("profile"), "last_update": meta.get("last_update"), "consecutive_failures": meta.get("consecutive_failures"), + "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, key, source_key, name, source, + *, host, version, model, + ) -> None: + 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_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) -> float: + return round(self._energy, 3) + + @property + def available(self) -> bool: + return self.coordinator.last_update_success 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()) From f163f04c254768534d89a15b61ca5fbbde4254a8 Mon Sep 17 00:00:00 2001 From: nb3rt Date: Sun, 21 Jun 2026 19:39:35 +0200 Subject: [PATCH 04/13] CI: hassfest + HACS validation + ruff + pytest - .github/workflows/ci.yml: hassfest, hacs/action (category integration, ignore brands until submitted), ruff check, pytest. - manifest.json: sort keys to hassfest order (domain, name, then alphabetical); pin version 0.2.0. - hacs.json: keep only valid keys (name, country, homeassistant, render_readme); drop non-schema domains/integration_type/filename/zip_release. - pyproject.toml: ruff (E/F/W/I/UP, isort force-sort-within-sections) + pytest config. Applied ruff import-sort autofix across the package. Locally green: ruff clean, 12/12 pytest, manifest hassfest-sorted. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 41 +++++++++++++++++++ custom_components/dimplex_wpm/config_flow.py | 3 +- custom_components/dimplex_wpm/const.py | 2 +- custom_components/dimplex_wpm/coordinator.py | 2 +- custom_components/dimplex_wpm/manifest.json | 12 +++--- .../dimplex_wpm/modbus_client.py | 5 ++- custom_components/dimplex_wpm/select.py | 3 +- custom_components/dimplex_wpm/sensor.py | 3 +- hacs.json | 6 +-- pyproject.toml | 16 ++++++++ 10 files changed, 72 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 pyproject.toml 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/custom_components/dimplex_wpm/config_flow.py b/custom_components/dimplex_wpm/config_flow.py index 19b6323..791e962 100644 --- a/custom_components/dimplex_wpm/config_flow.py +++ b/custom_components/dimplex_wpm/config_flow.py @@ -4,12 +4,11 @@ from typing import Any -import voluptuous as vol - 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 .const import ( CONF_ENABLE_WRITE_ENTITIES, diff --git a/custom_components/dimplex_wpm/const.py b/custom_components/dimplex_wpm/const.py index 2fed40f..1bdf68f 100644 --- a/custom_components/dimplex_wpm/const.py +++ b/custom_components/dimplex_wpm/const.py @@ -9,13 +9,13 @@ 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, - CORE_MODULES, M_CONTROLLER, M_COOLING, M_DHW, diff --git a/custom_components/dimplex_wpm/coordinator.py b/custom_components/dimplex_wpm/coordinator.py index 4b197c7..76098a6 100644 --- a/custom_components/dimplex_wpm/coordinator.py +++ b/custom_components/dimplex_wpm/coordinator.py @@ -2,9 +2,9 @@ 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 diff --git a/custom_components/dimplex_wpm/manifest.json b/custom_components/dimplex_wpm/manifest.json index 6ba6f70..3a6eea7 100644 --- a/custom_components/dimplex_wpm/manifest.json +++ b/custom_components/dimplex_wpm/manifest.json @@ -1,16 +1,16 @@ { "domain": "dimplex_wpm", "name": "Dimplex WPM", - "version": "0.2.0-dev", + "codeowners": [ + "@nb3rt" + ], "config_flow": true, "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": [ - "@nb3rt" - ], - "iot_class": "local_polling", - "integration_type": "hub" + "version": "0.2.0" } diff --git a/custom_components/dimplex_wpm/modbus_client.py b/custom_components/dimplex_wpm/modbus_client.py index a4757c9..7f25608 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: diff --git a/custom_components/dimplex_wpm/select.py b/custom_components/dimplex_wpm/select.py index 9b8f155..f4df6b6 100644 --- a/custom_components/dimplex_wpm/select.py +++ b/custom_components/dimplex_wpm/select.py @@ -2,14 +2,13 @@ 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, diff --git a/custom_components/dimplex_wpm/sensor.py b/custom_components/dimplex_wpm/sensor.py index 2ead0b6..54fef6a 100644 --- a/custom_components/dimplex_wpm/sensor.py +++ b/custom_components/dimplex_wpm/sensor.py @@ -2,9 +2,8 @@ from __future__ import annotations -from typing import Any - from dataclasses import dataclass +from typing import Any from homeassistant.components.sensor import ( RestoreSensor, 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" From 851ef333623a90e77309ae673c9d28837865fb95 Mon Sep 17 00:00:00 2001 From: nb3rt Date: Sun, 21 Jun 2026 19:48:46 +0200 Subject: [PATCH 05/13] M2: writable controls behind the enable_control gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only by default; all writes go to the heat pump only when the "enable control" option is on, are range-clamped, and use documented encodings. - registers.py: WriteSpec table + pure encode/decode (to_raw/from_raw), incl. enum-coded registers (5036 curve offset K=raw-19; cool-setpoint 15+0.5·raw). Writable set: DHW setpoint/min/max, HC1 room/fixed-flow/curve-end/curve-offset, pool setpoint (module-gated), operating-mode select. build_read_plan gains extra_holding so current values are read back. - coordinator.py: enable_control flag → active_write_registers, their addresses added to the read plan, decoded into values (number=display, select=raw code). - number.py: DimplexWritableNumber (writes holding register, range-clamped, ModbusException→HomeAssistantError). Calibration numbers unchanged/ungated. - select.py: DimplexWriteSelect (operating mode) alongside the existing SG Ready select; both gated. - __init__.py: pass enable_control from options. Tests: +4 (write encode/decode/clamp, offset19, cool-setpoint, module gate); 16/16 pure-python tests pass. NOTE: exact register scaling for some setpoints is unverified without hardware; gate is OFF by default and writes are range-validated. Climate entities (HC1/DHW) are the remaining M2 sub-item. Co-Authored-By: Claude Opus 4.8 --- custom_components/dimplex_wpm/__init__.py | 2 + custom_components/dimplex_wpm/coordinator.py | 16 ++- custom_components/dimplex_wpm/number.py | 79 +++++++++++++-- custom_components/dimplex_wpm/registers.py | 101 ++++++++++++++++++- custom_components/dimplex_wpm/select.py | 63 +++++++++--- tests/test_registers.py | 38 +++++++ 6 files changed, 275 insertions(+), 24 deletions(-) diff --git a/custom_components/dimplex_wpm/__init__.py b/custom_components/dimplex_wpm/__init__.py index 2679334..5d38f22 100644 --- a/custom_components/dimplex_wpm/__init__.py +++ b/custom_components/dimplex_wpm/__init__.py @@ -70,6 +70,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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( @@ -82,6 +83,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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, diff --git a/custom_components/dimplex_wpm/coordinator.py b/custom_components/dimplex_wpm/coordinator.py index 76098a6..e5fad1c 100644 --- a/custom_components/dimplex_wpm/coordinator.py +++ b/custom_components/dimplex_wpm/coordinator.py @@ -24,6 +24,7 @@ HOLDING, active_energy_groups, active_registers, + active_write_registers, build_read_plan, decode_value, energy_total_kwh, @@ -47,6 +48,7 @@ def __init__( 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, @@ -93,7 +95,14 @@ def __init__( self.energy_groups = active_energy_groups( enabled_modules=enabled_modules, capabilities=capabilities ) - self._plan = build_read_plan(self.specs, self.energy_groups, software_version) + 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, @@ -136,6 +145,11 @@ async def _async_update_data(self) -> dict[str, Any]: 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 diff --git a/custom_components/dimplex_wpm/number.py b/custom_components/dimplex_wpm/number.py index 5154296..ec3e8f1 100644 --- a/custom_components/dimplex_wpm/number.py +++ b/custom_components/dimplex_wpm/number.py @@ -8,17 +8,27 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Any -from homeassistant.components.number import NumberMode, RestoreNumber +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 DOMAIN +from .const import CONF_ENABLE_WRITE_ENTITIES, DEFAULT_ENABLE_WRITE, DOMAIN from .entity import DimplexEntityMixin -from .registers import M_ENERGY +from .registers import KIND_NUMBER, M_ENERGY, WriteSpec + +NUMBER_DEVICE_CLASS_MAP = {"temperature": NumberDeviceClass.TEMPERATURE} @dataclass(frozen=True) @@ -54,16 +64,28 @@ async def async_setup_entry( """Create calibration numbers (only when estimation is active).""" data = hass.data[DOMAIN][entry.entry_id] coordinator = data["coordinator"] - if not coordinator.estimation_possible: - return - host = data.get("host") version = coordinator.software_version model = data.get("model") - async_add_entities( - DimplexCalibrationNumber(coordinator, entry, spec, host=host, version=version, model=model) - for spec in CALIBRATION - ) + + 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): @@ -105,3 +127,40 @@ async def async_set_native_value(self, value: float) -> None: 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 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/registers.py b/custom_components/dimplex_wpm/registers.py index de2a0dd..74b43eb 100644 --- a/custom_components/dimplex_wpm/registers.py +++ b/custom_components/dimplex_wpm/registers.py @@ -382,13 +382,16 @@ 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() + holding: set[int] = set(extra_holding or ()) coils: set[int] = set() for s in specs: addr = s.resolve_address(version) @@ -436,3 +439,99 @@ def decode_value(spec: RegisterSpec, raw: int) -> float | int: 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 + WriteSpec("set_hc1_room_setpoint", 46, "HC1 room setpoint", M_HC1, min_value=15, max_value=30, + step=0.5, 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"), + # 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 f4df6b6..4df69c3 100644 --- a/custom_components/dimplex_wpm/select.py +++ b/custom_components/dimplex_wpm/select.py @@ -19,6 +19,8 @@ SG_READY_REVERSE, ) from .device import build_device_info +from .entity import DimplexEntityMixin +from .registers import KIND_SELECT, WriteSpec async def async_setup_entry( @@ -31,19 +33,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, - host=data.get("host"), - software_version=data.get("software_version"), - model=data.get("model"), - ) - ], + if not allow_write: + return + + host = data.get("host") + version = data.get("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): @@ -102,3 +109,35 @@ 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 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/tests/test_registers.py b/tests/test_registers.py index d325f95..cbe455f 100644 --- a/tests/test_registers.py +++ b/tests/test_registers.py @@ -114,6 +114,44 @@ def test_no_duplicate_keys(): 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(): From 64143df1fecb690621ef59231c6eedc87cba3763 Mon Sep 17 00:00:00 2001 From: nb3rt Date: Sun, 21 Jun 2026 20:01:39 +0200 Subject: [PATCH 06/13] Close gaps from pre-M3 audit (incl. unload crash) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial audit (4 agents) findings addressed: Critical/blocking: - modbus_client.close(): pymodbus 3.x close() is SYNC — drop the await (was a guaranteed TypeError on every unload/reload), wrap defensively. - device.py: remove host/IP from the root device name (it seeds entity_ids → was baking the host into every controller entity_id, non-portable). - __init__: default to the 'lak9' profile when none is set (was silently falling back to generic → empty Analytics). Major/minor: - select.py: SG Ready select uses _attr_name (had translation_key with no translation → unnamed/unstable id); guard current_option against unmapped codes; writable select availability based on last_update_success. - number.py: writable-number availability based on last_update_success (operable even if read-back register is absent). - config_flow.py: OptionsFlow no longer assigns self.config_entry (HA 2024.11+ deprecation); async_get_options_flow() takes no arg. - sensor.py: suppress estimated heat_energy_kwh when a heat meter exists (Energy Dashboard double-count); (meter)/(est.) name suffixes; energy-group display precision; add flow_rate_smoothed (EMA) sensor. - coordinator.py: compute EMA-smoothed flow. - registers.py: inverter_frequency device_class=frequency; HC1 room setpoint step=1 (avoid silent 0.5 truncation; scaling "verify on device"); gate pv_surplus behind electric-meter cap; energy groups named "(meter)"; add HC2/3 cooling-setpoint WriteSpec (uses the previously-dead cool_setpoint encoder). Deferred (tracked): climate (HC1/DHW) platform; full dashboard entity-id reconcile (done in M3); on-device verification of setpoint scaling. ruff clean, 16/16 pytest, manifest hassfest-sorted. Co-Authored-By: Claude Opus 4.8 --- custom_components/dimplex_wpm/__init__.py | 3 ++- custom_components/dimplex_wpm/config_flow.py | 9 ++++---- custom_components/dimplex_wpm/coordinator.py | 9 ++++++++ custom_components/dimplex_wpm/device.py | 4 +++- .../dimplex_wpm/modbus_client.py | 7 +++++-- custom_components/dimplex_wpm/number.py | 5 +++++ custom_components/dimplex_wpm/registers.py | 21 ++++++++++++------- custom_components/dimplex_wpm/select.py | 11 ++++++++-- custom_components/dimplex_wpm/sensor.py | 21 +++++++++++-------- 9 files changed, 64 insertions(+), 26 deletions(-) diff --git a/custom_components/dimplex_wpm/__init__.py b/custom_components/dimplex_wpm/__init__.py index 5d38f22..ee852bc 100644 --- a/custom_components/dimplex_wpm/__init__.py +++ b/custom_components/dimplex_wpm/__init__.py @@ -20,6 +20,7 @@ CONF_TIMEOUT, CONF_UNIT_ID, DEFAULT_ENABLE_WRITE, + DEFAULT_PROFILE, DEFAULT_SCAN_INTERVAL, DEFAULT_SOFTWARE_VERSION, DEFAULT_TIMEOUT, @@ -55,7 +56,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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)) + profile = get_profile(source.get(CONF_PROFILE, DEFAULT_PROFILE)) if CONF_ENABLED_MODULES in source: enabled_modules = resolve_enabled_modules(source) diff --git a/custom_components/dimplex_wpm/config_flow.py b/custom_components/dimplex_wpm/config_flow.py index 791e962..a07c176 100644 --- a/custom_components/dimplex_wpm/config_flow.py +++ b/custom_components/dimplex_wpm/config_flow.py @@ -152,14 +152,15 @@ async def async_step_features(self, user_input: dict | None = None): @staticmethod @callback def async_get_options_flow(config_entry): - return DimplexOptionsFlow(config_entry) + return DimplexOptionsFlow() class DimplexOptionsFlow(config_entries.OptionsFlow): - """Options: tuning, modules, capabilities, and the M2 write gate.""" + """Options: tuning, modules, capabilities, and the control gate. - def __init__(self, config_entry) -> None: - self.config_entry = config_entry + ``self.config_entry`` is provided by the base class (HA 2024.11+); do not + assign it manually. + """ def _current(self, key, default): return self.config_entry.options.get( diff --git a/custom_components/dimplex_wpm/coordinator.py b/custom_components/dimplex_wpm/coordinator.py index e5fad1c..c5fe458 100644 --- a/custom_components/dimplex_wpm/coordinator.py +++ b/custom_components/dimplex_wpm/coordinator.py @@ -85,6 +85,7 @@ def __init__( } 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, @@ -226,6 +227,14 @@ def _add_estimation(self, values: dict[str, Any]) -> None: 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: diff --git a/custom_components/dimplex_wpm/device.py b/custom_components/dimplex_wpm/device.py index adb489d..2744dd5 100644 --- a/custom_components/dimplex_wpm/device.py +++ b/custom_components/dimplex_wpm/device.py @@ -31,8 +31,10 @@ def build_device_info( 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 = f"{DEVICE_NAME} ({host})" if host else DEVICE_NAME + name = DEVICE_NAME else: name = MODULE_NAME_MAP.get(module, module) diff --git a/custom_components/dimplex_wpm/modbus_client.py b/custom_components/dimplex_wpm/modbus_client.py index 7f25608..9d0359b 100644 --- a/custom_components/dimplex_wpm/modbus_client.py +++ b/custom_components/dimplex_wpm/modbus_client.py @@ -48,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 diff --git a/custom_components/dimplex_wpm/number.py b/custom_components/dimplex_wpm/number.py index ec3e8f1..1dbcc02 100644 --- a/custom_components/dimplex_wpm/number.py +++ b/custom_components/dimplex_wpm/number.py @@ -151,6 +151,11 @@ def __init__(self, coordinator, entry, ws: WriteSpec, *, host, version, model) - 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) diff --git a/custom_components/dimplex_wpm/registers.py b/custom_components/dimplex_wpm/registers.py index 74b43eb..2a6361c 100644 --- a/custom_components/dimplex_wpm/registers.py +++ b/custom_components/dimplex_wpm/registers.py @@ -128,7 +128,7 @@ def _temp(key, address, module, name, *, category=None, flag=None, cap=None, re= # ===== Controller: ambient, mode, diagnostics ===== _temp("outdoor_temperature", 1, M_CONTROLLER, "Outdoor temperature"), RegisterSpec( - "inverter_frequency", 114, scale=0.1, unit="Hz", + "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", @@ -261,7 +261,8 @@ def _temp(key, address, module, name, *, category=None, flag=None, cap=None, re= capability=CAP_ELECTRIC_METER, name="Electrical power"), RegisterSpec("pv_surplus", 5182, signed=True, scale=0.01, unit="kW", device_class="power", state_class="measurement", module=M_ENERGY, - entity_category="diagnostic", name="PV surplus", icon="mdi:solar-power-variant"), + 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", @@ -320,10 +321,10 @@ def _temp(key, address, module, name, *, category=None, flag=None, cap=None, re= ENERGY_GROUPS: tuple[EnergyGroup, ...] = ( - EnergyGroup("energy_heating", 5096, 5097, 5098, "Heating energy"), - EnergyGroup("energy_dhw", 5099, 5100, 5101, "DHW energy"), - EnergyGroup("energy_pool", 5102, 5103, 5104, "Pool energy", module=M_POOL), - EnergyGroup("energy_environment", 5127, 5128, 5129, "Environmental energy"), + 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)"), ) @@ -507,8 +508,10 @@ def from_raw(self, raw: int) -> float | int: 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=0.5, unit="°C", device_class="temperature", icon="mdi:home-thermometer"), + 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, @@ -518,6 +521,10 @@ def from_raw(self, raw: int) -> float | int: # 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", diff --git a/custom_components/dimplex_wpm/select.py b/custom_components/dimplex_wpm/select.py index 4df69c3..7fc6182 100644 --- a/custom_components/dimplex_wpm/select.py +++ b/custom_components/dimplex_wpm/select.py @@ -57,7 +57,7 @@ class DimplexSGReadySelect(CoordinatorEntity, SelectEntity): """Representation of the SG Ready mode select.""" _attr_has_entity_name = True - _attr_translation_key = "sg_ready_mode" + _attr_name = "SG Ready mode" def __init__( self, @@ -87,7 +87,9 @@ def __init__( def current_option(self) -> str | None: if not self.coordinator.data: return None - return self.coordinator.data.get("values", {}).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]: @@ -126,6 +128,11 @@ def __init__(self, coordinator, entry: ConfigEntry, ws: WriteSpec, *, host, vers 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) diff --git a/custom_components/dimplex_wpm/sensor.py b/custom_components/dimplex_wpm/sensor.py index 54fef6a..de55a3b 100644 --- a/custom_components/dimplex_wpm/sensor.py +++ b/custom_components/dimplex_wpm/sensor.py @@ -115,6 +115,7 @@ def computed(key, name, unit, dc, icon, source): 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: @@ -140,20 +141,21 @@ def energy(key, source_key, name, source): ) if CAP_ELECTRIC_METER in caps or coordinator.estimation_possible: + elec_measured = CAP_ELECTRIC_METER in caps energy( - "electrical_energy_kwh", "electrical_power_best", "Electrical energy", - "measured" if CAP_ELECTRIC_METER in caps else "estimated", - ) - if CAP_HEAT_METER in caps or coordinator.estimation_possible: - energy( - "heat_energy_kwh", "heat_output_best", "Heat energy", - "measured" if CAP_HEAT_METER in caps else "estimated", + "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", "estimated") + 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", "estimated", + "Heat energy to installation (est.)", "estimated", ) async_add_entities(entities) @@ -191,6 +193,7 @@ class DimplexEnergySensor(DimplexEntityMixin, CoordinatorEntity, SensorEntity): _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) From 33928a8f415477c487321980094f03c7a5a0905d Mon Sep 17 00:00:00 2001 From: nb3rt Date: Sun, 21 Jun 2026 21:41:33 +0200 Subject: [PATCH 07/13] M3 v1: importable dashboards + entity-id reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authored the front-end against the REAL (derived) entity-id scheme, per the pre-M3 audit reconcile. - dashboards/dimplex_wpm.yaml: combined 5-view dashboard (Overview, Heat & Energy, History, Control, Diagnostics/Calibration). Built-in cards only (no HACS frontend dependency); module/meter cards self-hide via per-card visibility; no climate cards (platform deferred). - spec/ENTITY_IDS.md: authoritative entity_id list + the device-name→prefix derivation rule (has_entity_name + _attr_name, no translation_key). - Naming for clean/predictable slugs: Analytics device renamed from "Energy & analytics" → "Analytics" (prefix analytics_); ΔT/α computed-sensor names switched to ASCII ("Temperature difference", "House heat fraction"). - README rewritten for the redesigned integration (profiles, capabilities, measurement-source matrix, dashboards, write gate, limitations). - spec/DASHBOARD_DESIGN.md: status note (v1 authored, slugs reconciled). ruff clean, 16/16 pytest, dashboard + ci YAML validated. Co-Authored-By: Claude Opus 4.8 --- README.md | 143 +++++----- custom_components/dimplex_wpm/const.py | 2 +- custom_components/dimplex_wpm/sensor.py | 6 +- dashboards/dimplex_wpm.yaml | 342 ++++++++++++++++++++++++ spec/DASHBOARD_DESIGN.md | 9 + spec/ENTITY_IDS.md | 103 +++++++ 6 files changed, 537 insertions(+), 68 deletions(-) create mode 100644 dashboards/dimplex_wpm.yaml create mode 100644 spec/ENTITY_IDS.md diff --git a/README.md b/README.md index 78d530a..16adc29 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,94 @@ -# 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: +**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. -- **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` +**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 Flow: +**Options** (re-configurable): scan interval, modules, capabilities, flow-sensor +entity, include reverse-engineered registers, and **Enable control / write +entities** (the write gate). -- **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). +## Dashboards -## How it works +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. -- 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. +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`). -## Development roadmap +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. -- 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. +## Notes & limitations -## Dashboard example +- **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** (thermostat) entities for HC1/DHW are planned but not yet shipped; + setpoint control is via `number` entities for now. +- Estimation requires the LAK9 profile (calibration) + the inverter-frequency + register; the generic profile yields read-only entities. -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. +## 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. diff --git a/custom_components/dimplex_wpm/const.py b/custom_components/dimplex_wpm/const.py index 1bdf68f..c6669d4 100644 --- a/custom_components/dimplex_wpm/const.py +++ b/custom_components/dimplex_wpm/const.py @@ -439,7 +439,7 @@ def get_enum_map(enum_key: str, software_version: str) -> dict[int, str]: M_SOLAR: "Solar", M_SOURCE: "Heat source", M_COOLING: "Passive cooling", - M_ENERGY: "Energy & analytics", + M_ENERGY: "Analytics", } # ----- profile / modules / capabilities (config entry) -------------------- diff --git a/custom_components/dimplex_wpm/sensor.py b/custom_components/dimplex_wpm/sensor.py index de55a3b..151da2b 100644 --- a/custom_components/dimplex_wpm/sensor.py +++ b/custom_components/dimplex_wpm/sensor.py @@ -68,7 +68,7 @@ class ComputedSpec: 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"), + ComputedSpec("alpha_house", "House heat fraction", None, None, "mdi:home-percent"), ) @@ -108,9 +108,9 @@ def computed(key, name, unit, dc, icon, source): ) ) - computed("delta_t", "ΔT (flow − return)", "K", None, "mdi:delta", "measured") + computed("delta_t", "Temperature difference", "K", None, "mdi:delta", "measured") if coordinator.estimation_possible: - computed("ddelta_t_dt", "ΔT rate", "K/min", None, "mdi:delta", "estimated") + 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" diff --git a/dashboards/dimplex_wpm.yaml b/dashboards/dimplex_wpm.yaml new file mode 100644 index 0000000..96634a6 --- /dev/null +++ b/dashboards/dimplex_wpm.yaml @@ -0,0 +1,342 @@ +# 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). + - 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_est + - entity: sensor.analytics_heat_energy_to_house_est + - type: entities + title: Energy totals + entities: + - entity: sensor.analytics_electrical_energy_est + - entity: sensor.analytics_heat_energy_est + - entity: sensor.analytics_heat_energy_to_house_est + - entity: sensor.analytics_heat_energy_to_installation_est + - 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: 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/spec/DASHBOARD_DESIGN.md b/spec/DASHBOARD_DESIGN.md index 7bae433..71dfed8 100644 --- a/spec/DASHBOARD_DESIGN.md +++ b/spec/DASHBOARD_DESIGN.md @@ -1,5 +1,14 @@ # 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: diff --git a/spec/ENTITY_IDS.md b/spec/ENTITY_IDS.md new file mode 100644 index 0000000..199bfb9 --- /dev/null +++ b/spec/ENTITY_IDS.md @@ -0,0 +1,103 @@ +# Dimplex WPM — entity_id reference (derived scheme) + +> How HA builds these (confirmed by the pre-M3 audit): entities use +> `has_entity_name = True` with `_attr_name`, **no** `translation_key` / +> `suggested_object_id`. So `entity_id = .slugify(" ")`. +> 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` + +## 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` + +## 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_est`, + `sensor.analytics_heat_energy_est`, `sensor.analytics_heat_energy_to_house_est`, + `sensor.analytics_heat_energy_to_installation_est` +- 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_est` is then suppressed (no double count). +With an electric meter: `analytics_electrical_power` source becomes `measured`. + +## 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_solar_collector_temperature`, `..._solar_tank_temperature` +- Passive cooling: `sensor.passive_cooling_*` From e1e11a81c6752e31c8d6780060befe978d52eb23 Mon Sep 17 00:00:00 2001 From: nb3rt Date: Sun, 21 Jun 2026 22:21:58 +0200 Subject: [PATCH 08/13] Brands (in-package) + pl/de translations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Brand assets shipped inside the integration (HA 2026.02+ supports in-package brand images, no separate home-assistant/brands PR needed): icon.png (256) + icon@2x.png (512) — square Dimplex mark, transparent; logo.png + logo@2x.png — full wordmark, transparent. Generated from the official Dimplex logo (recolored to brand red, alpha from source). - translations/pl.json + de.json: full config + options flow (mirrors strings.json key structure; hassfest-compatible). Entity-name localization (translation_key) intentionally deferred — entities use _attr_name so entity_ids (and the shipped dashboards) stay stable. Co-Authored-By: Claude Opus 4.8 --- custom_components/dimplex_wpm/icon.png | Bin 0 -> 37192 bytes custom_components/dimplex_wpm/icon@2x.png | Bin 0 -> 111866 bytes custom_components/dimplex_wpm/logo.png | Bin 0 -> 12058 bytes custom_components/dimplex_wpm/logo@2x.png | Bin 0 -> 28548 bytes .../dimplex_wpm/translations/de.json | 57 ++++++++++++++++++ .../dimplex_wpm/translations/pl.json | 57 ++++++++++++++++++ 6 files changed, 114 insertions(+) create mode 100644 custom_components/dimplex_wpm/icon.png create mode 100644 custom_components/dimplex_wpm/icon@2x.png create mode 100644 custom_components/dimplex_wpm/logo.png create mode 100644 custom_components/dimplex_wpm/logo@2x.png create mode 100644 custom_components/dimplex_wpm/translations/de.json create mode 100644 custom_components/dimplex_wpm/translations/pl.json diff --git a/custom_components/dimplex_wpm/icon.png b/custom_components/dimplex_wpm/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..205d026a7d92d5ef8e864640273bd059b624c7ad GIT binary patch literal 37192 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

ur3`E@myU#R^am}6Cbi>HNfQ>s`F*RR ziW{M;8UwSn3_RJO&NCGnje*yYrbFFDM@CnmdR*7w`El9NAMWCbIzFf&pi#tlJ^yxy z-y7b!a53m20y~_S!gkJQ@xh`<_Qvc-C|>W4O5%QJhAlNB#TkVnqOyxe1#jhTx-mbW z$o;8`rtNJeSD_)WpBnRdc-Gr{fL$#h)U|D}&0={@LX^G-A!hLH;bNm!I<|H1-ZL+% z{jICDixnYOSbzRU42lDk$#FAMi#hs1O>J><{NzdG4VM=h4+U}HNxCaUYg+vqSXBh$ ztRIhzjG>v9)g)(cHh~sOUOV}n$29vI{pxN(?2x$liq#CCx~<|!`ke>@VV!nUU4Oek zY=3cN@jH-xv*u{H*Xa4))?RmOgEAP9bLeEx8T`~9aK<;7SppNX&$nZ5u9^8b814mp z_y**fM7;Y^iFy|J7-<5^B}GlN2{IoRj-Ralcx(($s?`wjM{?q>n> z*m&bg-lpW`PcM&l(kuw^7oJM>tI)qLe5$%sKpy5yC||9o+l%x{CA)R8{7}rRd*x9A z9;i4B-WCbUb3DY;auNitGX+kVW39ARM%YT8uu&GQ(0H#|pFb)(kCR!x?YzC&H;<=# z-Lp`~s?(;41wQ(~`c|-Uh6`3dM|K0b|BR8osEQZl;?6W8;Xsj&Ba?ZBi^R||3y=t& zBCejBOt%3n#x05Xqms;|x)hIC#`3VN;?=ZJFvA*+uS|xQI<#ZsYRd6$cd#q@WV8J6pl)A;&Lx z!I=UG`3jN$A{&iZTP2GafooR{HY)6FET9V`6T`z3PfW?Yb$M=t|Ml5P&)*nN*`=gt z{OSyK^IA#&%na(d?I3WKOm$4n1$*OM*?lB@^fnbPLi@MTefvP&7~=@k8!cnqb7H}y zBVaPsdkuN8AVXKz8E^H3rshq`y#2=>lzsOsXzxMXa3VGy{z^|Ra>3>5w+cQGp~9oc z6*B=DO!HUg-_v35#~NN$3SAIJ8LeHP?_LnTj4_a`QsAzi30}josFVgq@SOIe3@DB^ z!9~muYbe)}PKi^vlr8=p6xi~;Q(00?F;PC9Biw?=wn}V#Imv=C;2i6ss5QprkkF1& zLUX9XMa7q*;1*PPY1A+5insp914$rA5kvaE)00kkf)8CBpsxV)9DWC`easzz2F<7W zBGOfRn;QlE);GbZ{Sj&R>JVD3yPA~+E|gkQ@^D>KopUqevqk!dMqV&2(Jm2M(Lu0`UqP(3%i!|E#zj5>YsFKuiWWSl^kMT<9hH~eNTveDTkh0Aib5a zzZsN3H{VL@2{qAf$R3)w#IYuz1xQ0Cv3hStQlGoYh-6GfNckN5>>iysW6hMHo;cuN zf)3z8F{&SPX~_~6=y@rOA}ZX6A3VkEej^>7;%IpZYm@S-4K1dmq%E5|*?dh4O44$y zsc8;c4NhFGFYI*0i_QDW}QEP4@~NatZ%9kH3K%oF;q(?~tqADzsO*ate)n8nL($NNgJ8)-blmRVjX z3G;kwhjtzJ88@GWk){ zP>}tYTo-O{TirTkTNyf5jGKxu;$I_dPpM%!l75WtLG#=LfrOx?sA!u3u78wpU;aG) z|03M5w#%Pg>+k2Qj3`}MabOecyRRN_HlMFh&($n@Jd=aWD8LX&4bk$?ne9tU@KQ7Y zTGXqbdUOEq2u_vd#5p)-Ke9E(nCkUa=`Y2+aFS~jkA%pG9|tD$ax!yH=8dj=0^9;B zSt5~o(5G6l#=k~~fYYIfi5t8dV0Fq?(ygMKx~M?~Dg`S@>d79j4Z$C06ONJ#L^_%k ztkB$f;6XP9jFkcrh={ZzXtDE%2|MvFKSxJd{rH1yJ$PfR$Jgv)HQjzVnSbz-9|UQ6 zfR6Qf)*M!0=AHW)kZH`OCFV-52pwTA*4Q>A$>k_JI?Tz&a;f|fMHP%i?<_YR{#n>G zn8}rzR24ap{=*N}K$gOcfP)$P5J%^nt5bzJ-nWJJ=;(J@sn^LwjRTq0#gSs)z#~}h z1?2Kc*?SFiDBp3RZI_;(G*H2`An(D4&ck24SsQmSa=f-5=}+i3ln%*KkmfX1I%@qT zgYT@9)Je(q>0Ux-zWS4=cN1MkJ1#0S3n#Gqk~gtIfl4ca9v+8Ts$=$iQ!5#F;`JOi zZ?S7XG|sPZ{9S$-41b#Fswz3pygzr04A*Tc3RcTeKf_^&hhj)uztL#yR*aUTwU)d8 zLqb~2-!(j8D?1i6wOmC%O0mtsXTWM+2IW#XwQ(U-T8JfbPvM_zm{*E?7eS?XVH=0H z*mDan=Sm*?F0zLt|H*`$Ogl&;QAavMmkN-K_$^sA=m2ps44Q7AOj9+4W9iZX<*jdq zHEL{D!D{GH3O^p8vi`Y#I(_D9j=LNF_iRh;TIo#7>aT`yC0a~l(;Q0b_YU=2uk8Zq zdOV+|l}p4UHcPq5S?Ek(B%weqw39+~cyC)uLDv{B1ZOQ>s_Q<#h$NubOb~QLY>erB z0uxgN7z4P1g-DF(o@?q{GcAiAr5HI8`WKn+OZ?RYhRv-8kGnGXd5MldifjJtp#e*) zVco6_H7`RXFiNvMEv9?pXRBuwNE=c-75Ys3%*Ycn999IP4$E{3Qaq?M6{tsP(6 z3NCi?@v9Uf0MNQ>ZrfO8!lEJsX=d(A|HWJR z1Kq=>c@T6SAQu-CMm{WUt>ujVK9ba8eNW=pE1?UEB9ni}f(riKWN4t=M;sd%J@U0${26U*~%G9FZ+az_e!TV^q(t(iH(12cPusXZ3Feg(o%tT*?v~I ze~(BS(Z{hkqN7YnW0E#-8!1d_$4SFxr~~p4@E3Z;h6eD+!&URmpraK@%UV4zID_R( z2YsJ~!egc(BSvw5L;0N>1)ZNSpo*-53P~k@&{FV*MN~sLSo?JY9@t* z1S`kx!{K4eAjcs8$i4T7Bxs!H9vJ`bhJyBDaEp1_X;|?m|8PcJtRKV3sl*5om|;a1 ztc<>>@0FXx8Yx{BziL4B&ToE8O0M0@iBrdn?AKqfi~pO*W;ZTG*GZOj zm^ zSdI=}hm?PI@T>5vB@EEB^w%qI(9*LEwc3W$vHG@bqHn+&L{6z5Ern7H1d}2;NSQrU zuW)uWHGOrYab1@)uvdvc!6IREy)*;=HRHO zI9QUl==mMOgnOV~f(B%)Trh5NwL%?S6xwcO`RoS4ZzAG)SdLYdN~dxzR&Z{SpV~=W z;xnvip$2>BXCuF2p=HY%^^f&Wfh&V+A7^*VY}$L%(3{!s{!cn*4@`?S(ziFQfHrO? zGV0McRNb-4I(c(;zl_Q{!qmj)gmbrQzVS#b|6mq@Y@$~&O#l*1R}Vk79}@LPz>`<0 zZc>I1o81!)tx$|GYVbrw>hM%lGhR-4S0IE*ZCy9omo$_`ZmVi zd{2vtzWV-}8E15R-7}`dgyXnM2wEW|tIhzXA$is0ocnQsN*0Jw!d_{Yi8wXbO zpHo@8MfQni2*u2o)q#$+R(^wa9uk9X$WF8+(m znfm#s+&6x~LDQPmP_xyN{=fC`txo*lKb^`}`fp9eP0xTG{U_{#^1=ooCRYlMOW+Qe zZqNe0!nUiem3F0u>8q+ZV+Y_xSaFXeU@#WdCq2iL1?8)5aByV-?Y>&qd>Y|=3+4X% zcVU_S)bVZJViV@gBg_sH%LNiL;t{eWRMMRIa8W=^OZO$rdy#jq_y!uoW~u2ZkiGT5_`0@{wkk@@oF8J!d>@?3UZtkCoL)K>YsO`d7T}9cQ;d>%;v;NUsCs*{Y( zeQiHUJD03J=|Nh@yv0C6?klCql&*%f=Q8CGkKl-C5uy84^3m=Jw&(J~CJGA*W8W^wqUHFwPs}q$Tk^-dmWk}nah4pykYQtt1#M5JM=4gIuBfc0@*yE9y`e%d zn9kL+z|G1WZ?gA){0s5F#$giN_}%~S`{HOam>W>GwGOtH6?&3m;QQ>$_J(;X*@FXk ztS}}4q*+fGy1BK!IZj9lWIpTjXx-@ZXWaW5i+M64xU0nj)aNy|CaUSNj3qX|ycKJ; z1R+sR&N;}XEt@QC{@K2`Y-SKu;oL+2Y1uXSY+7&GRycpiuFuI?V&cu5GvKd!YdqHV zgZ2muu6r7G6VXFmIyh!qf2zxk^Y>+CPef5u) zmKN_Dt>@A1FGOH@5P5Mj$i!(NG(v2j(0EoO0%T~>awt>}9%J}?>IBU)>vMb#hZpI^ zNG))iIAC_hRMSjna9R7QJb#Dx4E`S2?ecz=OfS##YYKUzz1YYS*4Nj1N`D34_ls?F zDn}KdXYls&z{ZW%tz_unf|Na5{HFhaR;o(?eJ|@q zD*z4)1{2GoS!rkfP%FhxStO?p?L`ORVs5J-GJ*MZXG5~G;)Bz4UCm)jaPJK#mW{a* z9aAdETCzBfp)yP><4*}`6VgVHBVrhC@02{FS^{Bg?z&ru^%OObkwiU6V%3CHX=K&^Zo+!!X`fpO?E(1*36(GUJ%!L zJhp>`H*$IJ@M=^H$E^~KO1+K1HiOGEgl!@}Q$nTrM?AZ^WXX8uYJyW3wO6L<@JrUKQHircISKz@bm2NYdDD zeiZfVgdCBKa?fu}$ZD^x8dZ}U#0S|6{k-~JKwSEF9|%!Ir6d7~S?B_ou}G_JS}7Px zRAO%e-Ohh#)}aOxC^BEr9URbVnTmhgGDmhxz+{%i}-u_fni8I_jNV zK9Jkb0dYqY_!|I(gA^g}^$W^mKnQsB5}l$9kD9(}eP5f{FxEfU^lUSGolJ=~KTc=- zJj{gSoMYhUoZekU^QoTC-v;-~jWM`B_t#ScC}<9Bd=H*lB*1iXyU+b*o^4h3ht|5p1IwZVMJ2~W-36J6(b>sKmqbCCA)D@1DM+No%cBE^1~5~UPTHB~{3HtT zOb?_)5sY}j$~mK|w3cYQ>LZf_T)!g&1D|WEtE7up?i7~%_xbtU=MpD+_g1p_@!pee z;>3q2&4BGowUO1dT=yk*UEuerJXNmGukAumNwvd>?q_1*Cx+=#C?um{e@X#G-TY;k zUI}{sQQOzXTyuOFfZs;zHd@PFo`q(+p7K^-wU~YDEq!lQ){)grU2Ce)Q3f^ZjJ^{u zibT1UV0KnN9WGcK8kBe;-8?L9DOnAco@R0ho`(O|qQ@USjL~2tn6z?U&~OtXjiDL< zLdIkz;Oh*U^GG(2SOb`fD;$4v+$treRmhU9_}Qg9LIXMZde~19X<4@j#oKRXR=f2d zX^XFI79##MW}}{z#2ZWE@Ags0872@TQ1M$rN4l0oEylUao&)c7M z`4`(9rH-d{I^Ax|49`x@AZO@Lq}kjpY4DaY3E+@E2X}Yx6jNmm!N=4z_mv>{LI5VP zCwU?~+9EufB76s=m-j!QLmR<{JMViiUxY#N^~*M2MyB+YIM{GzCPsvKYD^cTF;Q{2 z-qhF8-(GEeH7hz%YK{2$`UcgZc-;=y`Uu5Q4}0n~f92M8;}Hyw6Qnzir$(-){WOKI z!4|gk^!|Mw2cTNelm!&@g6;nOBS$E7sJCuc(5Z4N^}dzM=1QEd-oHmN`XQYzf>(|Lhy7*PkIq(RM=d&z;P*D8L#vx=k#V5%Fz(U-sPYJC6=} zisPjM)KzbN&uDXVb6vgN2~moYN#IRVrvT}*ImLTOwC~*S4IZtTEgysEB&BE>byCwf zTai;Dl|CJYEU&&1JPkxa9E2bofj+l~1l^Jee00}P#6CIbPxo}3OZca553=>bV~!0~*a9(4`BC!t8Vxs6ReeKrm*&s{;TZ(G3* zE4Mf!rW64^Ej~Rc{BGIbjG*19SwxD^)t%imU=lI+*+_2haxP)*r~n35%m>9#3S9xw zUXVnLz_e_l0>{r2^5}Uu>3N9}F?Hdw$)#~&R23qEC z>~Xxo7)qCJyaIQh=(~4pW03DaNW%q$euMxSK?+n*LNA-n&Qm|q+3V($Yh5g;+MMr7 ze$)vWgsk74gr#$mpDLodUVEh7-=`aTiKHE}tsgrvdE2S|@+&g>t){B7k#Go22~1}4qhK&$K6bMdPSZfV}TWOfg<2gxlEf; zR2eP`chVHSdvyo_n(EoJ3wT)Bj-Z^V%`K{Nq5FN~p0e@PGzN^8q#Rk@>u_DMlcM`A=fy~7n3|Gj+dE@lYwsfD708`> zD$$DVjSzYi3t>Jb$wn%JgnjS{ym#|}874tRs=rXtzcdR|!SZIjoDtV}5V9rHm|uww zsk>&BFs~kb;`Mvbr};qK6VATDhH`m)$c6Qyf+QB`PU}v%T0e?ERuHkhyNCP^hMmPK z&*);yI|(K!zJnq*XecqHqs?SfBEgdoA{Brert#9aj>-fH+_PfGWsHmMY zkOOy{7ZnvJ&fv>Xo#sD3pN{!`y~wxsP^)gDJ*KZWTEn!zG6M!Jzt8o+8JnHdYp(4f zI4hDyhKM{J5m_o)Dn6peVU09vDb_8b1`;PXaG|t9N(t*E&H2@(O>hcQ{>(udkhv;tuVAmaRk9U6`oy+ZT;G)0VK>$YyYTT#T zJ1!SvIn#|?rd^$M0r!$QZHpWw5lo^)g%$T~ROC zT4(i35hbe|n^XRA^6Ni+tv}J1&BkrN>a}cLGxV}rs8BaLqB&fl3%YLm2NNIaI@e*fK!IHwwg>U zE*{Y};P84Rs^K6zpF8_H)fJgdQJ(X@NPr~ff9L(@Q^Li;a(K^*CiBm$XFhkkba7mi zkf03pr?W`tArzXdmOef`F()JY>-z=Ax5ZI=i;o<>mx3?WZ~>?213gZt)u3`%=mElS z8mt-5rPALs;P|Eo4$#)SSdtPaeRmGT@C_DTA{&15aItpK=D#Kb&w$IVVpgRq?w%ke+9ql#0MSOMO=lQxp# zF;t^JIy)YwMTVlF7F6Fv^VEgTZFXJ`9hyc0mY))9j)#KO?30Nq;v&h*Y{)p(5>fh8 zy)v}%)=bckV!!NtzMeSur+$`UHkJ|*)TN>uXKafu_wlfr+&qh+-!Qj|qk28r4D)HT&B zb|Q0JbLF39wTzpea>!vj_s(x%Haa40G-iRc2nLN14YEB7c!K~lU^wtGxuSx29&_T< zerHf@T8cgIBlGG-M{bq%%2gWD&f3e%(fRSIn^!YlrWF#hF(q=sPvWLXZzlRlKBfZ; zEFiq%(8V51DJQdxYQHUhX3isX_b$8n(+Ka<%*^fQl7`c6QV8olos7M`7p=T2QLHks z$me_Y0hq-NKHJh2+#OJJ79D@yFF8V(d-FJIZ{TgDdp?cic_tP7H-j{&hb3r9exitM z=bWs2S<<3ZpmTs)jb&PI$V7GsL?{@nM1JS!_x>Y_k(0JI1Bn{xTdd3Lwaw`Js#{lY z)$hNUsRV8S>R3al&kop5@u2L@oVsh?ET}=3()v0B{gnNQjc_u?>3^#42 z#{+B5kV=J_Rn>&|M+%Fv0uPPXrM2Rnxnk>{RGQ}aD|K;D#3p%pqh-9O&LJt+mNx(i zSSS9c-tyF5_tB-CRbhEI{&&o00vkejIZ#Tm%{9IXTXv{!bkY*J$5^-`^V_t^`+ex_ z4OgnShM2Yfzhvz^IIQ717#h!1FWT$Jt+b-Ai%3N(bQ6z?GU&s8OCQ2O&Uf?@F4dF8 zf6a(-)d8mfd3gD-9&+)0s&@TKMXZpW*H+UKdZ9Rr8I0)#DKE2#en*UJIr!*F(X-72c@ zSzt4icd*djb|2>qE$0(H;jirv+OGIpIbI3jr!>NF8f?};sn*Zpj+r)aZ457U=ot|P z$y)V>tx2$+Y|Ng`P$ziXNqAj|ch}v$BdhJVl=GN(m$o`DZ-gxy@e8E0fJX6a(H&_V zPR!DQJ^kaxq^{IT!1DgJ$_ETfgDQ6HVbe^2h4#8-z1Bw9#34d|i$;{*_!iRY?evR4 zF1l4ite$_$$(NkUoQ%Dx4soXL;W2ciWX?2(KKA1ST%hrj74BnLtx08)ql~RV!p}aZ zrebxo8rbe0_VQooTEq3i2T~-j;1NBfl}=>JLN6P_u{B>r&(Txg8qcW%keVred0&Bo zVb&!X}o?TjH5fdQ2{aU1iI*XEt0O0 z>w6ni%|`ucYbxlQ+CK_gtm50+!ZIzo9~8f9m3G=Txi1BXjoRg}#WI3EOA`~@x?K%F zZWxi&1znZS8R2Q1|9dE)zU!@FYxr7}f_JC7Yx9ijM2VD5(WlKYy@mvsW^|O^JQbN% z`2T`66N(s-g16iF>0Gb8!!gpCf97`3Ny`vc=9=SZ0Fo{ay8%>y=XY+u9iqkaXXIA_ zrfTttY+@V4u%Sz0eUiDSZ6lHTaI7+DvX)Kd@VnL!uqMW34JCT-ap=SRFeEX2-&&8px#Vs3 zCLKAnJoED6HU_qJ|8tw=Aq*C9fqeU2df)S=8?F65bhniRExw+j6?`p*T>6z}86jSH} zaNH?bu=QMgSz^SvfXWHkpWMZvS%CiuSu15A~%i6;$wmm)x(7G2>lSgj6+4%TF zZ_ivN8ZI4|zl>m778^}4^F_p)A=@E|u_#4(sj8Fos+-?^ZX-4oQ6chr!LF0DfA%*>4V z!EVnM5|N}Qz#USm-cD{-1a|M~|JqosALvx?e6~=y>?;~Jj3dY}Ds9V>iVQ@Je5Bz@ zpAJ&x<6Jv&k;l6R@ zy--H=tcN%#zYDDmrBqf-wzs9blvUNhSo~glV4Ekz^LYv&ttwZ$aZyT z5|;4Z=E$;30GQNRbb0Qxb?*rQ2;w?)rt-l;S?SvceD45ic#aXQ?EaZ^5bUZFNG-s4 zXd|K&g2_E;SQFL_uLPFoJIrf_tRyq=9J-XKJLi7duXoVRcfXL!m7mS!4oqtO+@PXo z%8G^vKudyKs=D0JjARphYBfOIFYJ6>xi3LHG+b);OM^%UC$v8KjORW7xq?4&${WaS zXAr7nl;`>F!k_da$YJN&?bWyH?S&9<36LA;YLepCprpAX<}}z%#C5)IoVU0q5}&ZZ zQDCJ&%HCPE(XDwKE#3*CrIK?^?f%sD>0|dH(S0Y-hYPx#_i3L8c%)2fvP|R|U=_K! z_T8TPtv&Wn8ASHdl`FICV!9!rI);KKEuO%rb@$35T}7J3Pe>rbp9N%;w#nF|0~2qk9GH1_7aiEiBs z!o_>G(~^tI@*xOa;^foeLGBqBz#~$n65)^!G&-nNUTXcD#>i?+XV>+S(R^+wog}}L zzITn}hZaT*^H`J3K%*7-AIVggX~mnr;D%wX zZSB3gVR-LaXjZO8{sWVn`@-L2F2D(ZLdV#W$58PRt7S#f!<}-uJIMQbz?r~AP?d>)2o+XbY5~dguP&w~B{(*=<5U}|r_Of83GP9?Sp=$+&=jUsfeEW& zJQ!{FozrNua#uf=4CUTC4%jBHhmgYGXSC3yODN+h~fc+UaK zVAa^s@80Nkj;O%^jR>GAa}<*NdaQ-xSWNo<$NFrN)<4JdpRzi-OGk_Vu@L~QUhlxl zAGh1J%kem`EyIrbm4Ceh8UZNn%d4Rda>X#GfvY|%>ql!Lb{nEp=tH*H3+gVWO0N)RUaCr03;$s&OI)=F!GB+E>S|MIZf&o2ei6zlwRsbC$tS>Qd ztOJPqk}@Op(SxArYHnhja^uV}WRQ#G2Dofe==LAnqY!U^NoGFf!0;`=U=exZ^w7}w z+|W?vl$5@oJI9JPnbDJBD<1$3I$)a}3mY9P<;K>yyhCES?QT6rOjNWP6?QG5Rv~nh z#rnM{lF7@C+h3~wA}b>T`sg*#aZewf@YSRTJi>q_b~F2&g`4`iX$4y!S)gM!@7^QV z79Tgny){ddLt635z2w>9L0rM~U5_l`zj%BLBgb7{|k!qWaiX z(74hEs!C1d5Qhj+6ab2u=_qpJ_w*IO&gkA3xmOaPlXhvT1t7%XGQz$w!hMsT8ho`> zdVqdi3WKsgw>P;0yT@-vVbqMQ1ORItwl(1hkVliI-mo{^A7`&balR-*;7}04lKdJI);0T4gkj}rmQ&Z_Mk)$MecKZ$Hx~HQxW11~CvisKza|_T-5AOD7Bq4~$ zV*Y17B=P5^Dl&F!V)7E17EeJA+%Tkch-~~%-1_`9wUO)z=n+qk7l=wZ!~u<%mvLDs z3r+IX58iwLT&r-mLkG16Ec!^*560ws?~su}x2z6YQyibj2;ypLE>QUl;zfXzH$f7}0H zWR#@{CMg8ZL(E$eg^Iqh&T9Y2xZi0-gIsRq?FO0qVJI~K82*^h&X~d#Nlp-LyxzlR=0cddkR_9JvbK*mUuR5kap7m&D;)|GXqg)Ptn zJIr208Z&W86S30CQHQx9soD3PNk%ZGNQp9b0tJDT7pD%{j?ZQrv+$>a><45l5Z4mz zEC3MNbPf%1YH71>BF#<(11h4sw}RxU0c`e8}}lE>H+j2*D#a84NX9`S#&4k3M<_WY`8Sf-63p z@H+Mtz$b!FVyk{$>NK?V^2DA3^f2z!>?u%pf~gFNMXea9@i7aZ`gr`%ZRT210cbU& z2MC3fdb)23d^n`VK$+?|F{)hZjw~7*6A6E0yf84J4k*Z%QI$1v842Y%x#_!ElWmvU zwY%(OVw}j>G9d{y7HE6cqmSMNwT)fsSi2fA)MU(A1f4axM);Sdxaul#Y3zhazO^Oy88QFen#1eNp387oDS4-e%9EY zb5;7-8G6N-1x9Flq?AD(Vni#uzw9!J;Tp5wKjZ*UIlBB@(w{E{G13kZ2r z8^*T6FnmA(tZwW7f3N|vz0#6`m8 zekC3Hoe=CslX-F7@Zy3swfCC7w8wTK{r|Ht+pPc;et>D1-tNoeuwHI zBTHdn%-68luKjq#KikY7Zx7<^$L?YdNlX(F{9FK0E}1zIV=Akwwj8RDT?Q=$(Vev@ zT9A=WGTb#?&>7m<$JJ@J=Pdv!reNSO0$ghtyD)E}h5KyQ=ltdt1t6H**8hJsCd}A! z>;WCA0;xg)u$)6E`+$BN1K52RAVr#+bh8T)Jpk*sBJ`7~Q=5>v2z~S%=x$_gtr$BW zqFrHtrW^4l0bR%Y?$EOpXuFBbZ2P3{7bSQq_bjk%SDVcDu1EIXf{j*di;FQge&+8_ zj&I&mNt2hi(Sp1Gk@8BKig|?t8gS4<%uzfj&{2^0;C4y|&@+g+f$U6#bKqto?CP2I zu_NeG(7HVzbnZtCD~Qlwc4Q7If$L*yHrZ=P0wbvfkl_U3HUKU+LUVVv-MDu@pw(}? zir#pFPRfcanSkyc-l0%NepkWB%M(=cM1wx!Kn_vr0h~uBc}8)};@4pBzMrZkXQe^^OSLj>-c{D10y8U{9 z57GVCoti8HiIE1_h%78#Tx~1^G=v`88f#H!<^pV=j&ow$p&E*J;NV;3?o8^5n`gzT;0Byz8h}hNwfLhxaur_W#lXl^R{;lie!c&a&`khNJ5|A+Bw2g(jna~Ds&j|^O{R+@o2mKt?yRsuFl3UItckTZ@Z zERHZGg(nY8#C@8O>4L*<@ecrZESrcq*f3@_vOFI!t`*t1vDleVilYG+I`ADK(}QD3 zf?ZYeVX?_vH0+}3(r?piLH{ilkegcUx&dLRNH{s^=}F7x?l^h(zPIf@jBPIx>SNcC zMy2=;w`=!IbZrm=s6b8)B&O&=`Zu@<6!tA2sGcz_xy-4J|^qG8qC}2Ba*%o+{tEIc| zZ}Gt&KjM(5OGye-X*?N7%zec)S|&O?uGHb9UT*yJMJ&j5rICM81eNJ&>{|i7fv!pV z-g>)nwcWmZFN)q>iOjW{wHx9EC_v@Kq#@1_h}FPT3)ku+D_Ns7zN11w3K?4K*4$f; z?AtfP=<;00EM01~++^GoLjd-lWfZ3f#F-2MOJ>rpcQl_LoGQ?S1|BmEWr?`Ek4;!X zdSqOivjAl07Z=FQEJW0Kf`PJ9lLJZ}+V6X#`}}-96ZCY~pMS9EsgGT>09;_|dtunR z9Yl8*qiC*f?G?i;5p1uZNXf9fq(LYpsC+6R*DhajD{_eFD@6r`O4xcs%XgO}^Y)D} zy8M2#x$?)Qk~y2)+s*M`sr>FdAXJX~^8*9=s&ZV0T}91T2!u4JaC_Dw-HnG?J-7*vu>x99Z(8@(qfpCX8`w4Pp1}un8@ys|GQGDJYDt=9#rZ?h3J@K z7-axYYKcV&#N{?|Sa!GOHKwr*`FtC%rNf*l{u|n63d;|Z1yy|}rw)vI`b1f&BW`Fb zq88y#`1dzYXBUwwpHwl5F(5(FJQ0)>c{zfr1{!SGXh32b>EAh5Anpw8wV>ux37t%B z74Su|<`x(0|EH13J0Zgrw0w0VGS3tg3QLghBYKkQwwva#TL@3`6HHwmNr(Ev%qM{^ zL<*QK8q->dOg&_K#Rs@Cpu@#)Ulvl84~Mh@I2%~4DqYMgJCQ>=R3sY9BNa6OO12?Y zKG17Oy2q0ZLV6;^KFNn{2OANOy_(MtA8WV68J9Ex?l%VY4m8}k^T1}lY&!F8t1lIZ z#v@SeK~P6Mr2QpDQt4$ombqiC*ap%B6ZgVAL8*bPe`}@eK3>$6m$qL*snCNq+h!}` z>kCnIt!3@&9>TTjH`eR$VLRZYMfR%O3A4A(12B~;>QSF?Uzt=TM@Ygm5Xt1N_~f>f z1N0!`%4lG(!#vUfKK#W>;Yht$w7-yBS%rI<;;1ATD$Gdzev{pSp1pk8zPe3z|A#fT ze#Ec6{j8VwPiU`mI0tH2!+-)^lCZZ^0#4_!s8S2I^ErjOM z`(g0<)lTredO)}TAMp;?#mvQvF=4W)BRQm@idK$Zw<)r3 z#Tk>(OSk(Udu$L&w<0o`Eh#0yBL$%2Cv*Pd{Sd+fTn+&0ZMiS0^&_#6b2IT_cJ(5j zQt1C`Gcw(s3xJD@V#eP7Qv*f%3xk7RS*fuc(f$J3d`^*4vJncvb}QYw2Z6nh%axR$ycjWa-Pq2C;X8Mt@a^x^JF~wwzx+M`tVtj*;rVk>@7DnM?AAl4zPr^j zOFJ(9k@pZxP3dd*?o9;N>|^kTJi?sNZKvmvnAyDwM};7Zp<^ve_m7;pPdGTF=-5Hu ztL2H;YudZ#Qpp*dS#ar436S=&3!s^l)<(+JPXS#>`U3!9{a>y(Z(a++_g13tz17Iv zTr+01W{ea10?9aGa#KZRB$WF~%VhVUBG?w~ch`deCWpj%aE7-rd8x3|2yUWBl&k>!37l#jf|8=>dOaC7QAOHz%dIv z0?e+4zSnPMYK=%bIjISd`Cx_sRF$YY=Bc9xeEs4xd42e~e5L%rpVy}m&YbD#YkZQC zEirXCy{Vx^(AV3R?yRwWPe;)K2F?w#LZe_rnp2c32aD5!ag|^)7ITk7g6)iKW$PEF z8@bGY88(b%0x2lM&WbS&aM_0fh?kuYCl=r5`?Q86Koj1zht8K<|FfiC9vNK{qCAEZ>~l5M$OpuhP7@kTv-tm6_FHzhy`evBa?9ndOpI((){So zIU)d?Bccis7Koe~BtHLoWarn6d1p1^*HZifX=tsLuf!zl_2|FD=D32SSQt?{ z&PoP&q7|B>KUwyVtn4d~{M8eX+kP?G%E>++p9SFHwDAns*V2I%b8~Lzd3AO+Jah2i z(up7l3S{d(MpBxt3|KOtBp_HROP{)s2{PLd-Lz5#>{Roe|NRRKe|^4)>m)O*WKUG%^}Rh;e#_XGY{v!o0{U2 zQWQM5CG}USkT)@`)#0+jqeEJi6@b^WjIk1xQ;O-t_JnZ6;mX9&kH3+OKxlcC8J<0k zdcKBvvi0&!d-nb^{1(84g$1WPB_r_KjQh(Uccfw;A5{Us)?6&gL;0Tp)N24{=JxHD zcxz{ehg)3E<)lL`k^ngqD6of(f`I~&6a*;65-pcf+0={ZZ6wmAklF1%Zo-XAy}4+A zSYtcC8rt_(*#2@eLb&{p%6Jd8Z_f z0OWspZ18Xy@o0r`vOqLkQsj!RJ5pbpQ0=9Gw%(WixClV*qg-Ie`2&tDM1}| zm{Ya2kUMwTte-m%%jaRX+cY(nWCY@@U>}cN0c^*mI=);y_w5+f&sNxO+X%CeJ5T^WWU`L$HM>m z@#6z`xN{^zekD)zvO=6Jl1ry=o_EsnyB7pMMfyzTB14xElwc}giBy?=JWLVVBGO8u z6JGl(TmKGDFHRAhnKR-S_3?PH z5ATQ3TPu;7uUjiysGAfo^i>IcofOA}E=`P%Kf^WCxJdhBh!J!f=$OUNmp#bcEebLCbTe(!D+U6>Ee#kDA$y;U?$CDXsNXF$Q>E;agI(s@s$1+XQ5{Vf0`?W_{H zy)2V&Zm-$=y3CNKUR>7C!Q?LQWiHu*S5 z;Dy_*0+!m9IK@}6)@g+0$TT;sxpF@+KfE3V-@g{vH|IB+mk-zL%YSFvJvoyoNNN0e z*tJYcwOgUUFF7uS__hD$`H@Os?XkR~S3IQYJmPUeEQ-YxfML<0PW)Zj@7|JyY?F{& zuww*9!lEM2H8t6&fXqk7G(GdRp`mNvTwQ%nyakv63uitb!M83;HYrOy)$@VmC)Nc> zfU5u`3@&^I49sM2wk!{5M6XB$!f2a5 zP2)P-;`Ub8Y~$8R{LCMw6|w=aN7jwIBgMZ?TV{nzUn8It^Q58lhyh#Hk`(teEH@&A zwFr2vX6<~#*trE0{%|hnT>5YCu3oOkg+AT$xNr*8g^NH*wEux~iFf-L)6x$hLosG& zAh^p4fQ8LQ;sIwwOK(N{U#`^e zoKS_JuI=(&*du1aqH?OV- z^K}5B_uf1_0DtQu@X2^#?r8R(%Txlo|DQX@c>X-RGBi|v(c}G9$h}~IpVL75^F-CE zB3Ek=p+#3lBiFFUg?~G5U})Jrclp6X94{%HKI(gtP4bj&OCa!_^pE%qs0-gcqQ^=l z%X4!NxS@S~a`>?qnJoBU0nn3UW4XB~8lEoe!^f0Z|=(fpkPPt8&P1kDJ@jN#d#%61I+@rKvk}T3B=}1jTGQn0(L85WeV6zd~ zR>PRv8(`mFiOlS66TWe&6<)Cv&OcY2s83$L9DURsC{Y0Ecx;>Y2Fy%NdH-f{@$iA1 ze(7M2K7T~}UmW-7v%^YFrV2pnDx?-cHh-aEfjy424n+DXVp0UhA0md0Z5j@nY;T5O z|DYb?KmF&6s~055oLW$S@x%%5*?|E|v$J9>_R%W}Kq}XMRsQbODL8%lwAJU&Z*~O5 z82Zb@)n|XETKeoE-+yUHspCb36CPkyOK})L-U1X1b0J6Ml?Y`=i23fprM?o5`AxBY z*aFB#j8b(W8YB%rigszsPH&``hC2$e1&l2Nwi7Tn1Ix=bV{a{4^UhnH;D^69zxLLR zPUlvdhjzgW8TR)}XVf>Z&d}Fp;|}46>_}bTR|0p+u_tZfXDXGEKUb}Oc7G0ka-XNC zN3?owP|?9WVx>fAB0y?eOOpE+Q~60y09hn$)eKH@ZaFeho9*jsq5Z8ZjqqQr z8*}YoCqEQc_C@zjP&~zKAV*CF| zF#jF84OWReClK>Utg(tB9Y*#L5`KQbry5x(e&NX2yE8WyU8+?fvONoQX$Ew=FwGK2 z=_`RqARN*UtME+wPDPyPH65I z^Sn?7S~ogsU0Ya}7U<4HdIa3g|EQK#5&mzbr+NA`Ck;P>;&Ixk*hY$2@vr zSkvJWk(LU%hOw?*LP8*^_3$KZ{axoQF0hhH2a+7HtNvjInR@I4zgSWzu8%%w{Ptfx zaboq?E?lkDeJIb)Za2#$Qont+UaSY8}n49Y1E7+SUw{lH;xJ$v8Gy9_VWAa9 z^LN{J;eTs%7H@Yt%UkC5r6- z)_M5|Ew-ZJ%T2O{umgDn_M-^>WeD?4Hu;VO2`!W~K~XyFeIoIdC4eZKG)EFBW9~%2 z9YSaZQj=hzp*`L3fjd<+tr9?|=-Hr!ey15lt!9Aj`_=}xTP-p3GgFW^;W!RwW+Zrr zKb>6Pqb^2LqmrvWEkDf7Wz&}hG5bGT%uOB3<$hwnr@u0$=x|O^NxU1Wm^5Owbj@N{ zq_oAFc+!xiF_Zf%ds!_Zbsqh9KDU%7Wr6nA1^ZQe20 z!1WXltLYgRU^~W60ag<73sC?b(T90RvNH!wfc4oDLxg&YkeUIn?X;&H6MB!K@TJ?f91DMHH&9)oGxw9Y15?TQhb` z0aA?8S2Q1arCk2((R}U;`#pVfK+*AvCMW!|Oq0R~p&W`WhV6-rrw1473C z&$1nnJLMsB9yzL$r7(bZ{=LcajemHrF6>L2!hhzBdjE|#v^{XyoV)0Pv>%i??qeH1 zEx&Wi;E*3fL%Sio$rnu|=R2R4n#W(VD?BPtxAO@mrs%fU9_jO+2B7Olj_ChrbkxA{ z<5tD)LW zxX$fKBD^jhAWc~kKx$bVG?;~d^2%yt-dc|MjVnQO_HW!?{!tpck%eKVr@gxX-fF8A z&CSgjAz{sA9sBqYc$|yRDTQ=2l(-0I95?itErb~^?{gklNCo7jhpF`Ji6;ya@k@@G z=WI;U;V=U;Gw~=Ysmc@L&dlHbxC;fp2B7N$1LaVu$yW;M#m^NBUpVIJQzM>wWmIc% zX{5r>wv3If6O^8*%@WzC9j^W%iHD@_fY6`UjkPsvR#qc>y=Ls2>(>68b<5wa*MqCx z;l<9c%*I1T`uDL%U4VxPe;gYS_h1??U25UU;0*-dkH7EBOOnQ0Cp0T zZslLn^y2|=`1P-o6a1^cMUHGT2oU@!o|*!fmE+IOrc~Wqx*c~Y<;AXoS~!K`!bos% z`yUJgR3`V2?t4b(4wf`MJEGy_g!Wz>(sXQ4DL*y)g%BYbu!h7asj#l~|FkNB2P!=i zT3KO`Qh-n=_HU94M{#yWdg_Nb0y?@g0A*p0|?KQfvFoTO1 zfs-!QzE$uLJZ|%|>l%oVC3W&C5`vCL*Zw{9zz%uAuQ*P7P;=vEMX|2LLb{`mj5-pLR!$F%}* z`m}<@Mf%@@0RPzc`Q+T3=_`SU1NZoOJ`O?=NBD{7EIBN%jpTFSZJ*iddYI_l^ea=5E?X0gFXqdh&<#bTW(ukZCd+Y&6w}p z3Ze`D-?i2cfAuq~cTZnT7>0$&_Q4{wJ|2NyTbJEjcp57Zqy;xw%p(w)P81eS|y%4Q>vjYl;#VK#GSpjD?0Z%LwqMwfOpCWZxU|=<-|b zVDZ15UtRciy}s718Q3Y{E9}j4=kXCSp{MQ<^_l|MQCL6J8$Uqsw+A58I7D8|{Pdk; zV^&|hDAj{IVdQTg96dQ*Dt>NQsTVzPEI#`bz=%d1swyf9J-Ya@QsLKfBeLQF%E=ss z4|P3HGt%rpH~o_|4vB9s?w6E%25e0cae)x;twy}mvi9xEo$%7XsI}kvI|~a}BxZ z{MW~aPW)6k|Am9T|5F1>eI`$IKq?edf8NayOmdQm74bCR+fNiOu9Hk@_6b#)NMeP& z5=Lgjn)hx8(Rbf&2jBgEz4O+^)%MkQp|zZC?2-~pvx9!J;r$o>n2EEs7(9>Og zTR<_3IM4jxlYW@#r+#H%p!zf814BbVmAr<5#dbJe(Rge~sh3Mio%9Kx8P-a>!6CfK zek{HGi2{)FUf1)!!{_!4HfdCPw0+p62;;^9lx8pCN<&midJ2(n6p>nieRe>r)^mBE zCp|UrZx59Y4QZNJ9xcCJ=k@=7ZEX$L*Bd*z2HSz#Q_4Hsn(R7DgUWjiS;eO_gDhQb zJM0CVax)CwH@}!v8%i^hnDg?zFX=x3cs5Bd+T+Kqlp<@XMvQp~ABDluqIVRHeYW7y zp#fC;hYQlm4C6k*q+;3mQ48rvG6p+^=;@Yn=Dc>qMS|G{;>tl?#Y=GyR25yzT=;}$|fBz!DZ2(BB2piGdhM~e!_^MD0SsF_hq zp^6;Ch4xcv>fxP!CuB}r(Ayd-gOv3tZ7odb$Bi^t3ZYa4(C%1XTDSK4d=$O;PCNMF zAJv=h{==1x8$v>~y<8kK|GJ$&9`Tk=Z|Wiq9Hu`%!s6^{Wa1pzl=xEvxnBP#0sNoe zw=e(MJQT}bu2=xbZJ;gJB0KIYI9LMpe38`2GT}2NO$YaT+7+V+#N2OMYb-;QZ1^&@ zR}6w~ejj>J8Ds@MF?F{HeX10DJ$T{fID!kVy5=&e6Y>cu@Aw`i01L1bMX+w{y>(;f zRw8>TuzaPc%pCOo)vmMJcB1qtCib|d~3A|YEDB}LDciOM;IX9iSmO#?PN zY-=q|cf3|~W3-aL_iP82=7ZY0RBR_-d*U{HQg~VvK&1dseYxxpyp%7Fp78bLsHYC) z6&>>tkLHNReZmnBs4VQ^3edC6o<%YF8EQs|hc-KqlIr6$wcpb_X4a>U3DjZ>Fm+kt zV&yJ+`BEGld0ec#y3a(?gAr$*kqmlKq;t&U%rKNkv_*xH0klEH4GsK$iPWV@O_#qs z=-)aJR+bi#*M88dZ>+RHltGt?=t&1o!(>rrG<7B{4qA&9siocn7_*iJD-{~fhpG}9 zRnfk#T1&q6RLN)JfV?_JUbEvB>S(G>V=EEb0gW(H1l(6q>foS9M~g@@m3M4VX}|2| z@Weg79Ts{TaW2KT?2Z(S=>%eeJKRHEcP>pSvl$$vQc|8QWs0frr<`j^aImMZEo*JX z%|vl%FwN{F172lvHRe!;(xg-}0Ikr#y)ax{jO^RXQFM9T+B*^3B~5x=18*SEMggH! z^t{dx25ld_AT&`_)7o^xz})M!Eo)`g+sf2+!>-lHE)v-q38@pnn6xYQfDGl6^O1AW z)>6k>R9c%_VL2MQ4beA~Zd(09B{-ygz&tV)kyRn5RX*7z+7G14cL(HxZYX?UE&%K? zQ$hK<%3-c*b)n+vLV>AR&?@hf%KL`%o~FVeC_gGd!JsMyYzvmTz<_0DJE#bUb4a5; z;z)%^3q5(fZekT6ndX?P)CEe@;9<`P%eDwdthn52)Z~Vsx&bXNb!l9&lDst%;rHWC zJlCjXTwEg=PuAmMfppi;2J^%{kP+{ma|yPp>H-M^5seKi4vR`xCanxq@3xpEzUI}EeCD4})WvLr0Wwot{xhC065774! z3Nk%S0vp0~B4%`&D(toE-JZS_r4_q3o3A4$A0z|hw35X;|?D`sG zx9hPh0LfBOaz23Up{I(X6q)T>%ita5!@YLBzWgRYUB6Juy@JTjND|*yNoTE0af)yB z?%Og+LrzQv8dFJ%AZx%}4!M{ktmY6Z9^!zHSkXvjg;3Ur#R8(3LT=ZK^YaOVNFoSM zAYwD$My}ATYyQST^2`|XUbqh*1_?8Y8^7a|>_l<4QgUQ1Yuhc$?T~rFfL#i$C;*g+ zX_JQWkXEQ=$CO(kQ+?CTbuV0dcAs~YV{YxoNSf70T_oA>z?bw*IxjNrLKHrN zc&JR%GSt~kkFFV_b+C3_A=YyW>v@?s!`KXzV|H)ZcnY@IsY3DCC3)Qq_To4;%`${V zP9vuY3euC(wsxhdIB(F}+f<04(J=a0PJfZ*tQ{4<%+PehaC)kUEgQ*Blz}KL;RnoX zM6v1$3b3e=3mQ=@1CW8FzN4H;FC|_dLQLi^Fi`@sb40?=B^zWsoG8JbS^L|}e)-Q$ zJyt~-hbCj%nPJsLQHSC7nl+ar3vcH%%tyfD1&nzLrz7BT4NwvV5V(W^3xSawTP&bG zNnny_^K=Y-tO?w(*mzK^pTtDAmqEod*%1bi!50!&-~q5)(nNzfq?Qra+GT_}u)xA( z9K@5RFg4)AUk8OXX5q z3Q=UErxd^r+!1sfxZVI zvg06>$}BFHW) zZ*hGJ>1S`AM)y#U9g-N4e|#i)2y9*I*(K-g+AgJQP0ZzV zvSqfqZ8s%hyC0g(TX(;b8RKzFsceBU4NvOLW(SYP#2nez;(YktllB;CpvDFJT%r$2 zNjI(B3u|rNn5b#(t(DNsz20iQ{x|O|e>dgU``3pKHKF1iGz>@Aj2V`xyD2xE zC^$ix2t47Qmduv1eh4s-!>Fjq`V1^>>T%RmMmEJ9?cy^h2YLt{*X|>>)LkA!CQizZ z4pIy1f}olSpC#hw1``GY8Lw7ztUQt&V4Wp5 z>CLtA!ARl*HtQN}GCapYyQI)qVtHai$^Tp=ST5eh4HC@~r%$^xPkQ2>$T@fk0l(+w zOvg69OMtleXROBqNdpGVHI({xWG_OScbAo@d1u@7q;$jzpc~!Us&VBBO2j`-gl>B# z+jjo+r9F#oY$UdKNECn!+6XN}BQlLuYj4j7;oI{;^!@8$`2HWZ+iucMiWvZN7Q)Nx z49%8-Mgb*kjN^bXC@V%=7ta<26M(uG_P5 zlN8R~7NxNAUmPB`W1b!! zy)6Obo`Q&N-1KCvSCL#^9IR*?30?vPA(|)vh_tk_OW24c`oky_Ys8{XF1P0uK=&cW zqa|EqrmF<@WDxJeWkZq(heSbRDs7Q}ohWS?hh8&gbDOnJ9%ZKy+koVNg%yJOP+uQyk&fAxjZ8wI8@vHsE8gNotspjH$C_@TAXGQxS_ zK6_7a9{jlAOvouIjp+Vw^DRAxG=?dn?4~g%-%EBgc75U?uhrxls+?IDvoE>Mw%7tl zLjlP_drFuxc~*yQL7AR9*H1+WxUh$7?s1%Xq6p+c*NqKo#x5>};gw}JKU@mKAKYp; z-x1*#8f}*rTbQ#O8#2lyCezFuzS&~9bTtTJP}9l;58WHJ;-4~b%RdkN z&~r;M>HO>}0NP59<+>>;35JW+=)UQ>tLS3~vp=RO6f@0pslK?3PkvdKbf2}WHEXY} z8vEvwiC(`Q+IQ}??Y(aTNU5v%fNtoC9CvX%W`%K<)K@_X6OIIlN_Wo zRfemYhh)F-joqgKCaa3HxC7#9NF|KWpe6GTl2~&edkLZdx~Y&pdJ}OeZxZQ8u;ils zq;iS+?bM##IXE|Hsu@Evrg|5hmUF)9yJw4HhmebceDe zSev(`ch?SEriwx1CPWIq&MG7I0pLILwH*F0QM}h>CGf7 zlPT+}cl9iqRntCkS|SP^KFu<|VRt1+F5qzx(e& z^c6r4!!@-el}l2hExW!vFtN1*>xz8p8^%r1y|HG@+iQm3TwyzRHN;!rPH3^yvF4X9 zT~e1Gk-i|49HkOW4e;8r|;36=Luey9u25Yz4t+x*seANoo zIx@YSCwR3=bSST4s?~-uVp>QAs>Gn;3_)BV*H;8RjJ^WsL1avEsaSP6oV5H@qBSd# z;dNu~&PUPPw>#1I7o*^f)sWsf>8pEidpS&Vz-Iv{VS%;hFJ8GdS2BqkB<}Px`ngxTf42)$q#+>F!~Chhma{pW3@ zIv%TSZ4?048rCkZn&{Ge7=8arCwSuzS8H>>-Ray+cX%=7FPs8(;i8*}|1ePhHJx0d-Kyn z_Z32Kp|1dX0BILv(x4(W8b<1Fa)@3oskM`Vqie>jty_L~Au#XWkIb9*JJF?gTH)2- z?sV>F&3iHX|B;yT09Hn0f1Go2Q*UM@Ch)Fw0A-J{@!eYR{&b<>t*~8F2q!(_myG2X zD~j?mElJ+o7GeS+l#z)^@!z>rVYchCpLyDS^c6r4A!$=~os81Vlx@9TiHuo^?6uor z^u2dG!S~+_qq)GgZmq00*0Xc)lH;V&{*TkQ(`}5A(gAz%V!|!8iSMqi&j0dcsqxN6 zc;}U3?#_@>Q9)sqEj%NZN;hqp`Rwf#bA93O4fGYjE+N~gm<|*nsjinI8(?3=NN8rz z+GfXatHmsAKHq;o48H%r)M`KYoz=#5DFI0S9?Tg~-@FPmI}7mZF;PXr4))`nV_F60 zNMe5O=QLC*5zfp6KbJHR{b&1z&Cgbg0~6?#fvwaBjeCH3K=3?7Ek1#ebH>4ixQkAd z!;@aJy;smz0J{M3i-os9gwz$^IA0&dRS-2ZW}{)fIqDR1$)cFd=Io;Tp@d*7vas=qrH7j7Xxb7|MvVHLjb!(wv z`EJdcHx>f^&ii3-Y1o5XSK4+h8C=4KX#)P*5B!< z!TSqAaI>hr&lL!2MO5Mf_(j5eNotql?cQB4GMbpIFTUPhryhTj(N_SE8439!X(8)O z7<(OsHOm&tQ42T>*hU?;^9>6>yc2}4|K@V%yMKRe@!gckNBF1$>oSAl>r0`Jv#>wS z&a}+2@mm0NW@<{wUC-Q~J zRp`cqTOb`umR1gJBX#NHF`=&jK4qpJ$D^@HNGsoy%ry|!VhNbZ%S~fpJ&J6=yo3z* zEb{%3`Fh*nrQ4zX!QWq7yq2Z$iUs8?z?nImnwi05-1B22>KOyT_YVj7tRx;YxB;;C zyRG&2|2$Q+547%)y%AVA($O+-6%OYR4vjdfu~?lregUvTwjmx-%*&qgGbU47`pV$b zMPC7YdJxlD3gcq>!q~O9Lc}1ZwO!>~6I|RL1EjkYlul^`K}u5Kp%g*b2x&paq(j0n zN{8^k2xb}#L5~(G4!|Dmz}4s^rtqUp0UXppD_-bzqA}YMwgJkp|%<@dmSND-OWM} z(X(po=gOx~vYT;?qeaHpVEO>cTB{{>UWS@(Y_cB!%%0zwI+bu-=jU0@V}lm$EBdgl2xnFvAt$2Qu^R-=ip(AluQSK)-EE=EtX_aspTOZAjS%PFigSXWMrP8X~TvV_jkiDMM2xH$d_k z0T8gCA;uUR(FxtL!R9;q`_=lx+M^|WI?tZYId&rPq+3xdDUU;h>4~#mA|yOyEz!9b z)IyQc(ZRXsORltgep)m^O4XW2TOu-yLZxR*E?jE5dHU ziJFfsFww|OWu{ImKlE|g5kB6Uo|3>eV7u!jdfKf)kYsDwkKs6v$WenUZP43=XYPQt zmavv7FHn@WI(bLUYy09Qx4hzQmw~Ed8kBW7RN<6L!+ zoNq-K5#2bA?^Lp77F%^pi97iYTq*-Bf;`U*%tjf8mln%xsbCNP1=S&?vo?(IQT*>% zOFgZ*23tF#h|A@a11x#hWC*hVw>seKry`H7u=8({bEbdilKio<@~eL487GV5O()Cu zXV=fyI~|`Q1M+PTjq!OlW;z{yQOY z1lFr$ypoKJx+XKeBfk)9lW-{d7j$=OX}g$i`Mr?K^C7UQ1350qUspSFx!l!^YTK5a z9l$ncCNFZ)UtNdDC}2O30%3ppQ|<9aCw8{`r^RX1<=WQPe^=4B;8y_<=Ggl6L_>bu z3~r7LW0h9o!77b-UvKDA*=u>ob5v#|i!fjjj}qod44nV6juW36TOrQtlD`+(KaJU@ zJl+wiw1=z1_{l70sjvqh1J-?I7P*AxdG;i+KQk{Q>P8x>a1GIv&%dZ!OcPYcR%r_z zR8A0ntt*qQ@8?JKon54*;h|R64zzmVz&zLLWim`_n2GOuaXjc*_xizCEg4iVIeww{ zKv2C09`2!|^5Sln#VJCvtNYP|=w1_D#!^;Rv>EEl55-F3!ABV_|7IZE%kEil*IS%S zYyKx@$LQ#geL4y60QL(z3*)N_KMT;kxDbIxW*6xOy_ziQ5cNloc%!H9Mtj^t%(dQ_ zRELJZn-wPtpl=1E5G8GvN;mCB*^?e>e1y(G>B z_q3C)q%S;)xm$Jev_Fk$AKn&T#V=sRb<>NE3uA@&TrsOo-R8s(4z$s*NTN|Ihvra& z2JfB&gDF6EY6lrsPxqkgP{rnH%#81gj~U^kXFKtR9#P6NdT$&9ooVjpQ6RL~pf;>D z7OEHWd)f-Sf5AF~7Fl-2wk>ce_%;0pUJU9Ft7Kih)N+gHWT|WTora>HIv=|c?B;eY zSpk?rG*`S7R5`oP9%L!??3w@KADN`Rz#H!QZ&A?z+4+qSRBYAyc)aQStX|Zc8+|wy zHi3l<_6X#4wk+!GJ9T6b#fZJCGY%V!P@7%Ck_VD?A96|dG{YF zWIAi+KNj$k4kv^|zSY%_Rc=hPPJImUBb-0VIdH*Lsh}hF;^+KIUa02$Yh{J1J=>a? zcdBv+6~x{Bo1`{C?JvX$OaJr~qt$0i0fGPFtGExbcPHg0B4c-FlPXsF~a_b(~7` z6i>sj`)^V0Q_A+8?O-%1wpuK%z4zzk3@K0XHepX_IP9i1eC;iXl3Ew18lweB-x^xsy()^^?_EQjyfps!@^u}*uyqYw zhJgu`Jmv9V7|6#iyz5;pz6DPDU|P`lcF^i~&6g=AM@vk|W=Iuh;BPi_L@OD9>}F`* z%zR)$hmfLf+0Zj{!u-nMs{)bXM09VX!wkiET;a+Q-9L7Z5J+XEy#^7`@ehLrEODDI zxd1Kv`vUyR*-LqqYr}KxUDpVaHN&w z7tyf@xC8Il&ry;BdY7=HSM#l9%2kC5+_xy_pBH#MzjLWj*gEb$_x|Dtv1H!R{Ma5U z(ZbP`*Oq?A50h;D$0J`1d8<=Kzr8;f)-u`@*8ZdIPeYr-vyR_&t6_Ik$k*f1;KgqC zO;VvrYIG@h=XH-#0x|h>&bGsz>FO=l%;F2rm*mH(%tG*Bra~Wv?9uYk*qFw4;(;bu z%`4Pjry((47aRD~ghd{8dWk~MR_t0V}5JnxKM+~;^V15fTI1|EA zuhl7#SX@(bY~=iKFRm2AcHd-e4U5?A1ut!F3zlB0I>v>;ZOyb|M>vK~z8(>N4ErLolYI*N)pFvKHKk+lT z*~sdt*0bS)Mf`YJl$#daQ$}^qgRN%+JNqQfK_n{snOC*BC7EV?S}yL@8ld=l{q2@ zy9D&9PyzAR{j?Jw0x`wXY4CxMj1*@9>uxANVskPJ^6T$O0h@n z;DZ`~I|JX;5Cvr0Z0&ghBZgr6Qnfl#+xSBftgE!a7vt`m-n)VuFf|Ef7;(@~28(G~2n29Zk9;<5u zqOyywlPIaHpbG9n-V1I~T^sU`!L-MTi)^Zxm#r8Sw1`Vhx}nG1Ch0%>gG52YWvn&r zo?gA$rJgN8kTQ0vUPfVp?*?lDOwcfiTJc8yGPfSuE25p%yAtZ!D57;Q__MENj9C4a zEH7@iu{`w*hwHFU;g|PO<7z4zDo;?YA^;O6hS=rgFd5P6-e;mEd2ilmWXfoxr~kW3 za5W!ap(cF4F~Cwd9gRKK=xO$r6EW&kW*dFcwL@30#Nb(U40u?W5Pg!n%frh3LHko^ zsHtJ(Wc()ksH@q=a~HQtqfba`X-Aj}=4$nO%V1z0GkJaW`b0N|5x~Kip=@_@7uazF z-VO!uiv2;lZLGguB!8wgo__At_<&Zoo`wluf_^W2qJVugFIzYBj}A*6OI-ZjfUgoB z6jwF}rK%Rz5ho`2(Mp>Cx%4MBX!FgF-m>OjNp1@qLC5CE!cSriU=2y57Lu=S`12fV zm+ksJ$+BKb7)`=LvH$IJk7~fPS}Tu^M(O+UL{0=S{n>#Cw8gSG@=UN{fH^l^1{lU^D@1ff8m-(3Q28XGkh^6(6_DctzUo;J3wQ*) z;8=2}kLY>d{+*rSw=LJJtGswAOtH+d?rlGc`>DRiT2tebR{LxrxZf^ymeb!(ZopaCtF=jfVQ- zve07*iM`FWB>nrDwGn6K@Z$a(cBE*XQl0FS1_i%z)2J*Dke2~u7F{p0#-s7C-TRpQ z+_#jfkh8(w(aod7Fm+zJ>f%=vSv+h%M8sU<-5*_a8J|{6k!8nN$C1kciBj;Y{!YnT zYdQ%H!*&v4{=E8NJrB2Hf{QnE#a?XD1P8B2pJFK5S0_%Ops$xAw}$0%<(;GAQpqAI z21j?ACCQ3tnv*B8VLUeB`eO(MZ=(Dbhgt0LqO(DaHaJE3 zTf$@tf6!TrKRVq-dqww1GC*;lmv;_9_jEp}^%(ajtHQXI81HH=+t)?L(>5{D=2+|p zuYfuNI2JsE7VntdYK`vvywWsL>>*TAFI-|CTb%jFeZO_2phpHqt}tT&pqjuMB$|Pw z{vYGaYYFLU;xe7r75ZB3mARNGXujykTGMdbt|McNwx|95caieW>UZ)cjh!& literal 0 HcmV?d00001 diff --git a/custom_components/dimplex_wpm/icon@2x.png b/custom_components/dimplex_wpm/icon@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..b4959771e693edb67c3d32a55a2a2eee5b157f53 GIT binary patch literal 111866 zcmdRVQ+Fjy)Ao*cY}>YzNwQ;O!ijC$oY=N)XJUI|V`AIpcR%mH`0Awlq}S>~cU^UL zb%c_FBqAIh8~^}7l$H`x1^~eSn}P#?(Ekk&9*b`PFaSVWOjy+;`{GkGiC{dH2$<`v zYJ1D5E@m)wXto(5=2Srzse+LxU;My#ahi%6$#E48JT^6vcwvdV7sLjA2JH_1h(~5* zWE@O`j~)BbEaeKA+r)Sa4OqZ|8c`yS-+^DyNITtm}F@GU+4eH(uR-c=$bx zPlojFH0*WnJ>SI$I*uxd`yx0m+Oo{~!b5;(!Le)m$J>oy-#ayq9j?lXqK7;*$OW2= zY?@J_>*EzF^y?FI#vd|(cv4;ro<8r#5)$yv*$8Iq+NJGJo-ILlFr&c4ZMOZr;Ke4) zm&FxA^Ce(^yWNpo^J_;M|D*DSBKzRH=6Jr42b=oK7~sJ#kF9Vg3h=NGQK^S>mfZE* z2Ljc3A*gx5=U~IbdD8kcSU-@#Zk7}0u;0Ig?bGVqFt0% z%b%|FlCgLWhelLEVnbPRD|k>ZRℑz#XZGx(d;6eg8mkW7+hrUG|Ot!t}hY@|T8M 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

  • ?xz|TrH zUlfK9j*#}BRqEgw?48)bub!9O_3+>Qcm6tD{}^sO0DCVu;-NYzrT`+~ygLBL=jYJY z_C{!Lxr)JGGxNWuxS8wz*}5gc`0EY;(>7)vCLbGlU@u+MVxB~z-v|N&$$L&)-^I zQ~a{u-7X)L#eVHTtg4bcY1{IA*#R(rqMjHy?3rFym6vC>+_hL<)M>S=l^h@73R70l zMWPN`$Wmm|KELk}!Z2PD(0bKi=MOA>*;y2lBHk?2&g$|aL#`LO=nBhRf|aoUM2vsQ zRs??b3WhI?wVzLcKb^4o^k8Z~U+bh#)TpeUY^<5i!-uP`S(FeI;=B)+I01C`Zlj$Q z$6mxYjq?!wYQjegrR8~fT zwPXydSz>i!tU{A$rEG$hwIY>}|GY%rm2zc6LO=s!EEAdvQ8s3Puquo( zOjv<2mA|KosjNU##U)kp@kA*DifooDbNFavnaJi^n?4Z_iTK?#wTDT<&(f4X*m-C_ zybtgY9Es>#Cmo&uIS=$$&VzSCWYx1EAQ*D<^^@{2lL~=%vTW@y4Dzj?sFQwIiD9=&sh=3Dl(nizlwz`~(5zBmEG?@kSq+v|Wl1q|Qn1y8q}hrX zASrPOM6uR@XwwK|oty&zh^)xU`qM}kJJi+^VP&i}L?k7}CPk`Qa#TP1|X zBHDz=kRnsUaZ!xCDrC=Wd4)={puesrWW{h zRSkRva-ggIm&n6Esw1!00Y2Qh=><+gnIQfC-FA_HJd%8>{ncz{4}85eCekiAhcq{s zr-&T{o5`H6h)gPt)GQ|oKwD94mT(QC90VnSAAY=eDn&=R~n%9mK|}F zMNN{65_oKY4=d)UCG%bd{HS32sI>O;nZ+-MP(Jz9!F2!baMAcT8@_dm;FC{iElJdF zS>iAIPf)u5ydO$bzY57VBH~SP0FFt{IxYJU(YM!u)mrYt{~47ljWGC9W<#*Fi9+E)+!OzIR;Xwm11Tk)}mk*qpvJT^Ey_RS(glo5w$!9 zM1}HRbS1=S95Y>L>)}VDmC|2}(nG`wsSPW(R!ZUwk}?T`QSunk-?KIv(Ga4QH)_Vp zrmC{QB{G!-+R7xloHCaqrHf44Vhxh%k)0HsG@bRk-PziBJUjHpQ`pGW%bpY`R&@I| z!Na@$yxjBBqoKdRYZDo2)XjvSaQ*s8ybbvc{CqmahZ)mFtJHQvX-}CfvydtzE$$)# zmkMlG6Ld9TM|?;EB#m{ib~y=t29_p5lIwLG!MP!*8wf?bVUnQ1@HbOQaQv`g=1R24 zH}|PZ%hL+rps?_y6lq^+=wsotlKG1Y;EPdV_MURJ|KALU$BcgiNw|A=oD_+9w^pa|y}{?e$Y*6J6MpDP{v-~%f<^D=3_#1$jk5ChQtojmXT zcDLKRmZrT+8oC>)>aCON4UoFIrcTa~CnWh}l`z{(D@X`XF{4Hw&X-Ao7}=@LGBC+% z(n!A$miLJxz@j!MpCRkh%#M*>*Zd(%{T+(U5s`Xl_$4YWwn!)f3eYMBD`9_h(Q1_T zqVhg#!HO2HL<~wJdN*iiQ^RF}Fq>LCn_8HRtQj9#7#|d7{6z)hPm5yo@5kfOKc7ye z>4@Bfm}Fn3a-fo`fe$|~F@5pH{Z~tOEPxk>&lv%0Bxy2eW5?`{3YU)$nLi0Jm<@ zAN|XJ=@wc=n)2SmhxUU|4YWi>iHJB?5r75fL)y;Db618paI@8<$G`Z{rE8^fi(k(8 z_ukH6@WYJOc8S*3iPuu;E(bJF0PBb>_dP=FY7{XU&J4`LJRVxNW==jif7?SYQa%( z+ySoyZY+Z#ph6MM4B$*7&KT{q0-R`sQObCfBOgrwhwEeyr$k5ZWLdVytUsR3>IMDe zEL8?gVqD0AdwVWL+Aj)6y4;5w@Hsq?^Cgbk%k~!#@Xteq0EE5;A37a^dySm&3!**3 zs~kTuMtxySl9#NB6qO{CH&~-=St(sqfL54+DRROHiIR>$LHS2QG_vM5i)*l22{z)* ziUKsjfLbIh6#KivP$Q`!uNNU)5$WnTl~%qbz15Rld*OyJtVUdDC5UANU?3Zg|IW7IEGkfMacEUF~@P0sI1PEVi4yew|Cp z;;x^?{-^cI|1S>G!4J}W{hHDn+X-&;6>WA2*Hhs21aVU%t|^3pM(8QTjz-K8ASZxS z^2g3rcrE!FDAqX%I{rc;3anmNd`8Z~dvdYwPTTt6oZe8o6iyAaE>M%nLVjOpU+*S~ z;5@h%w?-lcz!IUH5#W@NrwVYQ5XPGMNFyF*j0auKhsILB0`r_P%}{Z&o3j3AM}_RR z++@-^u|~N@C>w0LAr4#ZM>m6wUNZhKT`IWrS?71$3CH%7)sY;QVl*0!4w=;>&ZuC6 zlf=MGDXtieVKj^q`RCN4%5&n(0H@)2y8}#etiw{#pPz;5g@tWRDV^ZGJCi@B z{=2q6auISJ4KgM3tO6L8mYi~XVvWUZk0WU{-hjJulxrqO8w;0 zHWCI5+WzkPzJGsN3OEj{i0=fxZ4Lk*)4X>l$!C0DS`JAUjFfoTTMEuv3r#k{W+OyU8X*rqv`Us|9`U%VxD%6uwld|xR zO)Xe-?3{f?oVX3)e}qZ_d3;i|!fXP}{%AZNJ!hsv0Ne&bIUvRr`x9ycsm#cUY)3KW zXgSryAZmg2F<+KzbWgs99~bfMp%p!9h<-tp%cXYe7wSRnBq5k3?C*?!VR>Il{Yqw$ z_x++`cqFZT55Ru;#}m7EckkCB=fAh`y|)4Azr1lnN!s$m4?Ym*-KF>34n%_=@g2pt z>HwU=g5lEycz$;Ivi@Ojrm5(wT+fm*>6E9KLt5sRp$h~(V^uFl=mX$-26j`k+DOFk zBdljg>nXrS0=NEAY-_O`n_;bP|S6j@(@X=w3kRmXHogi$g`#s5CG_ z8jRIiGEteGbxc}J%Ce;HwETFKRrek}svdg6pgq$Mdd(1(N8tr*`icRG3N?gKr(+Og zp6~m8oJzXr8UVg0b(NF@lEMUy&Mo8#U{3+}G;#+Rvcj^f`jWhFL(UBK;O*7oDpEsq z489*|8UH2<;JW+ya9(pBT~|K|C3(5u(lCq+^L`2NxM2H*W%#T_d{kQaa>DrJ)2ZFR zySI0Y@aL}MTes-mM<1yo`BMGzkzx_BI$=0PM4U?ppy?9VZdI!m>%Gr{FQ@cFBm?x%%9GX9~Ef!_NV;(zwUm1GTZ<0V-$`|{ipDkJ`8hS zbQ~kjy93~AzwZR1pDXc`XK&wTbLXyHl#XV91}8vlVCN@!KIka3&XBGFa$h41GNiSR z!hWK-Pk=o{>M9q`$DA3vRzZ)MdzP^W$Xx}{QREH30_^9YQ( z9wFP|jO`JDIvglCe9PwgUweu`tsijePnC05$~=1KHp1PzE`%>;`p)rgW@my31GWEl zrH41~1MK=rfkbGR$IAopkEc`I%mFTNju&i_&A@gh2783IhfE!7g$T$!gJ`vLo}4P1 z%e*+5N8g~ZDG8U3f`KdU865N95$E*p08Wb7_!H*)J~-PiRXonY6Ux79_vhZ`OG4cz zD+Cxa@Ka{|ya4|7$e7;{;HQ>wZ)D)vK{7h}_~X?Mu`FWD?k*4a_PFz?CC1Sj!#>pTg>}%iq8fd1;w;=#P5yLAiKp}n1 zD}d3=@(@r;o#-GZ*q;(V%{6o`ai*?zruMUmi;#{^+f^@jhyZtf=DG`hc=IOizw^KoU4reqGDn}e04*=A7RyJvSK)Oh0tOAEkH-h0n}B)p}F zi1Qu+z(tjQ5UE;`;%y+UXIr;G6#6V%T_Na%Mc~KP?h8X)QvAA>0hP3_go^08XA zwFRT(92Eav+4scns+KT{VN3|wNEx9zcB6K*3+mH_g4te%*)Q#K})?Gh?N_Fw|vEckZn`WPH}@fSMfHN`#10;QX-< zA3vrK?yPhsTp9NP08i}{$e64`)t$2KvE`mc%#D?Ek5(clhDb_9C>6$lE9t~m1;-*U za48`Z?f-WSs|MBr`@eane$J0D$^Fl)HH>fs%+D;qW5fLUi0!9e%;3}av{V4ltSWK# z@X;dMzuB%t;(XXh{Bm>e!2>r=Z#{TmZ!MFXBO=bB1K=Ixa4)1T#{sy%*mAB6WpFD# zFJ)`pn_F9hA6mWjb_zSYsk%5Iyxc*$qycsb@qz}}R)Ed{P#`t^n8^~Kkp09iD|}bB z=W6)fLf@?;TbgfjA=ier4ldf+2EqzgYSuOeNI>i~9;5aEljZT@! z=eAwRn?zZhha;tgV+A-=Ae;z^7C@&1l&+Ijq(K7aya}7|Z*WR~@N9AvgoKVM2^Mu) z+;{kMyA_%VNvdagch7>A_0e@3=>6dkKX|x6J7~jIEd2HX@Y*gO6=0qZS@o>tt_9Al zBE{B#b-FJi(~MdRtwMq+(h9y+?kVyNs|0|e_5aSY{pS@OPU>M^xFaGPbq%gK`0_$& zrF6#1fDxJBeRh<7{DJ|b+r}6F!)*3AyL9R4=7R_JbKh5E-mpicJAV38A0Yhs&t)EQ z4nS}ezZK$aM8tQU1n4dd$p_y(C7OH-KwW?59s0}bQ}v4{Du060&cJ5yp$sDWCYt9aZ4d=2%A4ws5?UDYo-BjE}ZI{^pEuufe^szi`CuNlZg}Q zcIn_(yNr)l1uWv!lAsrSUrkMHEo;Qs*WQX~-MBobYgI^+)|kK9)~!v^=EiVOgODaM zwZMr5)M5a&5GYu~dct@mq2zZc?fi|rdr%S&e?a~5g^gM9zdTFqAAI_$iw?M^CO#a$ zyysub1o-)6f`9iD$KY?e7S^xHaS)&E_W_FkwpyF)+oMCQxC@ERB{JefmWBNXtc3%h z8FC{fpWgZO`pvBpvDey@IpVdWiR;hV{_b*j=h3TkNMy{V{+;lXc(Bak0~itfnOZIl z@Dpk6YXH7vhQ}6pZ&=Nq{=Fwpp8vfkA_K7Sed5#-y#Ky}(TJeev!as`#$Yt~5pdoe zfMZPmm#BZ0YcxsL*R!r2cFIXF|51+yzh%@QQ?!;SwN4f`5bd^N-m!?g1hAvg8UIZU zxULY_dZb8d`VuGZ83M{-FXV^19=%TW%a_)%a1Z7>@YcEaUV+ao8qO5AuC;sWgYXgu zY`&6j)%=}6GYdgXF}F@GC{PyMOTNIFeWidSGltxJ>rKQ`C&;clj>s1uSXfhR<-HOi zA@LrpON}V$Z;%?L`*1PJ`?5TK74NE{A?$)HG0Q$(;E&B;)A zb#+cqxVhvB)QL_=?7d`r!Qv^DUy5K&#i z`5V{c#sBjuOlQc)0|!x|#!QAa>Z1rKPIU42r2*>u>`22JFhqkZM2t#Wws9U>e3z(# z^~OCmI4S;W&`ZESRL0DzrKJj*0y57;Z#;7X4|9Oe1mFvm{msBU{i3ic?W%d#@ zYd6ZT2u2R!NruY)FP;5r^60mC7+SRW-UI-i>0eFECDY$upwHpv=M8&)3efF!VA!W* z3xG#`z`Z1*o!HP$R~7pV4Yj9V0CozHT^868fGt(>ix7GpLQ;_zCG>@NRR2@GMPL8( zP2eRZNacDxJvfxs>t3*+8RkhtXWn~L;cs0;rv9CfX*_$7*2rtiU-F`K^@A$`LA^5# zi+{PPUuHZjDmy5bVX1e2M&73UY#JcMDtG8VKX%ojkbGA>>Ex+|xR?n0Ot6~+k5jTI z8NqQb?vR9!I+UF5B>CwzfbpYH8sLkJhRQ|Nzt`sYC}o6SXu^Eh?aFKA*E+s@ztsjF z$-5689|` zgS4MNF`A0k7`6(2{WVf?d$i`ofSLqUiQkh{zk%k>2?d4VxoI$QPCxb$D`~eaRTF)(aa6y=V^%cu$Tvw{5p-{Do z>Ys#%gI52t0&tdzU;W)hIQQr01P0B&smOkf_g8DwH<;*+lIQCC^CZE*a9`$O8r9Ou z^(M$1q6LADH$6b0h$xN_d)ht-2-8$>He(!=(yAJ@5?uJvNb=4-z|9QIu{{B1fFu;BI_`WwIg4=h>Uc*wlm`H6H!ZEw`^l94L z+VYb*8_F$mo-%{&no|a|`%HY%#w3nQtpSgHqC$ z@4xpRj`V@8E%x@+?asf&4;U{k1EBv@i$64&|L%N(U%mLhnB&59!^yzh6FRDE++vRyYG^Y#|;$_Vrk1R(3@F zQSSj{?;jo(KfVcz$-|x8bfgm{vPD9BBDm*Jc7b82f#rmGsAUnOtrK+@h(23&5Sz4% z)h3XNg!bYetHswrg8myFW%nP_ZOi$hd_7h&pq8M@*KOcIP6Vj0Y=d|Q84Zi$u))anN>>!;ca=f740AXOd+wNje@Yl4(z zG%&iE?1MSTJ-D1-<8`v!MWuU$OQiv=vfoTeIG8H*IWqJJ2@t1!zTRi$_ zlj&2Kz!+erN`J2AtbC|X|9e_zDt!Z&1vTZbXbGxCi^~`QtZQ8R3I6xP?Yv1*ugT~y zuU)f$b3RYdn7)lcS^K+NCc3h1d%*|ZWm+#zL!|vc`E7!s_ba+?Tn4bt6wj(1L4qw zqv1EXff!iIq*nV}352RG1T0u~V!)zH+B$_f7+~cMBuHit!M?`EPT0k7CrJd%lep8# z{`n&{Q(G|=^6%kJIZkwvZIA$Q{qsEU@%ZTRW89z5v7hHSoJ@SH)~!&=Uw;xeN~RNQ z2FJ+sgaNj4;2{EaG@Q|n5CRy9+6Ieuk)rWtP~m`8`Z4~9mO0`&C}@6$7T-LYb+AS- z=EuD5`2BT&qDd~!M2bvgI%giwMUH1q4xi@k>96MLlYe$JJp_Q$Gpzqxw+J45Y+&c1 z!0nn^Q;*A5;HSl94FKv^zYmwS*mKs=6-4g=bi}z{FG+d>E87E0yL~eI14H`*GS~V9 z*ZM^JJww+zh`TZ3uxHSC`_`4@Ye3C-SFLYAuC}%1eC;=%ehYfhyLttRxFP!4{5jSu z9W|HDOCyCQ>CggKl?_&(SpU)EKQLZAXNX7(M9F>6CSjB~lgx}uMh=r>rcO*8r!I>2 zM5aFmnEx`A1U8bY8oZEZ`{+4u|JhIt_GkU^AU<3;vwNCz{)8R&=OA56Y?DwgVM9L^*?ufq_>%qqrb>N+x^DpwERIJxT@7uc-^XmwKi+CNuU-#XuOc*eKk?PtNqsi zq+gd;#6@$T`7uDUzuO(|THfhLcy(mWYg=gE+9P^<&zRrZGxVb!qPMn;xiKWO*CQOo zgdG+02`{XbHUBc1G(B8<>jV+?L zb}asA&lo*-KiVPt=1yc@>qfXgGH&Nbd)>hvKvMFuHlmEw{Fr*M99J(is~%jv>djZO zIF|8Xps87n2sb{(yAL2o$fktm>0IF95xd`8#0RR57o?Erh zpTP~dk^I#wC;dH#Tf0De1C%R$qH9BIua6L~_YGd_ley9%+3g|?5>iK3(z3tbi0MLK z?dhu=f5m36TH3-c(rdXaJMHSIBn0d_Su{)G>#5rxz{0<}=x-*-`SnTHdau7wmOuP2 zl@Y;m`%-@F(g4Zl>RH8v!ZYjQ7Yg{Qo!}+&h?%-pQ!Zj+G|-QU&}?~11U|3Jx!rPTDD3$i8u z`V{9!5gi}obMvrZ+KLIT7-EA4x)uxzXtzlZ1P0z_xoj<~58m|vBd`Z;RP zOQVCcEa@eiU0HMfpjG^eaC|G(A>USo;02b1AhtfZ;Dnj_Bod2y>KL&Fi^)`Vm54H8 zLkUO+du}*@;?E}FeyOd64ajPb|5rUNbyZ^rfOYr(^X6wQ+xDt{e#L+H>8Clo@y6ui zobi)sEUDICooiv06}jVzjA%7kDqdgzA8iNdxxs--k4iLddrJnErWUk zs`0A{E|Hv}NLdyOk;y{%c<$uMocZyB<;eo!C}lT}MCQL8$Hgb-yskd&zqk(q@2Rn7 zix#ggzH$I?qpXLWN7(wu08xK5>h}^i>O{0VMA{#zxDUaNfuS2cq8nYrYhA)C396kh z_9G;(=BE*U)y|izhQDX^iFfbxfj@dx-}4tfxZF6e(_tO;)}|!3Y1$h#ld5U$P+~R8 z{*Qes-0y@{GcW<9q(JT}S)NIoGFV$M!KweEK8qzt9CIw_qMj?>G+XZVlM{EZ6bxD? z`^nNU>V;3860$T4_3;`x%hx@W6vmzpsEdaG+0Qke{Oq$T^^5=BF5r+%v_sM>jBFQz z`vu5?KwHP8lborzwD>l$q0_}H{As6#_N-J7YK^P#nJjU+NSX>yX3U2Zk%I-xqXqDj z)WL}mPJ2<5{R2e)=NG)LwyM5Xk_5DMwMC1|7yxX{?0;|-SN8;R%evJ z_9&*k9@2h~pk85`?NpqcZl|4gzXq|~@xej#E8ap5V{gBc`3 z=Gc)-Vs;lLEY-xKJ&x%$Gsi|{NC>&eDDFm?@aIy zRhjax?+&P5*OR~g?}rn5C+T!wx3HZtpJa#=D=;?zSrHiI8rJxMww?=aW(3-N@XJSo zdB1Xaz4c{q_^ic$^u%UL{Szf}$~+f7U9cR^SspJK9;L`nQh;MboJK{G{o6z0|ETHg zr?`7}Wm9t>0Dk%ne6AKPzV89RQvGyUI$lxu)71aJyP?(Z--O+GFvyav1cqJgTp6Lc zHj3az2k15J@$V8{*Tug_w5uBby7TWF^zMD@y@h3YPo;jmQBOrU4BTH`j8@9rExsbW zRZ4lhOKZe(5<~`4PTiU+BKkA{umNC4eIzLWvG6HzhZM0u3`S)h*|Ond?8V*~`l~k7N&=e! zOn#H6r$0-g(~N1H8JWZ)(+qHKSd{Y+0f9AT{lC-!sH#!bdp$D93lMnz4Xt{Xdy-@o3KQr8@nkg&ap>r~7?KfAae4E+Ufr@BO`v zr|toO+#C#iCZNp#w75)N0L@lEop>t-RAa+)3*bYqlu^NVhW)jzt2Zy9>MPw-mT$hB>>S9ZGH?H~1U@XpSTsz63nH_95LX*obk~q!=s6V zCu2t^Cu(4oWf^?%fhL{K8$sGkt_H0$pLX#7?%RGu~G ztZaX+=ar&8RJK`_m%4vzRjpvXjjzQEpx#cGTPc;oYxD?|Y(Q05@>Xj82O0!ubP#$9 z!DnueAT|I!<1}QT$ch#Pl&M9M0mFg`Qb3rqv)8+56IkFoumd|ckN6KCKpyFnsiGg0 zF~HhJUE2dc4sychHJh9dUH`kE$gTS;f<#T^n3xm+7I{#M8%NBih;%9-9U(@mq!Sbs zObQY-JehoCZ|0@-%@Zf63Fw6tu2w`CX#)jw!9Y0A0ADo|s;Hic_DkU9^ed?#I=Dq6vMZ~FEwD`eu0JPmtYqE{? zKHw&Zn!EU~L&4bm2S*U6y{Ov(GrVHV?#SYmp@C}yYc%DrZ2ud5!mA15l@8IULlnmd zQ6Tax4QjkbeVN5oRd=0O!)7zk)XQ{5z2|E6*S|hij$t^?nq4gyu)v(FToknqqnUUO z05s)e%KHS7j6g!tOH|1QH z$;ZJ)SXurr`~kaNfx+NhvZ`V)HU$|@9a`0k9~*>YAwF^p34>;+M4hPgGexA%Nu@79 zDKg^5j*2z*AM9jD&}R*NjYmL!-PggFsTjKDD=9L{q&e8}nH8)_8b^S*d|J5h;4>t*=t@2-sOGyH3*1qa?9N%Q^$ufRnxD8;s$uQm`>~|5i zwhiuWS-Li|cw=N#*4In>?k7Zh3F20S(2o!?F8A=h2i-IM{jT2=&l=nVzs{HLUhQt` z8^Q{Svi!IC^FYWHHC(FXZ-aes*&bL*<{DryiE=ps#Sy78n=3@z$Pf!ypK>K&84?sn z2J>06NdI>Lnf@N!xwDdLtrlzzK=(C;{}n&m{gTOj5&kC~>pH2)9TqdDNd4lEm|>I< z#KLS?cmCcYK>XUTt>`LJ;ZxC#$fA-6uAaB$0en4NSnFxBJw#nVeZeplfjNPU4ZtaZ zoMc6D@+Sw=@f$E*{O4;ae(zK{nS_@qGXU!^)1t+zj2CJH+{CuN4`6??C^{npgY78Z z85rCjlDR%Ipt}Co`i8D_5O+F+!w9I?Bc#}`7M@V$x0HGx>i%l%Q=6`9t6#lCT@TY+ zd^6Z|oNMBnp0ZFU(A}2tDmi74rX;yesOTGE>30xSVm}v=To-r&5+xH*gl@)^Vd@rN z&hyzn1DI>K=eLw%UGfN&a-jd*xg&QCNfO(PT`^V1I7j@nVCgYX#0Z`Qh%AvMuol61 z(plNA8Y1JR&cNECYlGIWd%`w6RtkaD1VLos)QJ=T3#SA?#wj6o;VWhN1bweS00@fpXD5LdF9f%E9&DNk>phn5y9oc3XF!DmgSxPm za>^ojLS8%Hf>2LTas(=ngbk3coRSFTCy+0kktuOB`4nJs(R*r^pkCC0O&Q$30Dv!6 zKMTUGTd1`KUtkcCzx{8$>Hh3U(mCTKH^pJ$Y=;Gs0kYB=q6j655MxG+L9|;H{e@Gj z0;^)>szP9;jJa+|+2R5;qo!tgzqA4K22y&jM9kcm34H?5fpC$FK5a zYa@d*?K!Z*cRbXe-#8rz6Kk7O5-7An^3m@#7O$d^vOSC>1_n!9xL< zpg^i7lyx6^c?(>C6~>?6oho^?C67J#7_079x-7ib|B~Z-MaEwtNe;xn*xRqcFx z2CwxH)#<=;rA*(Wu{-%yu4>kp!uCk@NdKNZwBYptd~-H-g&XI z74~8~g(x4ut2JTMU8oak3k({HC&O)p6dE{{o=m^Fnm3N?U9Lg2ZpZn5v~T2AH32xwA~{dBuGg@FnxvWWjsg zlR0&=*7F?^*@Up8?Vl~|Yj$Yc(#~&p%(go9>UcW$P3aE)=JTKaYS*n@ z11eb6Y-Lr|K}*`rxBR?t7kDpvBkN3Y%95OXp2^cM7TM!*;U4E82Lf=2D5s=+h0vk8 zJUA+Z7}l%Cd-HzF?e72i$6U4RuZLe~G4_;8%>wvaB?P58|4cYn&Ht(6>BRBLMEJ?X z$GGhzp)CY1RoQpU;zxr5N3cv_4iV?dV#DO0d1baOdi6Y$JA+!J6 z{m$Os8O9m}49juV6sHm|*rF->+p7%jN+D2ho9w8yb*pUOs_rlPeDdD?`^Dnn!*sI$ z>&gExpP&5cWN~y_@F6ffMTBDmI7Yw)pcIXIe|318_bt8}>VEqw6QzSpyDj|9+y4gX z1+D$(0(mODSg?%8%*Rv5Po~TVCxtsYDcocXvY4c)(=z5ouQ?uo9Cf=YL*1gqrGqWK z*kPUg;)!}e?7y1s@E-u!hg)0SB29-sGiK+fy=4EoHP^O?uJ(=g_~UMZIEs+^9Yc|7 z##fiL^O1Wn_X%HWD!HyDhnA}E;s~_(T5Rkkr6ZhArTc4LTz{sW0p8XT!6ZT^7anu~ zM{H!b4|ruu@Z<)V`QIJ%iYNtKL=lb#gUR1{^eDXpQ0T~^-~atUsQdkIAp3LsHtIlB zN91w`@8G@;)l>C~kNz$f-2$Q=Z7Tzj86cz#=*sSuH7S}FF99o|l{ftf_dYe~4|=%c zf+5dEW~uOaR`B7B`C!8Eq!4$Qvy2^K>Wp#!XtGo@Rn(iC7A;zUy$?WtKOBga6zlqS zOLtmt?(M}-Ix;wlhr2r#ukKsAv2CI^)a0jwbR`Ddwg>}@(DB;-tMj90`ztX@gMm_i z-uu>9zpzP^yT$hd=bf8Sk_}DS`mYHcoz=;&BnBWsfH`8v3JnvK?H=puvXzFYz{?U8ynWslb-D{9-k7IY$GQQq3^u|DMh}X65ue*Os*j2(`^Vx!u z+^R}imA0*#{#*Po5uBR+I#~J&R4IY*picrR5Fl132T(#CZZYA$Gp5)>u{|Q%BD6!Y zv_&AvEOH;;+{@v?gDkMa&hE8!@!xNht=3Je&V%o0xqTZO3UKHB&Em-bo->kCGVV&40?V=8q+pYg!wkHhQpvIit7*kE~zC-|BzAYGKc?yJCiT#D7jNlu#u3))$!mbbAZI6W&xmZ zBvhd3b`We2(RLz|0bmXVEebeLyWZ@)HTu@ATL7PYf-3V1SFd7e<$57jxB`^|-1(D! zF^i&9K(zI5A;@zlv><>5fTS6-Wh86s3`Nal(a-AnYwIRetvb+or=>nS!QQj9{499@ zWov*kPs1-FNTGyGLOIm{AQyOY>g3>vi-UofLq?qDS+;=f?fioep0eNE_#i;bJ|c9d zwr!A`clG)2wJm`yE+s77xPejfa9tDo432h8r;9KIr^!r|-_WE}v zICDZCwBy3=zbX=kR&sCZrK>}gDz5f-%{H_){r~;KrgPIUz|js)|BwX391Z${gJ9Vf zSp6L#T7%LfAz|XUU>Y!?=^^QpWENqzureWzPIZ}Hc>|4p)Q=uN#{K!+djxEdalHsw z8xO61oHHNUSX(64WCSL4!V5x~6)ZC$nlBh*gKV|brJe}-S=TcFGiqttNMF6qRbI#OyG_(hQrfBp8f{%!*`ykQV;0LZnNpc^46=T9wO zm00E5{j|(qFZw0nPZtB#R`=~w)sr_~sJ#-uzgRyv6;r#kWu~TTn=ihWoR84Ltt{@q z>c$`#Xw(k602l^QC@g?*0#1%m@}~zm^Z?)_NDZ6~KE9Sm`{T#>u?7!n6m$W8VlBtc z6-8vT9C#s)W`=NTnWv197zdFMS@lF{7%Equ3MC7sR8Fqslv{jNgkd1UgpeD6g#nxx zq!R{rbW{|_zsULIo&J?c`tVct;1KvO0BZ-xrY?~RWrs+!dex%ErNMJ~0lWdwIV!l{ zGdMz&orrLc0IwK?JpkNEkZe`=FWbU2<l%6KJpdX2n4RIU=ST*rfXe!A z>!NQ6wk_bOLumVkzyf+p-;(U7&T+x!)pMjY2jCUtn!N#4FwkCoKVUR9+p8~p01()F ztl9Be=PlHANgV`rBv=$L{4jRB^!@1s2+F1`D-<@j%AO! zzJd)%x}$$24US;=;1(UZp9%3|5l9Qb_8t}p=ccTYT0`4TSw?Ci=ba|c<%fN{4LI!6 z6Gee$N}^fKAf-CQ8T&)Zzh|>HR1H5OSJ?W$cwvP@{qaJ-r2S!H{bZRXnFnK<971Y2TyQ*y zRzDMQuc}%f5|7MyBrbmZmK5oYV_J0D_2ija(-r<*!Pjh!=`E=-x9n)Q3bSwH)1mvr zluJTL^q$U-U9hToKMlbFwlm_0HP>*ydzC4fQi4`Pu^;O4(7{)Zc^sXt-&We&8U;EU z7~hI+VD(Sw%c#2~!Q|qTC%63?Ap*@2*GOh>*7X^arX)ndxMl`|bBC+YT?*UN1L|Wi zQr}KK;xu}xIT5dGKDcj+B|eQ`V}@t^Zl`vQJL&!4WxYpE9w$YO zqR(NEFoHDHYA=&&jEXpCN3PDiu}pmdp9O_Up}{?$>AQfWoUD10p^-*Ve{?t`8H}uxV46uKc3!S1;2aoky>DEI*7B zsgQMtf(V$LiP2aU;oGbxDnzlEue#G*|NTwweP8muJi0JIuK7uZj$#rG+6V86u2*t} zOCmA@u%)1V-DzJ7tUs1=s(_m&U}fWFX}`vJ_Mb8mBi{NB8rNQm7W|uEKgS)ys$Lbi z%Fn2q{&k3$6aFVd@Ti?3~a;5WWs z>bBYgz4IjugB)ghrR>vh!`x82+v`oH<7h9ZU7bj61f-Wv^M8|2osfbZVNS|Q4du@? zVTrmLQFXek@n()x))j@ejWo1y9|h%W%Ojujrn98JZk1AmAQ54Uv*Rp9sa2!Jj==rZ z{~Wz^b2S8CG=0jpxtS+lDioqYEnoxZ@E~HeG?r_GKVfVo(+EreZP?Ol z!{Qr3XmA#^F&RYXPXEMet$-I)ex3wHo{(5YUwH>Kf)Hk#*N&R!G;$eKvr~r2=;=sK zx4hAqt)h${&e+6Tm{SHaHR6O2Xap`?fPV2KpMy_+i=3s^$iAsH*w^ls6xTRX>o~Vi zHShH;=TD8nsom5e!}IJip^iQXTd>;5o_Y7TGX0ZaQ*zp=pL-&j(WZ zu-~-{5vh)&Y#1Hd8eOLiP?YW(0pJH+C^T1mr<1Dl|7=#l-jEglR~74CD7*=$I|n}I z{ct-Xd%F0R28g{J53)c%!w2g;qNcK?+<}NWb%gQvG7nFUAPOk~O%4b7wyl39BcAeC zbpMV#?X^UL(p+4&it<#ahpX$q)^cizE&#Yi(c1@U*+MXpE2Zp0`M6>-B1~_&#fXcJ4Y+OJMFTB0D)&&F#FFNBPCyEwh*y^ zb_8%A?JsN#@@Ok8nlE(F&G9dV=um;s8IR_+syq*LwDA-nGMvY@;$f~z;1!YaLS@khT(A4}SGWZNBy5fZVdtG)_g2^-aBarCrf zrQL3aApmcW?!UUePTF_PosL3Dj$~GY7miKB%cFch%^H~`nO}Vv)ELzHRYdB_0iN2o zszrvl!Tm7hJ2g!ZW^A>USAS`WIzTkICZ}t}yHg9-C|Vr-PM=d5tq^5t>icp%0 z$5k#}i-1i3QEACE0cl)Fvc%!SHmF*cmi}GWYg3S_*u z-(C2C?naeng(-gYZ~qdm8&+n*c>nzKE=DV{XqOy&auPYiDfde+@cPrHWaT07X%GXv z323k_7!#FSeDm3|xJW{KF*CVO>y0dA;d&pc>K3$(_dshYY$QM|#Av1INj%=B(kAZi zu=Xiu<;cS?X%1U>dcXv>SJCUlut5N`@O5^cKZ$=mPc);{{%5^q*&+k?3C0vHT90xV-587RFjt3|_N zAQlxml4%6+nl4BB!~bs_wz^@9UyU2xy{9L&WAj@Y(XoWM$Rj@wFlr)MQ$WfEwXDup zxj;%E0t$M)`|<*`Ou1yE-dSg~qSX`x9DF5u%Thak(`MP*8#T~IKQ%{*cTq2-O-zvc zBH`MBr!doX^|`slROw+`KPluW3jt(+!YD5tkRE{ml)+~2^=`*JS{&`Tj|=B;sak(v z!MI}i{#{5Lwx@MaEtVayu&(lGfZ|&ite`)Jsoy4NZhQa2z2}l6hn;N<6~Q?e2yNQ7 z4D4f5N#W0gIaOQFWybiEKk$LOVQ~tRmkyIga$P;KXeh6)ZTfR}9OaOPz;o!|=EXq6 zvKzXhDa=R72v@^(q+b^e;hRV&bJG(lV1g+xKN0NO4}pw<((d1>%I;nXsguwwxNvi_ zIaF1y{P+lxCRuk)WM>9Q09JzrZ+hf-jObn%V$|?$B+B zS5!WwkNSttpUY~sK}H4kDwAtEO(pbGJwp8vx)~A0Yt@u#D0j-+)zgHHACy0#{2A%B zLg@xMVxy`3)0Lz1eT%GKa`_n>=o;2un{9qY z?S-$2H`}A`Q>XCYgW*89(g#GL{iH3Ll&9%+R)`))Vmt0*HJLKg;gcVu6{DVB7NZ08 z1+QRgcWvGq-3S{>hXi3H#{D9~#?{!Bw@6uz-Gi5r`OaMS8chh<#C4t_`2?x_qVK@+ zw*OLRNR;GJsf_zsjIBww_~Y;<#o>K*5aU$c{yiKgpsPblsA8H^m!-F%Kbx`v?4gC$SR^yzzP-a!jeg zfT)79xx}f@xn5e)kz?Sut!Gk{h5le{q0mnNb^(=3nY9{$C&^5!MV+j3Dpwx^HJ1=f z75n85hzd=+dtoe&knqzWAYcFceRVvP?n6ny_knsNV`Bo|f znI&0ZWS0?lU)+dgxeZ9A?NBe_Zu4>7dIx*au_x*Ld^ctcd*lkX!dr*g?4(0f3d`F8 zo86yt!vJ4<%-yc820N$BG=XWT6clqbJfuJLfF(YP)z+0R>o>I!N>MHoP>NY2Yc7b! zlA-_uot73X_o|Ka^A(E=*>6vFH#9ItaPqY7FnVPt6KQ%i6-_pmy*3G=E>UJXbYvzhK%W{sw?+mRZQp!T5q z*JS*Q$+iMqhvGlrS7OWmc&Le%G&*TNHuJC2dTEGttV`SVL3zWX!_51NMq*MKFVon8 zQ$#rnfa_c&S|4;l-GhC_LE@yQjW(AKYD4KWu}f(8)MT=7u+)dq5#_+(awhsHXnNxl zpH@oRm+fUu^EqM-)o221_2Q(XON~dENG3U!L+5o}YG)N0ZAkLPA}Ca}%p@gch8MNK zu~y1n={$DLW%czJyJcBs9?_p#DkvzyEWHbO2VhNKpf8zHsr?=IQ3R+x&7;T|r_$>` z=@CN05I|whXMT#NdB6DAL-~iVng>aC30qOMPK?R5?0&|Q`*6rDKddkwa09ZnY7_7l zlX2I=tgv9F*Dj`wN&^g1V?BJ;M)He!u|a4WAZ9~q>Z|H(zqnbeXk`lLQ<}sYkqXm* zo42gb%MBt8)0hk_m5DB9m;Md8AG>jF;VE=)+&e$_Cq|)N$kPH4u{UeK@htOu^2BVJ zVg4etcG(&>|^bsl>pak_EsNov+(N_2a}6k`M_am=V$kQn&~Exwg5jCJx}R6cEl z7)lHu-hE4{o)%pGypk^ii)Xk*oNi+;XO*0X#v+Lc=Z64DJLrF_y5Vlu^}-){{-~P? zyE4!#Msj);?E|Mpos89kFo+}IhU%|7)fr?Vg!L#iEO%+zj3dH z?<#KHYQ#IXvhK5L8D&BzU1BQ;uZ4L}(pM#0hQamO9gdE}FdU_U9Z@ixzJCk2ZYi!H z%xl1Y$KqHVB@a{PgiAT1sdQ9u^p>~i;-A2^Cocrc=i+NWFf{-A1V=*B#v3tigO?#8 z`G=t}hf1qX8!e)IXzSWztlXh!yERy$2g;u94~&xNala|PADJ~U2+SkhLsDOPtEC_% zD>cls0)<;3tRX!`a*mXqj|;_`ZneXEKrY9KOG+X%1WIBc)c3@|9rPy*^g(5)0JShszKidofT%Np;xn35`JyW=u7h`S{aU6%Os-qmhryCjlE z(Rnr9vsPEc$q__2EaAhI!*3b_+%TW;D3@{cYU4BE<|o5Wdm#=y_r#XV3CPK)02#KR zBdWus6!czrII4xsSIX3uM%!?L&-;LJqyciTc;;xJ_hx%XLB$eRa|27P_u~@`CCh=oscauoHk@ ze~vbyI4?+phQK6*^(!!8XbG$q@-#j}2&O_3`O58sFfv-3tI2dzGUBFiLN5uiO)D4v z5abck+_cGwbaa+2~ z#zL?E$fvqc0t6D6;$t8ZK){jcqU^-=vZRiX@)PI3)ACt$IaI9$i5i5=TpB&9>>rd& zlOJ!NNnUK93kP9XdV{xaip!%-ZHnGD9_rB_oDVO&|D8jrfv;xY!Vx&tvH@C7GX}{M zB5J3es8UwFo=5(1CB^34Se7$BIGf8CJ*fFvBP0a}jEOA=)lAVyDxrxlQhP#+mTBbV zDEA_$$KB@EK8AkV@7kPhdkK3EK_v*i-259Y6V5X%6?<{~hbsAZ zf&6{B0=eaaycM?Qx7OzpB`h}ymf%ve=zWRCRzxS0l#q*~X_ih@;I~t$NGepmD7}PX zRzs^VLqNs%^R4D~VH0m@9N>9&BT=1g+HLSAwy8VM?6@xSj-!&uMOOO~^`ym7&=@gD zMSS~fk}S|SvUKUfBK3p4vKerN#Ms!qUY9Dvbz6%#^VEe2ETeUQ5Ex@cAqQFy6p7)I z%L4z!gGw_ImTv;02~2SL=rwl56yX+NWCJu{%q*uDw@1P&Q9$DZlg;GT zguXvr3mWog)&kr^?>|c5JQkI zRYfag4&7K^mQj}Sp?60wQw7P$c_kW17J>GP9Ec!*7qQh3oFUw0npKIIO-u5WLiDbO z^NJ?mEvM^v$Gux>P!!4fO*aGnBCvRShLIg>zA!3c8aaqFBCIc>8GG}Bnd0a>XlKn= z(Fq*QK_Ab<8y7Q%&8Ubno`^ITOl7H|o~MB*&UisGMxUWj*qQi2OF`Mze-#ZTV7f;< z>4%AJ$iq`WtyJk9wjz$r*E;Qv<`cNGVnS8q>46Oa^QyLb*|D=xLJM}HrV{i1Vw1)f71U`=%It9Sn3kA@F@*iDm;ENCS=(7Fj+eyT@qZ~ zf~y} zZ@_Pk1d{~JC}mPg@)#)c{+usYs#$0&k8Ny)@&iHv@Bp`T29;}g z_lE*EpKC$BT>}Dwx4XozGic(aaG_2jplRP&`Q{ys2Ai3!U2&NJrIEY)_u42rM^E0Q zK!c47hA2Gveg5pr2dN!N{o@!F9;1m?=lKBH)BO4F;_;7-4{ZkUSRKLnu8ew16p9mo z`wE&k^a$fGADeg>B-Gq9uQ1&_YlrTRrB^vzcBnuqG#!=*s3S;p{8)n?YLGZf7^7(! z7eRuavhTSvg=nZIKvihXq!Px30QI<5x3@+W2z9-Iac1i}WX%GQCcHh<{_L zV}H2UQ&g3Mz1=_F)a?9GjGu)s&&z?O5M~gV*!{T1 z`m3s3A0?&A@O6ZKq;j5p7e5XzM`ow89#F(B<-!20Y7l{w1_*`v;Krq|)6AAOtWmgp z$v{N{s5B&~ocv-{rg;DpS(%2hhHDEw89d?((vt0=d`*R&&L7~%f-cLY$j1U{Z71r+ zx9^q+c>@(eRt15~FB|y-&D|iR&SNuk$7T&^LBNA(oO-NmgUZZ@5hM{`=y0y4uYpf>n@KxqpMrb{de<}-_}`B4Sz|R)5YC8t z(XpCiZ;lR(J}2nBXI~xl6e4fj2tw#aac?{NWCK?@?ht%EQ3U;<0|ty8yNNAHw9YFy zg@hO@|Lo;9+yapI7*S)H^maBwx>z6evUhw3Jskoe#Vwv0OBi1Or@ONz8<&wF8bAq{j#8DwN_c>b-c~hny;W}<+#_dYOZzix z)Kbi!1`4Dyl^|`dy$gafV&idwg$A<5OqEQ|M;P==Rndg7)!8ip)5SZi27Z0cfB@D{ zB5>uI2E5YOYdAht3;UY9eHd0JD*B6|nj1QGv2JLz)b6gwpk^~V3vS-TTZ1CZAJx#x z+3&e57ga1-JGoi7HAr&9X;ZA%LJq|7pILy#wHOu>Bxa~2;(uJhlNCP1g7e#a zu{y=&s%pwYnY!k;sHgLPw<@;Ue`%%_z&I*d{%aR+w{a%f+4fh9w*+86cQ!U^6GGIx zobJpy$s5$u7@Ndh;D~S)q$w9leuo0vXkCn@3T|mx9#E6pnnHC);b?*E>TvyNvV09`ABdE zIL&8yQ`(T$Drl4Ow`L~S_{0O+w@6NJ%<4$aL;p)y*L3^%U*(OI_ zx9VUkxXR6ZLu+7CL_sX_^VfUQ7YFE+Ddc5arbdo)2ZiBg5W_>1tIz_A>}aQtnS~1eUVeUo?ur6858& zBZlfV3={P~tYGWUlu#<(f><~?aZ2>G#t{s*eXn;nPI&COQU=YP_rVk5#R%Pr;;dhwoMc(s2H;n`hz zG@FYOxx~Km=01rsMuh?rf7Swra6FMXWaE+GSZ3?sYIEL9*g>BLk8S9r)Lem^J!q=l zSrV)ls$s^J3Hc8XQw%YR7^lXz2R;+1K55=1^6?JA9PlLoD;3aCFG%@wrEBo@8j}X= zTcS{4NIMpztE>)AUOFKo$WQr3y0#B$ALf9DBLldE5+$9zfXv}ycwDL+JWng%i;eA_ zp3@CIeXS`B3_-rQI(Ena*{~8q0WaiQZqIt6;X!~+($ulPqoVCa}I>z z9_iVe(`Ql25OB;R+7J42_9T7Fxd$)KbWB;@IAv+E%uuu_+8x&}q4EZ7O zF2poAR3@}nXPOp<+{9H%?HevTf)rN%`LyJ2-3PSrycTS$feiIaFly6WF+*EYhmWKYP@K zan<{$9>S0d!s5)7f4`*PkbntvZdEiZ?6DeD1`SegmtA;M3F`~w{OXR8AOPYynXV0l zIXN7ZmvO|SYrdB|x_3BeWoU$Z&f&nsMEnlD)0(eHFQ%URec?VSkR|ymo!h#E-lJr+ ztqVW|xZZ&eUKd}{gkXCjsv_ka9K^W+P7ob}ByzXc0vzu|`M^{cqsbLcck$Ub1Kq+C zKuwBrO6JZnxiiKbmDW(8Nhm0su?pxX2imF>Wm7zdcsViPIK(QlG&StidDNCdu)#^% zzD#Tn9Q|g@mpBwIgjjN6XN(ikyCpuxM7!tQtQ9_=R#hw~sYJ4aY(@m@w&oy_2tX%3 zR%6njJ@7~9nGf(5P5zg-fLz-80b3RB1e87(ZGl_P0u6jwbC5Fyy@buYxGdk549`h= zBfz);Bec+4V2U-TWV2Cex5)@#%|YDvh)WLTm@#~9IeMkpBT24XfPmv)_%W`F?HFo|A1DIW11HGD?%MDz8@}9=7cCT z4go~aO#)HZF#sBK7MQF=ef=IzzN`Moi?0a|?i5n^k!TGJl1p7VBvnb>05kMF;%Q*s+gJJ`+SNA3xfb+R$M3QAS8 z)F!f?sZk<|(OW_{CGo`rqt|u_3_b4VU)#i@?Jv#V&gWYo?>3h}YpLU?L;#;+YX5<= zy{ogk(q_e4=9^$Y{oP?;OqJ;nwYwx1I8t3#TwF5>cp$egK%e$nTrhapHIkBo%ogmx z+fdZ0LWJ;8;|eM&?Z&WvtY91*Lz+`0TFOTMyjvk)CCSrFNMiVq`;PEY;B&TsyC|$U zQW+(|5sDtk%fpIBu>Gw;`LfUQl~uGgrA2h=b`hCa3W=ZhC&L!WGSY!Gi1c!C78(CvSZ{4#LaX&$S*ZjqDdZD1OV{V*hEiog$>+);#@?KN;gKBd*>WhTo`1AJ@!c4`AgQBDDCg_;NPmYD5!E zh5HNqI0|<1q>J14Kg$n;54M0yq=~mznik#F*;_MyOf4eV$P`*NVE)v|$x@+|16P{C zIH_j(CBEdt@D27&;vmP&vOW2KUaYsvB82o35{G;;|$klJzj)N|;6(G8FNX3Ad09;6X} znsP6kaiDkn+MG{l()Nub#%Myr-;h)kB8UiB=*z`d@md54Z;Q!FpTgB*C*S}t1Sw-v zYOj|DYh45IrV|7arKEY51gPWc<7AGo(nAuMd0z-@F)g6U4Mqpp{XUtz445p~RH`W}g6~>wW8jy_Xb<5mh-L)) z_5OHfD7BLgU7C)->Tx33eYc0M8>ZnGjXD!p77b5S)MSIam|;W&l@Q?TWP_0*iOuCL zcIw!m^d#dZi>a0RtP0#UeA@tzjkd$`D~+%>*jZ@$sXbpxpjMU2lt$RVqS$qLr9f1Z zdK97NY+V2uh!$CW-+(rf6HDxelzR&rV3hP2Xd7n<#l~Q@nPZKm%OaIX9@oSy5I7h> z9;TWUo}C9#Outtgb?OJ)e&U6g5VMbE2x$%T9^GtX@ZiFK09hfckp#)pq#d7$t+hPs z3J1ho%zV_k>|7+ga6`147h69aMYQBI4SAyL=xoIQP#BTXamyaX54w7S*r7q694Gr@ z%UH#x0tQg%JMc7mt)F5FzN9A4L5g$%=jWnb7t0mnxqEfE*wPY7fkAR@mQ$jW(~K|o z2HCW_->}w@vWNm-%08xck{&s)hCey2m?dINhxfU3?~TFP4Cp$4fcD7SqAb+-?0# zERW~rG6TG8_&3z&!p>8?0KJBG9}@7Fz2%NrshM%BlF^*-<1fa?@08Rq6;BZCaQrcd z@}a{)EYSPJaJ)>M=E%upNS+m-))+1~oRW;RL3Gm=s1-I)O?Q9}^5Hi>qMMu)D3AeN zlYNl{VM=ne97+eaWZus3O&0G-M-w>zg#|i@vd7B~9NerZC*!GDd9bkL;b|51PgP*a zr`g}eI93#4!&LU>(Ul7D@9QSqSQiGnqXw$1hoZek$}%DPmJIM6XBC=MC}wJgcG>`5 zXxIa}m?bqE#e}lU*_nxY(FHH=1vT#L=?aV(Xb){#0bT-i z#}E>n1nq}B&-0PH&tHBKg0i0s=qQN!;580X&?q2c`$npDsTOxR{rr~N);~~b|IJpE z+&WiO%Iw#_vn93{R)b_4LHo<5M)=zIWb!#Q#X6=`XsD8(#>Ei`WHR{6C$*xID zFXwB^#7wZ8oJ9SZ?OyrOuQXdPrpU|L;C*IIEF3#)wr>oxhZl{9S+b+8;tH|_Tw_v4 z+}?yYoWc?owtjzi!bj&F@Rf#To~T7~rwPiDA$CVXSV8dZ{X<7OWYyz&1c*6)_xKO> zt|${w4m{$AwI^@MXQWQr>y2LRS*4yrG7S%oj*+l;-SKsk8AV_H8wd8#aT203Hm=Nt z6fdY2Y#8cE3&`!b(s%!MrUEn((g|3&5whz;;E5(b+cydg6yUslwFUJ&3SK>NwRRfD z2r$Ld$o}$?x;gH|SyK}MtM6Gki$-ir+&cTmaOxEeu zGE(HJx#mqSSZ1ZF`s%P0TdipJJroV>O8LJXo6eBFdC%o?Si#V#pn%x=zgO{C<`kiA z!;c;EtPZ*{%n=hr!?&%99Q`?j3_etKy|lD~Hct zMA3O3hp+IAMAz)6vcgKy6f7jGlxKPS-R>ADPj+Yek%4y-b+xV#kt@_k+roiRmyJmu{)N4<>-hLdKVfHi-6-w-5U>1o)l zI8RV1WxNe+$l`C4$F`?PEsX98lA0?@w%6M{8tJfi<>QgXo}>Cwr(lny8O~4{+C+~C z0-aSJL=w{IAtUfRm?1;kGk=hK3B(Q1(k#6a_~zz|y4j%`JaR2YjD~f#X3w1nO{@@s zKnzN;x+cb1hoFPv$j!^?3$uUYNZ97bdTYO9ma}2x3;ibYW~9@j61oVP$>)AY_hltO|gW;D!wA8A-*|UDLGY| zYSppWDyxu`C=r@;NG}v8tb_-2K|2APuv=|K8wLcuLKp9N%C_A-CZf}=u(#|dXVVT1Kqh7*;U)(o&pQq6Ac%;-=369U(s0a9FAv4EoU#_ga zJw5Z0U_^!!xF9k5W1k#M$$)P(AC{%1cC8yHPBqwHNYz~cl0r3;cNdR=^I!zEW*OOF zHWUo9;b|1K?1Xm(@^AtQs2^LOyWIG=wWOFOqe7g{3!o@T-vLZ%=i>(;rpjYdS(A(B z7$sZUMo4M(6v+c8QGyEUSZ*da^HUy+iYd)AkTb1gDi%KPO+R{!6c|Q{6^$Y6a+K;; zGIEot=+i*jiUIjO1ycJtGhJA)oIRA*&dIA6lAmhn)A`Ngbm8+zU7TrmT?=_FiHI@) zdP5sf{M{7GNaCcExMve3V+P9>4-|)F0Z@zV!oV>pIZqiFPTMt9K=xcT))$W==B=Qr z06{jMm2^tE8h}Uu8sU2o3%nm*>fOy8`40_44c=T}4K|dhRbuE534y)0wX0`9&G{iH z{2}aRr2ldoZ6^}7tPg7Pndy!}UEA1*sJll{lh-_61$UJxYrun^Snj-Z z_5NBeq;}vw>oa@eoDJgiWx;kS3k5zSmm3Vjhwh||?IGMWu@9+R4_E|qeJqL|en$eV z8LMv#Ht>}$$HkBzv1;_*14v@~1%FTG zR%~eFrus?2DNxIS#|EpD&pMPe!xq07VSxL-G1S)l<HuKb>_DLxsc zg?9V2CpT+wo1lq@N06Ali@Zp}4G-aLDN!ZQ_M;j&aUru76!mDnKdUlCI`66hvGjhU zSa6c6$FKx%9z*OCYi_rmNr2Rjf6Tmxn7XhiPt0pyZn!_UI{meAW{qlYLiR#P^`r?k z3TcR?B3%Gou-fM=*0!^H9IoVfaUcG2l`TEM>3l~^*;-Ny&J!FXr=z4=rj>)aXEmRK zxud3Kl^KjT6uINY++<0H#}a;*M@Bd$)(g#BJk0+6@WS#G4Kn=L^RiTIje1C7KG)wF2{=frippDv?-7n*TIIZ9Gci6?)Qu>BJE_S~? zpKl8#A-}kDfQ9bQ9BB~=p3Wc(8~OGhb7g+e9vWz(;qt5)!M2g-b)-AqNvW2%Gp#Ne{O<_virRD{HP{Lkk+E_e=VwOq{*fSKQ`eb18HC!OBUi5Y_-o_jQGWMr7InEUe_#W{AGSBk8fP?xs#1`bjmo*;b=m_61Mgx zoQWJi1r>mpPP@~7s&)3{!dfpb=VX!dZwzQE-iuK0c`d3I4L zaPsVkjm(@PSs}=d?Ly`@fb?(qQP9-J?Dwj(A%AwN9{X9jpXMbCPPPe~o-ES#*Vto_23dSNJ8rtxr_zn8 z6_S8&ABM3(HpPcLTMkb(O=fS0BkTx%Jm(g9s5n&@r))J{A~98Nh6Brgq@j^v+kD?Ig2IiN21Z+O?YCjw0#4B%ucE>*~uc3Pr{eZ6lSHI0kag#$$zZN=`lh zp^=r(I4h6)Cd>bHZ;5N@X4CAN&K_?0qIDfJqYJ-e3xGSaUE;YheS`Zp&Ti>n15DP% z1Q_Xovw+QLmPQcY`P)jrMYJO7bJmn-2!F2oX2Bx+i^!mG?tZGOMw(b8xbqLPIzv}5 zBR>qR)jN`1fk+#^?m1Tv#8QP^Ugp+Oq-13fm6Y0ZNu^TU>Ez+_H}rja=hal&_$Lbk z6TExwrYWSyA5ay{5@&ZlKwQ$hox%SU^bzeve{V;Tt*y6 zaPwEm%+D3>sCq)i|8kv&fP{s&A97-d#Gv83>R$|V)jx{326`42%$J1ZA(v8MQsuaa z^%{5(`Z*ZN$G9AtZk)IiZRv@6BM9B09u?9v4`$EJ^@H#J4gR7!LbZ@?IF|baBZ(kq z@6IQAbqK$IJEe}|;m7<8DY6vr0x zl>43#60CpjHY98qK{geZ8$&MkaJlPgMIblHIhTCr`EAGXqlJxxMr*t&4>B0p{!DyN3mLgL$5l5(6#V(*E`;5ceNPle_Bg{Ez+Zt7rVw<1)_};YTS#IvQ$jivre`4-qzs zc~5PFoN|aV@It+HXI?=?6V?Nr+G%+gl>z&I&ccnuo&x8)jn3vOonr2_x;X%8zm3x6 z*FEa(4`Xwhd+gscDBj_}4751s=9<1)vbpk%Zygc}VWN6__n>ZcR2V?0Fl-yHM21!d zGVurm`=<=Q7o-#X7{6%)&(aiDOB|4NS5uwXm>6X9 zvIyScyVRLuKTAba@b%0~RIpOYtP#$KfDoly$Z=0&V}5+tVgX@qtL>@xdv@xc^v$); zzCkw6i~>y>V~$pF-ys)HV=lH+K{>*CGe{O9=1z$OaL^kBfjrV(n4IjPraq1w>x_FS zdecPqd}UCrxU1$28s*gd4FC;|^3oDoH(0;EQ~NW}Uj_77?oFPkB zy*h}t%}$p8vPT?uP(J7Tjn+5J1wFFoz`Sky-U}zVCkZ4IhWL;PP)%jRLW7fe(|Lhi z;qNDVPbch|eNv9UGK|hypf0x}O0V0T2Hhpft*CiS_(*b4o+g|JH)AqP)elf>88RUl=<8ys`v!Tn zxZCN<*c%e;1gttY0y{iA*B4Ecmx?A=qRsTL3?i*>vT}j`AwZ<+9`+g>_C!4^bMv~N z;MvZi&5<^gKcM@uk>934;VDsp_~YX_Z^X?SIM1^%^JmYO@rHRi;|MibBz+cj5}^#x z@(9aR%%qdHP@TERgh^OXmAqQ?7?Pnuh23k#m*yFE5`*Ju#>b<6VTdc}0-`sb4E0r| z7NR{u*AGYTZNgrc)v3q(MO;fKNov=#E{N5jzF6OTY*a+>UCI*7j)i7>x%N~2xCWc( zEoj~3xUIkm@pk_&_gmkjp0weso~zLdAxsHsYJn{yh>UFK?6OPY+U_zJ01u|*ux*)a z5#$jZRV>yR^k(&R!#5hT-?;6!FJ0{^YoA|!4=klP+*5uI6cph-@3Xy+#wGTgaX{Wo zMMco_R~DiPhWfN-^x+zxSCDNGFb`F);~gM-(V4Yep(2662&PyJ)FK}RaC%oM2~w$| ziLJ?hCK>mSj3d2%BgB&ThOAXUDOl_N;#ee|3(w~w zr*m;fbC$yi^V12#lh3o_@Lw#Zy7-SZxLjHMLwZ&<{{gPvgWF-SZc6tzD-`}?RIOLT40J{aMAOP;kq}G_7?D~8135Av#(_p=S%fm_qj|az>~u1=A*S4n z+Z(b0EfKKAWkRLTeg;)qwIt9~L4wc3l|jJ#%f(`R?DFFo@Znte>73=s%-{3jl=*Zj zJX^423ueyCm0n4K$_-Foc>P*;VC1j$aE+@@K)CkHHRszp5-m1j85stxcCX{$`F_HB z-t_!gK+*GQ%X+O(6$O<)w-8CEEYn#?`yb9&o=!oYq{2_fPL7UqIDxsJrB#@(lKQP) z+JAk2GQp1?18#8lYX+|?L2R}7->R72&61Y3gDEro@nhBe*8sx+9K?w+y+PzgTM6yH zWuq&7f-44LPXx9BWWWL)l+tvj(V^aO`73|qX|8RE@FEB#p%C_$n}MozSj+yZtx!%O zDhNa|V%#C>_6dhQqMbg`m65@{ZDV(~VjS&|>1_?7=>3K+)%q*?4v?ES5#~2TklDs{ zEiM_Vi{nDotTpXyg{y*qxx|x&(8-M9aL)W>>g36c;UG-*d^)Rx0I7*mLE~Ff#Va}f z&>$#h?Sor<=Li|uVAfhH_}2tLp8@cWe&xy3^E_vfj3u41%qHSar;eXaoE%J;52hL@ zvK(eiC&xuGZ7lw0#$CnW{wlcnKA;-^+*&0Ggg03k328Rq;6RmS<6)=M@1fo96YTGi zx!NVVY5}hh!nObo7@)(5%0ORgTvyh=^|O0QWaVVv1_3XQrDV9S{1x%96dKo56Cs)y z!FC9fKAB#RaI0%*w@&t~FICyoy%EC&;jM-!IEQ{ktn&{DK}R}F<$Kx?so|H z48j(o^bwT8BB+a!eonoq?tds>)jk+&ho~LG&o#c52GRAouLz{7u~2Oa^micl#xhn>AG&0+D7c(4mQnzbt_v?ysZp= zRPd)hVU-e|I{W3qSt_8J9}WIA?XN-MqluG8bKrwi;II%mjlj(RcE6w9JyKhVmk+J! z4guh&Wo@@64&KrGd-ZGnXE$zOCw5VXsYi%gk)bPH!2N_^zeBX6^1s>wsO^8c3DreE z*Jkwc`1oDw>LJ_ob&D57HLf(LQq}`QClOZeOGsL2DTpB#uMylY00w|CNErtixqc*3 z7XVH-s7k7(wA41iEm~YcoL3(^gR0J{wN2%Md5><@RNvVth9dL1qwyT%XfEzx&hl^u z{KZ80Q7WR}AEh9t8N)0S-wVzQmezP3*RhBRPX8u+Fl z8;-RAjHMZ4sAMTwd- zp>}Hs51sHRFZ2e(|Lxvh|NjV(yyr7GbpuOyiP9qY0<@>G#pMhDH2u2v5W?*bJ&U@! zDsDZvRhVp)GA$;jIUi314pN4PGY6k9m_MJfe3>#oUN||_ARuL!r!09YER}7l?xbQ= zQ*~7p@WFt0{3y8xp$u3$$Sv!E%LS^%*F$wI{lf1n{oz=b8(Xf3--a^mviCI=PWuF9!|v_OmaSUg_+>?b_)OSAF9^Nx;lIPkahrm zF&M~(vG{yY%izhy0mUc9BP!6u9Rs}%;a1;>)|0RF4ebsHM?FKmgeZ;(tp-BMR0+ol zG?1;vV|6T>Y3w!#_?lQ>{8vzhSyYZkok9Si$ACizxRncRcZs+AB3nEh4&K>^_{p(t&=TB??{(SoL%BKs*hf|S*l=)#Q z?8m`e+~*UKFUL-v96NLR1<~vPATxLF)C#X25ALi?wl{Bryt`Sn=Q$9V-SE!M3+W_O zfVD_Tk4$esX4ECx=@RbtQ18vKi_}RF;|Mf66@=;Pfmpg*EJ^BK(Y};I^k^54uZs&Q z$^n`Y5RsTgx&_OyVA*j9dmTepcCFnVc4_Md_WM8C-A%6l_{Ww%{7|(>mt@dzuSP?) z5RrC|lzJ#DqG&Bzys{jCvQFv+xIVrcf`O`j+PnyIW~~JK%kuD-zs%D&-dH>`r`QGS zI*u1?Bm=RWFba`QlSslqo(Z7aN($d=R#~HvDxF-(ewJ&JH<<}l2mQwPn3WW26@w9Cistngxh~wtEIfT~hTwt$2vlLeIGht2r^DOluKYTP3d6F?dnlnC3#XVez`@#`D%)uQ@aX6XI z=lP?p{e1Z8FAE(v_4}yAdDaA4%J(Jb$pNnqkCG`K{-(D5oum4|oHI_kZ-N7cjE1YU?(Qr zG5}jqApP}Xgd_^?Mt+>>6X@cxZ_mwdrGt2a5O)kGQlQ!f`V~gg_Xfd%-be~$?y1O1glM$A zr!)`Jb|0Vf=kj{5Tm@?l;Wjt-wcw>yt6-`u&q&Z{3W7~qmMND_uhA>wGFG;~6dA}Y zWf(6Qj#GgHCH;8sXI}grkvX+c`KfnLMC!f$ypUudA)<;?S zd;9yp&vm(?d`va}Z@>ROee|ne+5dEQuyqxay=x}Ak^o*YfV&o9WC42?Fo}s&bN)iY zOq+fB=FtcMmKmr(!t^6jc_>vW$sp()(n*Mhec%+-BLKk}bqcgFn-GjAS#kQ21^C~z z(?^R-j<2Nypi-LZqkYaAEdTYVC)3{5BpE=SY)KS}7_!6#vMCTbHvlOLrofZ}!AJx; zqU}5=e)3gbfPaI2!4J9ry63!+if+aMO*Zxg+r(Fron3zRl@e(a4UL7yLta^}Zmp_Z ze5*dTYT{L;o}Ag~#ya@ti&R=W(PiMlq_&-W@_s{ZpEbbtK{P4tNC;#-*sjB^* zfiC`)@5ERA%*xcRq<(LPa@6WLXsYz-&hF+-d;d4T=~7pEf6ooB5Xz0M2w#f(R4G)gg^v0_DC0;ww^1pzws4Yueds~4ug zQ#O1AXygC#(@)RsfGXz>-#wz=KGQh5MT^T4090*RACwlF$`kngbAIp5Snf})6 z7?Bf0h9xd&fmTv-yqFWr1ZnP&7c96a2uIq&V|7guNh-FvPXSbw=~8*kFGyuAw(<_} z$=HU{;Ho}*kguu4hU66r1eyzfxfoTP^z|4ZJg5tOaH`iGzt6T*T^P#4ST;X}BbAr8 zR3y&@rWx>f!7|Q74(9?-7R(0=;m32q$7unN7s!V*2Tz{>p8V@a)0vKgP48PXM_dMy zy8Hj=1#MZqi4OpN0Po{3-kadgJNH@c`pi+iGOyGU0PxS8OSp@pL2h>5Na7m~-5Xm- zZ^neL8-S|-vJHYgZ7mOfv~1?ng=VeYukq6sFB>)cu2F*uk$&hND)~TVCP3w=MTtc^ zlsiU1f>@AIDiT|SsX=$dY!14TCF#X;AAWrECcXQPe%S@M|5daoTeNt^0s!A0sM{>K z>L+S#5quUi`z{=$|Le)tB&HA#jF}e*i;hs{5T!$87&uOp zv|CjGy;@9IoQ$ve)r(zdSyQ@tW3nh2!}SDRbKDiHfxr)X0Um12%P!v}kd;0swy%+|dd8uH0UB2|Vkl%8I_RCW8UU54eN&c0F2 zJB8Hm(oY;EgS}ehmzmmEHD}v6HqFJqV)vInsWwqnCP0s0gL_#Wo3hzbIrPESccwl5 zs^dQw`Eug;@l51V=HQ`U{JA*D$R5f9Pco7DK?doglaobB9XJ=&`@4PjZlyo*LZ?PE zQJ>7`ILI>mMM&A6i!bxf;$x16f@V($Z*&CTOaR}q0Ix@g*AQV3K?Wii>oaNALl>Fq zO@%<94kBwS;fhT80&ly;^P|~@SC?G;u0N!)LVI8^r1G30{fT-7h=|ovjsO=H=~xQ0 zo1$!Qp&k8>wf#R0Ucs~Gi+N6pIz|3?BcTjXVa-#@c;pt|KMv#mVJ7cGWz+|B60JSb~n3O96 zmOvu&3x2rtLm03!DOX=N%-LQU=62n_7ryaJdYBqRL8RW(*DIHwE`0&je-}8J;DX>n z-CE^;#=gx@cmIow<#^8Wbmr^(^5tp44^LF(kBf&X!Q&$5r)dX{2LjW0I4t0ozvTa- zJWcEGxv^+Anb*&~>tBZ^rGBlN{{xVJyv6}&;`0S{Gon!hvWJY<6T;UJ;WYzr-6C8u z-Z}si4TyBH&sAr>iYu$}R;~Z#T{ZTfTT{Em3t^3~63#>UZzW7pZ4XLWaZhO}<2K_E zV~3Oj%C`DCzgn( zf)NNtf;?s37Ld5(x2dX7qe3i*IS9It?$rc=uVovb20VXJ-&hcT1+eO>Tr>JN{%*yV zR3*F6ToHah_hS57Hx1+}B~{EtWX_Tn0LMAVK`QcS%KYUi^TT6t4-c5Xe3}-IrcxaI z<>K`C|MkVqF~EZi9z9xrP5&Bq0cbyr6D9Al-akhM>6v)96mfa?17N(jiTeonHbDHF zlm}NjZe%gq1%#^+;8g^?Y7zE~ngAho0}mn$-k^wBv;WuE_-5aKi*Fgt?w@Kwt~y#u zTWbGpVo(VvT~+;egxeXwUf$(AYR7~z809*jj$4eEhimAJTU&1fbUrs=N3}gHoIHWf{fyciY=^$_q zsmPFXOSB-7NygYSB$feV28asbI2VbW2K=F04I)6W6ejP6CW}XvxYgCpPweGxF8pjo z$9)Cgr-}W%_f}QxL)s&J@?hRm{p{?q&@E5FfCY%sDSQvLw)dgNRX~|$za2=HF%*T! zG-Wn{%{jvpR6e)KTSAOCvcp4^wy<1fbQxc1--*{{yT+yXqh8*1jS0mx1n z2t02bRs|6ocV2^mygL|(`TaAFZ(hXbAKtiOVLa~5$&O}>I|gJgwy;NtdzNs=3XEdH zZl~0hFULyoL|DFdNHcBFpLiKTOpC7<05r};`9=6D1_A=tF^GeRa61;+?--g~i%t5s zsOx@*WBeJB`S|9|@xR#LU;Iz+-SZ_Y?JlBHerUbxM4!D92dc85Em~aK0HBE*0CM9d zz(K0*pI=JQTog1l)5VeHQ5F zB0UG5;X@W_=_8=l!p&8}FZxR83JafJ*gh{EB9mojVb~E@57fj#+*pcieqTTOLh-`p zMPvQB>ma4RFLx>BuT|Oo(ip8;@v8p)(q_4t3{WvUpVlt7|GBUdDz!~2WrSIdJSBjs z08CSn`9fs85II^nImkdBXUvbs%m+tYJo$|)j{d)=mb#JYUqQ#i7dP+W&Vv^@ zO7-WiHQCiFsN@Rx&tA7^UA?+47^ELPdS?fcfjjN&c6-Aa7kfLL?16z@0o>J=KO01U zx_u%aMC$)aFs&H>O}Pp2cGkGDt@On|Rg15Kx>YY+4ohx8c?~qXfefN$k)8qEjtQ@J z8S@=W&In);k=ZwfQs*!``ZH;AR#vaRpdsczjY;)O*~RCCZz>0GtImGgxv42FvN>aJo1z6|`#0>TR|0xRtN zjX$lQRn7|$^hYYyU#|0h)2}gs_g1J$5vw&3t?}D32+&1NiEf2JRshU0fMXIl5|m@d zc$^8JE(A`d%tsFJ$%5f91vs38J3dMC(}#06egvRB{@F#JQ|}J$-P6`lze88;h%dsJ zd7--hySD)U&v$(=;L8W=$9(MsM1O5-tFwn$f7^8i+a26NH2V(a3Ipt-kgERmEnv6n zrKm&8scW>E6=5WmWb-CAi1BKNG_Q|jBV$3t z^pL<{&fVEC2Y95s0NxmI+yeag;{%Gb3_J6A6>qd?@q@)n%K(%?0PE`fQ8~FkM`WxQ zP&%H?;4${yj&*59GM%CvFE~52hMx2ocDhL0G0JX&as?4m1LU+jNZWq&I?q^=*eIJv z`?4DJvTAQowhxB6p%jJ*ce<;q_={Qtul{sN^r=?bHAUKHcA#oGeBp!ESll-^CR9!* zD+_;kBjrXkyy?&dq0S0{#J3?)-FN{#PP)y>*h{FU+S|_}Ck*(M0Uk5(FL zXTt7SSdLPm(;3USDBScklKFHtTa=6cg`d4F1L>me)Bf7;Uorll7g}P{HoxB~%T20B z;mb^q&%74=9)SJhy}h^x(CbHLv`4hFMX(>CTqQsi``IxB1GSPj0JhSb(4C{csgO0P zxs&ap|NTR=FQ^{my7+7Mrb^uV7S8Gq)XY*~==ClD6EtA0fCd0)k)0z-(Pgv$d940iO#KuvsR%#aDEg~&K#I4J<0X6znkfL|8^p}Qz@o5j7OqFWx;3qEr5P5aNF0f6rM%bouVO`Od(KW|fgSGgvoRq)N5 zRof$s-gqN=Ezi4&DTX~UyE|m}2O!tGMAu@%J=KZ#a=(a?%1&3i#p+PjIpwu(k#?5< zexT&Lc%jO*KpYCc?JqNY;i?XqJw$6zx{kp_ggp|l2E9p{CVNuHFfk@Nz22Lh0{HHL zmDf{za24d^gIFNX{X_2!2J%n7?GN9g#rK#4uxTNyHr8ia1j~t*|1VWJ`svfUb_)RG zpY>q;*LuV8Z<%O%-9(EkF}V>L7!qMuGxvyG2<9Y^Wy}K>=`0YfwgTyHu4Dt(FRJzb z%D>i$o`{E$uhhG-?!7p8YbbmG;e>(H2{Oi98n^{uemx#1{Z6O9Lv|}c*xfR8b%1cahvsUBa5qLAS;Ve3 zom2r*wNtD}qSY~P7yj=j)&n$!cLXejuu9nmUkxZb1GHHvwg`F?(rlRkJAkwhFZz3g z!bdx7PyXis^M4*nTOQ?DW^H4UTkisV@`?OJe{l8c3zxB4w0NaSfNM~;te0!mY^{F% z!TTYShMOM>+_@vl{9ju@d|5zme>Q`~pUuXL-%XPIe>@oQPYob*td`zFNX{apPSe+b zqXJ-vqPm?Di-2`13rdSydM(GaFcDHjqU$yYE~2ou@nK_vl1h{F$8t)b_sIg_{4I0me?` z)Pbh=<;jfs%cH_Q{1Wmn|9l~j{+r44$)Bg`F@(u*h2&Q)@VbEJ`sIfoB0RX`)1)8W z3%{c0{N{3ke31?G#-ps`MinS8C@tw&v;X@z@9ar3*s^A)FStJ0dz?)Yw>->TC&cgEXr4eUM>xSR1rfjZ|^Ol!eZJkJaxQ1;_1DtM7sYm9Nz_E}O3c>F$UUG%m0 z?!!kxK3%?Q!b8?#p-OUEWr8n=!8DfZ0N6od%=U=PULWP!7SZ)C%8dcxZr7OY1aJ@| zbgb&e*Oeae3r3KZ^FlIb#H898{riXJc?qoJkpB1AZJ1j8zZwD59Wwe0Moma$zyRv~ za0P^~A;?LDc6Os57nH?uV&=0OySuaZ4-aSe8l^5>^!)<=`@sdJ3K;L+x`o>NeCzI- z1b2%TmpuSbANVis1N`*;`)VcNGXU0W&^_}i(Zy%>KcuPs87FiNk#~f22dlf+L#GSB z$WYt4h}m}&3Q`w=6GZ81RscczfY<|oJ^^$sd%y4~_FDsOnXBkTc!s)G{b-pN{w6Q$ zHM!#zd7rcI&1Wur4ZyScHMO4=THY6#1IQFaW(+t}HD3goGhm7$b0;_#f~f`o1;9kJ z00qFwLgeIxi^I>jIQm7Jo|ZDH=Pv#g4O-7*@!^M}lb_0`e{m1ARpW(^lK#Q%(%r8V z3tE2QDgsc##^zI?2Pk=~2fbM0(ZImo2;}O}(DfnVjV{u)KH;cCIEWB>k;?ss@lTpL zQD17pz@hL=^0lhaW|C%$?-I{E9~BMAI|Y1udXV!K|qU38vyw9 zZ(}0Y3&Vrp&Wp6rX!?2faL)KnzbH~?XDPC_to6}%@kkGy^ckryEPcd6?Oc06I8f%V zK^YOkmJv)X;!t<|5rWN8I$^SJ?mgFN^P3tO8_c`#=kgL0nVWvC!Rq%fx70w@_pe!i zg2A)-Q{k}^`y9%t)A~N+sgO*7Wx|LvBRF*^Gvj1}Ad{S7ma8uV50PpJe@v1%V3mu@xjUy7+{C4eI=$szFvyx(bm-BLqo@kikRHj+c7Q zs#6Qvo$~(s2nPqaKb_L!ER(AMtYy!3@o({c$G4FI@IgRzvFgrF?OI=O$<$xZel?lM z-;H+EF>neUh#i`E40$qMU>B_I3KxAq83;+ASO%78WB|4kfo)4PPf!*yp-Q}~rVB0@ zO@tU}e~{(%t$vmJuSx+`T7JWuTec5wkPiuHE?P@sUqj4%<@wHkt@_DJ1wS?FNriRi zKh8i-7Q)AwZ`p(6ocSo#^ghc;;pCJFCq{{XOcN%U#m-Gl%!|H>7eJi3Ic7&0WsfE& z*|Qe^=36P>YbhZN*gW4a3f=uzK|rY+;M1yb_13v{|8nskyaVypZ+D~JD}+}@2KNVq z`#q$qeWJYtai>Rmu2ILlmyA$?gtm?O0&k-Qv>6k=sK8T;Zx`$BF=0twF8ZZFR^7;} zl>bTrO!*(42#V4tQDG>_HJg=?@@m*7me2|3SAiG*@wIF7|K!o5>A5dnLOq23WWFp} zwP^8u#Wf40q`eBN7+9Bz=PeJ^o!F|^W{9Vvn-3_t*B##w7qB0Gcf2d z47--LdO(vN!mJ0B7KTz0%#C1SF_7aH%p(ElssX&06K;6F`^BMZ9V~nHD!JW_AM)Zu zP=EETzn5x)D(jW{oKGGYK{l`n|eL8JU( zDH1TLVVwP}SIpjn_p|TN1S*ZKqQ@eZ408UZNm0=1b8PEkAg| zFRKFQcI{OagLRwUkZA_yFMYXy9R|9J;f?+`}BR(#(1Ue#A526657%zkKas@eiZ7rw>2<)crw7CTQNU8u!I*y;dGPz;|yx5V*gZoM>dk+MuAt z_XhU+u=s1*S5symy$|@#ZClgUSPTlqCzV8`Vjz;GB6PV@y)flpEfb4%n z{VcO__8ObakfaIXG$xpKfX0R32?HECRR;s;snL*AVIh@+MWo<52$+k6-C}sdUR6{; zg1WHZlna&1xYr(%pv3E&k5)fzQ0r?b4(o4gDZ+0={lbr4%+KkXq!X4AUGzf>g8rSp zm=WQylhHD@D5HPI;A{OF1RSTr#|!48F}st)oR1%}JN{*!oql{eRn7isS=-m!g@-e2 zn*`v44-h63g!vrz;U)~<>&i!i`||F+5R84e3<93F=DE@?wT>#V&k`LM*B;vJm9C5H5Po~BpvCtB-*pDy3{+j~e#7OhQh0lT3D|3S z9|Q3H#kA;J%Db^kdp4Rc$c+0UCk5k(35JegXbAcY&~YNMhWxtbY8zNCLKzDc04f!x zw!=C*81~tCQNMVrUIdy#)PO+e4b>N^{C8@FK@Xod&|O849}cR$UbjTM`7mUlnb4P{ z9f=g`UqpbcPwE^q6LN&YE(8Y}1ZYox#sCY)IAN4AJJikyHSZbEoEsk%`S^>J$A4T* zr$=G&*JHDIp2MlXpjN2Gzw__ZzpH73s9U?Kfq3C5kf=C7b- zbp5av|Mik%B>}F{dliN2I$d@JkoSQMh9RhFH6$pIC3Qp@fXgF9wgxatlep8N@lTk? ze>5IfnkB0!^*Q|4w{FoppWG#Q?>*4KSuNEzTgkt3n89-EvU7u{OGR=PTfucKo zO&Kx->s=>$6Hr$&?a^1Ao`QgK6e%ZL7-s@dZ3T#56JFS)quOGe!ig zX#pYT90)UJ$sLokz|hkG-(s}oD1N8cgIm@XcaM~3dk(ZD@X04M9=vVfS5IJn9)@A7 z9@yghg6}#5PzM1kMEa8SW%Ti{X1J|?;>kJ(vza^E+Dbv9>CB~Z-ie|DO`I{sh!_*Q zNLzjuL>3(r3maYZQx=;t+DrYFW;rJRDz8BB_nvYfJfmgPA}lpTS~4`wr88YwmVU&y`8@0d_)T0cky*1kWrtsA9b4`k*NK1a;{Ss}*$BA0@K@ja3_uy22baKL zo$Y@{fXJlcq#Ym}ib&xAoGOcDtm!#2OkyD>vzsITq>uTJk6`i3O<8~s0pX*^{+W~1 zq57o%`U&8_UGkjTVb$XMfbTj0P<$=c_%qh8`~bjxd2*5-?w`K~WSPOAd{3HYFx&9mR8J=aGv#6Sp|ATtq!S9Dc_C`jOwQhf>2=T*sQs{+ER^ z1K^)i1%nQjnIG^H(dBNv0aGphHip{o zQE^9=fz~cCC35-*C|D(E@mHT+1GHtycDd@ep1(hQoH>V8CPzzo%@)2LwL6$M68~${6_b|KHpA zRzGNDD2eu4I}$BgT!0@+i_=PnzM`_0U4yI>$X@VD^J<}GU}T3djNb0E?J-B)IJN`k zsAFwBupD*B#0g8>C5kOloDjwls!1VPBcde$4FZ(W=ePgrJ5mTbZ8a*GH5-tp$VJMS z3v)In%yZ}RnWJoBihP<=ev)PRj41zfO2rt))h@o8Y}^YSyb_+vaQ5M9SKcHkeZ@og z?6=d<4tUn{d$#}Y0CbGj{EZp?%-X%T`q8!P#=dqvwr}ki{H<+c-tM7!Jt4R{G$!_J z|MF?IWpcqH3r_*_+zRDl`0TIug!tiH11-J=Dpe2P^I1v-=rvll*n0AgW(73xRAH@5 zxJU&S3zo@(|7jq`hDt*NAASgQ z`@t=|8)Sm3j;0nZzH@xvC%=_8L9H8q>uUDzE}24Sw)}8g+=Odw3K2umAcQ z3`AvMp!ZQ1AbK-NqLD~^EsCPPwQ<*&xNpQpMAmBNJOlw)=H0d2?^36=d~~y-;M`$8 z$D%kcu$bj}c7m86q-k*!>h$_&`P%(go-=~KJwTy;z-nUh-~oDNJ!SWoP;%dRFp!V# z-SfNikC&}@6<)a3zy1g1#8;(gp9j&uxeMLDf_sCG<(+*rd;7*--Hq&xT}wB1BlG$Q z@y!9z8!_SaKGCqJ7EgXF7dZZ!`d>KZORze>c^w!}1Xr3Dc|)j;yTul-7Rru~^#H(= z03&7fmH~kJ2x(-kK|sn}WFqraU^-_$UWgoK3||&3pJyn)&RBl4F!1?2caIChM+cl{ z_p_ty?q|Wn6`v*Wax32ld9i%U}icp<2p;4jL>|Ks@_-_-}Z-EhH_x&e{yyg3}z`0)S^( z#wUoy0jBQD#mXYTe*2p7%ko2Dd0g_}`@NO*PLHAgqT3Zct{=P~Qu0-n<*P3I`nP4R z6ZSP|y9L5`!!FD;-q!{2#y_f>bJ*Mj}NN53szKAP=;^<&jWp%f1$6fX)UL>8Dk{+E!%07FE`88EAm zm=Xr9FcM+3V4L&`nOrXh6YXCvJ8m|jUiC|opbx(9-`8@T5~97v#y6OOYtf>{4`B|h z7nCXsP-zBu=6D5N{BPbA`}=n)AMkJL%AvJVfxquV-@Mrqvn@i}Q=F)NK>424k}pM+ z698Ty@{`$2rpq+`rrS$d_%}^DJ`?bJA5xtKD4`-hAC$fQ6~n)g0gxA&VD((-c{&21 z_uhLCIvS{Hkfs@$t5?PT-_%m|nIE#(0~r0Uc6RoDHjJ*nPSI=I5xucR=Jg)oYeS;l zo}rx>aW_KPF-ZNc*L_`XsJvpL-^qz+{a-bm%7fvny($VAL>$h1h8C|FYR!krZ zeH|$D@75&HdI)JxZYHd0qby@yECgnS$Z-L1uwZ_av3#)r`C?wkmvbkNPAEV8Ea$_2 zcA6gk(dnt$Xm1?i`daS84+;M1KQ%CTVw4xKf6(WH{RdpN32v~^Zii)yOPK-CMab_& zt4;x5|G%dfM7dQhANO4Y)i>YV`>6mns;vkK0Z0YZl2>` zWSRV=oDj7Xp*@oqJWpWz4~JX(iMg_E;_KTM-`Fwctv)H+pKc5c4Z8$=1K3vrrk_;H z59s?XOEt%G2G+=cytr%q(VC&c4sKHy*gKr`VPr1{h`00%l1GWCAlh4 zt3t45^n{>LE~(N?eao+lm@p|2lP*v)5~06_Sd=0`)6H)LeWweYoDjg3|9^Y`5+iAr zrHNtZ-1|Sw%+1~7omsD}>SR^TbR?mfQM9H9A~lg3k^@K_El4m6Ah_d#1W7;(ZMdt9 zsI>qA8gMZUNT3$lR4gQb17Rq(I2qEgHQnr(X||X#Q(e_rm6e$h;cs_){;zwEaPH%` z-^|TDJR&0^Gb8SY@Nj#%*>C^jp4WH2qc%m@qB`Nt$u0Z{9!QWQWzXOQL`1yN4#2uj z0l7Hr75fz_wFRT?1p*g8hwjHhrs1s!7I*!V)!Q%qD|A0F4DabYZujTi7c>1)G5~z^ zP{uz03f{?X@~D#m2uJda`huifhrg3$mw&gHTze}^Zfq;~)`n8wvBqEFcY6pj-LIcp zO$IwAA(0k8iT16qf;0T=L|@QmVkFWh=??H8($$IY?wg3$Tp(!0)AK5U;S8|B26t0^ zC=P+m6eZ#Sh(v&JM@2Bq04D}8%@ER>DK&_CJ5Bfp1FHYSq+k7?59W2yBw7AY{|Q;f zkftmzsIrW24Il6)o_`aKf5fXm416M9+PM|To3Lgq@KOzSLALA-Y~-J9|88&N+Phiu z);nqPof}#D{p(u&U{mSuOS@l=cx|Y4D$V`@6^U-EP5D!p?c>_}T)THX(IeKupOl^^ z;y9{e;iWr3`x8g90^(STgIP11in*+qr*q;_!E{&RUUbWCM8tV`-PXmH0v1xyuuz=?88RflCDkUs zPjwLlNTezV!-zzYFu-;g2+7c=lULO0o_6)F)tqg6&=DFEu|WGd?zf~01)*NwVeWSk zzLUemB{g%~D9y-UMKSsN5Q6 z`pPwxT)(2#Ti3My_T@DB-nLfnY@qsXuJG+mt@?dYaBtH;w$4v;B5thUlGXe{WxuYH zAkInw$69}cJ#jndsjs=&VP97B$~gd1qH;tZv^)WOu1jgD0Xu@5Yvy9kJS&M#3WCFm zdAA0BR1!UEh(9Ztzc7ZMoFE;3zo#Zonxg(^#i0H_eExYL{675xTl1LRz{TC@EJVB! zC4h^dy)J_B4_foCbx^toZrSbuA*IRlzpN_!QTHAGhKTwBz$cx@#0d~1Y-Ne>w55np zH-1)@&boT@+Q$4<;~xzFyPmcG&tMtE(YquJ zf7@*T^sherJN^FF?``+DzN3@vtBJbYSILzO@mgQ0YyAYS=ZM!gQC;q7b+w0ZHAQu$ zr?B6*?S6rxC~SX_o^7sVQliTM*nrOV`V0Bb3I4eJ2CZDFN8aP>3bA{_isY zi|fXb24x)gOfmsPMZ+*{m`4OG(?t&q^Bytm7>3;vd6x|BA5wGlso{hF$Nu=>znM;t z*Kz|oaNGlc>ZdezWOnV@y>{a_(DdoD-J4n(|B8Gt2keYI;qZb;|AJ-Cn3Z|f${ z3A*#>Lb~5maPGK^NDF=Zu6yi7USTVwyf&JZs8zejf{n}zl!xzxwd=!0W}wAAYky_r zsQ*T`m)L%tj^XbRi!K!T!;0~@nG|0_>CN}tHdH z-sKI*uBHIjGOe!-wYok)ywOK>y{GW1wD=7*Zls_#GdtJ^GilqBRICgB-C`^bfN#09 zyH38Jyv}v|$A15al8ETK1yV~C!=nVd&IC| z0EP|mHZX2i8YFYIGa%MK?kn@(Ol^n2xekCe{zM9X@{@&V*L;qw~lbOIvYCPn_ytB0872pJ;eGIANbN}C((;yqe8885u{H*~>HRHe_Y%~l*g|Njyn@noZ1I{jM z;O}Qi^>1G86@PeCh!e1u9Jt8$-&@k^kWAk@E1gAhP5LZfM8q@Wb#wqO(nk1AUaVe~ z9Sdp2aHsv;j~+b0M++W+Td%eOLE8RgB|ZB+`~TL)#^yVDe)Zd#x_%|qZ*A-3?E&Jq zdP=?BQ}|W}_*PFTsrPTE2pcl8QAV}O+AL;W*w{hfM-n8?(!1HNRi^@98SdW?{%u>*3?a+Uy{?8h{vqr6Qg%|rYl}C<0r)alYfM(J zv~JhIMbRC;bLI!F|G2N(d0KJ~ECLC^0dE87fA@z!)W0*G>ML7Y`s2|kpJg=cHOaOH zxQa+`_mz5Ui2AJz;afR~@rN6kg6j#wr4(^E)CzKE(QUL}xl}(HmR>&~&-arAti<2U16|Or*iaiBfc!+ zIrI$sWwpJqi!RylX$T&CAm^=O4Owq>GcF3e$RBnlE4IxIK~?cN4QO)vI`saoe!Ksp z0rua{dOd1rqXOI_ZY~q5tIT{u176P*UQYq8_Y=H6Kz+53aHX&H=C)Q~`}{ja+ET{) z5=kF687aAXt~Q81izoZ7IsoT=?}&&~7s;|zrN{p@SQFaty4e2X$3^T5xgy__+K><8is? z=7Utva{6}9eXsPn^z~LJey;oO@sfG75HkSJ2)TgPIRRqK?>)r{c$(o4?+?$1Q`^LA zTKD894C#^F;q~2>nF@j%H`v&=$Ga>#>CZ8kApZdjhL?M4JJWb6ZD31jb*YbhNnvu? z5MN=ys|xY50=%pNMAQFLhOn6+4tj1vTjy(qVLppKXkqy~;&xk>jfi-mSaVJ-=?I6{ zxYB@o#_A1&kX8T;Dw#CAkucU;aiu^R(5Q%EFhsgKMSV0tJ}4SiAs_yepQvuv zK=(Bt3+u4o=har>H5~-!Wg(*@@ERdz0M5smCwrGI{Iu7eg}YzDg(b#cdiZaFOe|f9 zBDDx@eS(J%TZ4MRjgxg~_;UXnDwr4cJ4vGc zC!3r6=i_l0ICgH!V7t8%`3K`K-u*vu*Ryo$Sa`888TVx`SQH1WW?)>m;rdb z@YFH@tds$Q9>Pbvw{4Gq3onDL@7-&4efa5-|M2(n{PN$~-nj8s``Ndzr|COeTE8l(A(4^8ah-`?!Oo^yqnwHG_Q^PyshCbgj=8K)meDPV? zJo&V)cmLURy8q`@C7A&6)@SE!qx8o%;(&kf!D924#(e|4-%0;>wL(_2k!!A{=p4MJ zi0fv=g$MgsC*syE_3?a;zpW}e=J&Ir(D&!_{7F*xuc6+2Crht>KTEG)OOo$wC;FWM zs_*x$hQGRzB3wxkhAFCC8vBJz1Z0L)l#ymbGvwL9bgBjsrgESsOGrL}8 z<+~dj)y=If^WXLR{OG|0e(1PIt1=?dU`JdCcI5TNUK;VD@S?BIR=DWpUHSUI21u`5 zxsv|Qc$}q0kv2)4CRv(%FYjgl&Q`CtS5_Nj}VB z4Y=J`O3Pv>sZp!y=TrNBwlYeKpUwKa5`XAKu=d}LbCn9fY0_U@@giOpR!d;P@CR;y zt5mu3RZe4MnLO<@G3>=-C51!JfNNZl+Sern+#|lC0Zb06>MLlrwBm$O4^!Ry7U>N{ zwbdsa544&LHn_-pd2vvd<>l$LKKS;x>&>#P;NU$QU)3F9M>S-J!(^RW;%1qNa z;p~vi?AJ{_`(#v1|8Gq*`+PJKRl)M2T?1!fwRcc1v_`&FD@87(fliqL)+?%pQbQN5 z3hNo`V8n~Z8Ks@YV?TH0qlj0Ck8e9o{RLbbUKmo;g>ZQ!<=%blz3$*5fv|eR;G)jZ zqL5Gjk~u*k>23bI+uK|JvyF87d;R><7U1@#R+|IB;Xvbt0vr%5VqVaz=V>H5{^V5FG9=J?Cc`#LM?fLb|)U_q+S_<8y|=B~N{`+rED^ z;+e2G;ZN^BcmK9!5cY@Q%P3XY^Z1WJ8l0KO5}ZP)?el!xU6X)`{61vE3O_F#wp{!x-TOxUJ?iiea~54 zJT2`3HXQ&;qANM)YX(T{Y>&m>ch>^QpbNP0Ci@uh+RPZHF?! zaI<~HjVl-YcsS(uZ(D}|gEO!e(FUEeDXX?+jg7{OI9G{y2dgS}OMi49VDG$lh**nm zU;X<1cONbT@Ys&lb+|sf&3pb}%0(lO*^8y$=XUJwD!QYKJCF-F5i9#8BmejEeEV+< zhgZI*)9YJW-N-_K*=0gd@yP&x4ohZBPGV89be*dU zc;{EjVF-}PL_r3~md2belLf=IFXafyG2#h;8UrMAfc&ps=@0%2=*_p1 z^wQf&cJ)%KZ(LIN)=;ar`%2#!AY988Ug=o{KN0f5jZ|TtB5GOCbekJ>A&IT;dmC>z zz>n#Tb?1mT1>Gdq8AR6LoY`N#G$wv?*#OTghH*uFJSWQt#BY{#S-Wy_Ho-Ugvt88O=JJ0-I-F|;!w-Y*djghPP9CmF${4=}%!CgtTUt@>o@0raXz zRb?i5*?dyr zIU-3{frx1hnEQ;dnKE9^6_0b3%yNJtN3If3l~JlJsG92(3aZq+Usc5*%PKuMsot6m zo6liV`4&R?3F?FKa(e?19e~&S*a|-<-++zmJ3Z`e_b}gJ-Oo@Dw1xqY+E7~eQ{;Z8 zpr=9QsG!$pEenc}`1*i)0|ADJu%#Hc5n&q{wv^QUg`J>e@QIZ>UCSR2`aa(1xQv8U zM%ymwR&~`9sm|(UjjPdmtRX`BUEU6JT%?^vL}5`9^6ZNB``P;g?X!_6_y5k7Q?ReR}=7j&ixzcBdo z9zg%*rs{uJ@n8#+!InyfIg9b;Eo;lc>9e84f?eb?o01XmA$=IHw!&3dS_R`Ov>s<=NUR23 z5LoPzeMDr*kFgEHN*%^mySwB5elcjT1@C$J62Bi}*)m-1HDSA9q)W!9#KLTga2-MG z1lS5ePXTYH2r5P0M}X}_VUZ)0IjT|v*9x(ziA@bqCx*ug;Y0%-<;aJbN{;$zJsQ?( zd^K-oznkjmFYA21QpN(i0A-(^9g8F1cAiLD>_pWAHo*I)6w9b1<^|w;o*wo3!rc#^R8a9Utw>MB- z&TSnZFH7{Gl}+gnHQ?eS^0?4!#-D4prh0j95u0pgZYFwg|Ofl~QUL!TJ>DucmRf`d8m^aq^GYo@XyT~29G z92!#`R#ov!J+CH{wme{7mh5oEt4DIn8ITrpfTh{+;~xUH;xiE!MmvJD?UVhX{eM)~ z_yhTE41QPIYK_4@5%#VUW`^(@T!Tk-tx{t!WybAo0Bs00$q(Z}(^r`>YLXc}AyQN~ z>0Bi#*G)c0>;bC0F(wDZ902>dfco;gYM8P|Gz&%i_A^y(YeDZgh44P*kpiY;Zjr!9!_UeR)!1-LEey& zl0m9vaR8-i1ih&+*9rC#g~KgYV+NQK!km~ZU{x}3p#Tca{i4#tdgHRHmDWwBl^K|( z&eNnxw4$vlF?o`ZQ4=;wvC)eA(}a!yKz>Sf%%w(#eSktKPLss?I}@$>bCA@d=HJNg z^i%%juS5aq^r;qY&}o|@=-TB`E$7Df{k=~?(mYRo1uR-KzX0GH04yV){RsN@^2dMk z^5yMXl0^V;$+>m7E!s28STW<)rcLjP&a0?d+1H7exzMkR5Xk-sShrtt9%5$Y!U2Xo zQrpa^O|E-6smzet%yN|ysX35D@=#=YGf-QZqDu*?ZRz396iB<@ps!RSt$f1% zvr&JbFLjc{V*G0+t88X$XJ4D06n|i$bK*2l+Iq9+_{F~e7ew}Jjd;*b@~vYclPt$h z3bE!F?#vso^*6HWK}U^gWX8aZ>7XRopE8})hE6KLal<@i1knvB z5V%f2cmzAYI;gFsL&u&(_^klDWen`O=L=iM8=q+LBhl|;~Ke9HJKV| zsub0AMN>e|6J;g^vQmoXs-TlO@li^LNry*X@O?z^(AM4a{q0Mj1gKu)?U zQ+Wm``6g`#vDsc#dyrNS$eWC=9H#6S-MN4b%c5Lk!hl)IDl@4{64D7!k~8T%(Fp)0 z8niYICOrj-PLd>3Mk`QSORCSXR;UuqsFjHPS!0dbXi6C|A=Fs|kP%>Jm{KC060qe0 zFbZi1g)B7Ch=T-Ss6lNe3O5p|`vdlSN+qJx-;U{B;pnf6G6_++nLPq`Zuq)DF#ZuQ zf$T>Ms}YEPf%w-tTH2?CrLSBbu*Nhsb(rAzX)*7$^ggODP5V1)C8$09((P-paDb+O04cblvYh&txKxAPa1_+_aU}n>qtCGWS zn*GsTW{*Fl`wbsYXjWU%1T_#ld3f z*iA9ftP*6ED%Kh6B-5DYn4~Finvu#9G^sqMk<%1}*MpMXlm7ffek&{S4~f>C0%IaQ zz<_eJFagaeIUg<|Mok(LW|1FfOXd^DK!LOmV=gRWY2lMGh(b+a;-uCRa%QQT6Y+ffq;}hu^`7`#8`Z&#oWG-tbrMM<8Qsjbaq#cbU)N z=hZnIqnzJh-^+&#OUA!*Gx~ZLz*%J!du)8k#`$CZkcN3)oz9J}gm&=$n#JL0 zdq7;;Ru+Lq%7WtjvoLzO#|%S*mg!$AXYd=vU=*TJ62LPy8o6OH`r0HOQf-s+kiLH{;=&7;UxkegUU4~sz$A}5}y!z#!$jouu950r2=SBur~8+ zWm=A5yBfer7#|JETNz%rW+Wt!m{qD%IYBgNRvBxh+3`s_ty*kW=Fq?C&>aWBt7t5; z;V2G(>_XNVutLSj05MHvoR5M$A(f>XvIKM{!$1<`Tmfdn#J4}l((#i?sZU7;i0$9n z08auv9}FabsHL*tJMwG>m|eC|ljROMy%G6)wC^d^9Q4M2VIV>qQulHT3r%-$kE~CD z+AMk{-r6iNotKIJ5ibf$ZGRmWzrQBKG4XwXGJ^r2AcDCaOhc9N;*7GO%g6|+D@#V% z9!Fp;Ll=FfN6zQKWRF+;3r43kAan*U7@R8^m*AJXxx>LAxa(1lp^X1>Q#N^d)V3Gl zN~EKQp+~ZqNEd;dQDpo6r2|n6zg+!d>=lYrC=tKA?1P{bA{#{25VMT+sL54^YZ3c2 z2$g0oQQ^E{t`b&dhR#VqM$sdvwU+DwG)V(>B2EJtF^JMk)UKU&@~6_W zuq`J$R@T@z0qlcI<;x_BEg4&}1c)i6iBg)_&Z3sMY_2rSYJ(F>cq%F4GA&hqqd}CJ zk#HRW_Bgqw^;_1(^64en(F&E)ZoItGT2ju7@}T!N`As|k=OKi_PWda8z#UkOzN^hj zMpkR3k{mEg0CM|VfoSOGa$f`5AH|s4G2(#YynAOrqIi^&Uk1pD0?=AGK|%Ueq1^_Ak&WST1#%bq#&A^e+yp;dSS} zjGL7xKBR_S(%2gmPtt688>K}3hUyx*FbwksSVqSg_YT%?6~{6+&iQoNWqSs>MOd5w zM+IHVKDZ5ef#3H{#JXbJS)^GE^w3pAxDLt$1Bx5 z&-0aZgDm{5e22W$c@Q10kCB;P2p|Sr40{F0m13+ELcy%&2|!_x%2W)$f~sbyG(si1 z1R7Lh5bMM_3{?s&4n$3;42k2hTn>?*2gv|L2LMp6cDdj^?MHSNOZc8DR<;yuuUJ%a zYRnphNrRNL#v}m)Wu>6bGD`@AsT~-}$+c8}OU#Bd@RDu;vHvnrUMtJhP#PEB*Af>g zIsoT6083Y*lJ!)f-KSc)s9Owso}x4QiTy2;^w{4PM*wn({uN@Tq>Lx&eBu1q&J9-r zu=?Si0c=ygOKJSY!Rj) zcLpp5-gXZFu#7S;P=th#D<-H=lnFsq3b;;%Kq%z{fXV=ib5JvIO{5IFjKh+Di4vy7 z3;@`58Z8ko@P37aB{YB(nNyTfumpH4g3&l@08VNa<8O@xHMyZ?QkorO_-#ObA`ZZn zRDH*Jn%1`nEhbWO(I^gpM3)j-u5kcjLU8#~3l4yz@pei8)_Zl1EFw#^o+NV6fbv9) zJ3}r;ULt&rAejK^!Os+0CqNFar2gf8Y6(4_s3)m=$?4m6z4KNMU%T&Kz%EJ*7qugT zx%oZzj`RLIMt{+n)Y|-33!t-hM7+#4F_->z5qrB(-6Ge%YyxKHVHLL?H?c=SAndk9dg)0YxYm z1j8Sy{q{v}>af2w7o8ze*0n|-LFitvZs^eJc(BA_er<`0F?$T z5ZM}kAr*?jmq@>nC?B9|JYPU~1I$VsfLfdgD_e{#O)4%M5k5=uS~eHe+Mt!)^5%f8 zkzQm-P!csPL7<)do(dn#zK(%vmTI5wm5J1ZiI}h{8Jmf2L@l_U$DS|b z$Uc@3j1s7l7%EwAG|DmIH5q`_eA62Y)nV?gy0CuKotR5Wy<060We=K?I=nUPN}-Va zGGPmf(HDbnqkR_QpGxG8{ZVLz8^Qu@ao+OlQvs44kQ$-!X#rt<1Zqi(qy-bmY2FtzF4S_a6~z@v(JR1zOI0EayVhmsm37N@{Q zV5VW%&;XZ7H~|2uMi_dB(4J#{RHO9(y#u`NQ-@ymh`^jT}^4wKT3RPH4V zATTAfWcZRo5uwpFDlTT)lzpYD4Xm3%o*5|TCfQQ(Z82Ny$^f8POIlGW5?kd2C+tUd z%*+Y{IATo(po@;=y{pknSAgBG(ybw+JR$Uie3l z=qwRnn;<22olB$+02Z_>A=7&O>wS^ zr0OwQWyfTw^$oY5?MdWl2DVx)wQ9%icHwJC7cJ_5Z`R&_W~}g}{GoBOdRAf=l#*$= z8kJ4ugkVl~vt?o1|5j4`3$mWLfs3o+w0y==bnnS~!3pRx`McZbb>urufL&hy8aE@R zeqRAxzE`-LR<_5IJVlE`Qgn->6iPfcr8=z{j^@lyMuxsPG3N6lrY|zkPcCbiDP;^8 zCB0Z&8gn(zl+VH_%yI#KgS@I*6j%!yH)Ov#cWboEU0u*f=T}^{x zVres#m!h1WF2Z-UelcdsYf@Rt(Yn%d=sk zv<-S5a-k^Ogx?in3yLmn;mf!DxC_@k(Dr4vA96M=vwLMNZU-5;_AQp8{Whc6F*9%c zRrr!{p24&fsBmC9BV<=iZbeqyXWlT3N~Xi9;hhtv&pxlsr+-n;f2(W#_}Wlfv=n0@ zZ6u1PRWsYR#Xiyf;>!eZ4Vc9N*q9;ZJu3|qN(!s_d#v}0QcJJX`Nfx`;9cRBy((bX+p4K5sc&@C5QEPJ33w@Ts?UWUAdUA@{R z$R{CvoN?Ayf3ra?U%L$kv<9Vns4y369j3E^k{Tc-$WtU5)`){laW6s2HK{DuNrGGo zUqA~ReZ!vz-Xd{_2x=Dw>sCYt;LF0|5}% zvuvC|{Iz7#?%z4$AJ2(Q!#pXOPYSkjuR90EJpM&heew5?j=m_0qsM;SYm5Kux4y;S zQJIK9ZUNvGW}5-nWZ-;c__{@bITG2S|yL})63EK>e|wUJ-$vF z+TV32#Uia49M;xrU9unD6CUMtqbf?37QC(iCA-gw#Hxo|_dzOv=g6Xp8<5{lD z3nN1cW~)(EOEL^P;ho{3;bL`(zRtER-o#(K?rlT?CR+PfkB~YK@W))NX*&(Zt9@Bw zh*d-Lm!MO7a|f|zq4{7XrTm(BJ|{D+nD=JHyHn=95zyYgp@V-s9v=gM)&E)pQDPez zGKX3xPbBiMSU4#%c}g_~__ATVBuj#@yQKwHiBgO>2ciZ;t|iR4%A-Any%QspZp$ND zEgi+c;g!QL8H;H{YvZ_f{RnqYGmF}n29kiUBsk82{acA2+m4V{eQd>s1aD#4F*wV5 zEci@I+W>DCOmG7HeLD-j-I3k-EsjYpvAfOKAz5Wuc}i@#ZO8D-j);CpTZR;1e3s97 z7~TG2|DKE6oyav}u}_|5(LepbqNy6Xwbg|a;2yW-JCuI?;5dX7n-lI?zddbcU|j|v z;?<+;Ob5Tui!p^EeQnt972G5ZZ~v?_%%`q^ikf&_5{)KCQvdX1M!Yj3vpcGrgU3a6 za*pwL7>{RaD?4JwT-0C-riMUQIa6JNDrB0gNXSaSVN(EhLqs>&EgC}K7XOt*r^Jc| zTFYU(UDmDYYfk-$my3?$-x})ES3fTwA14dAX4&%5zk9RxW=l3)jrbRT*}3%`YtNg$ zT;X%>gEly{UOHMap{Di@Ky6i;>|vCc!?Dqsb)~E>xIg^9{cMHw5mmvS56gtSw!eC* z1kfIIZ>@bA-}~EQEPQjUKNemXtja zRyx6QR8v73%1uyBj$mpOVJg_Hm^b?_!Q1|=w6Q4Xg~~ zmXqV6f6-5Mq`$Icp3R6)CS(p~WS*QfW@pUwXx!k=NmC!}sA_ar&xn!{);lBCD z)9=qN$=Z^u>8DLK2d3tGNh0&3wE^59hHb;V&CGqv*X}w2UQl+)(1dT)wIyGE_eDG> z&f;RMECOp@v;J%iWuERU7PZJAG1f`>FCF}4Lmy9&iy^4C21#gN8iXV?<#y(S)-IZ8 zSacXrW;dV#l)21?*fcb&?c127A(;Vfi@_3L2uVPs1+7qnsMd(GlL!ukk(0q0hz`K3 z!8wcO^JdZ_zGAF%n&ro}P1#3WMy@kPIWR!w!Y-Lj#XK*F$1`J&CWiLJ_@5Z_=*aMs z6NbGLYL54-X1bkQ;@@c^mzRzSOUC=3ekxgl>Z9AB{>#s{%Ugp4HU}!XjH=%#zSIC- zQOw&XhWeMp9g(o0Q!EBMh(wqxffMG4M$5dRh_3^>QTvO)Y7o;MgBJ0ky8yWD0`O!& zt(hSU;@tLyZsfO2Y-CYhlw=``*_R9eQiEt3SE{2#hB0_g!a4w!_s3Rr&Sscp)ZA*? z!8rhD45x@T>0GStpVcsYjo8jAV&wh}#wz=Isb;!hPFo62=)RE`TB<)VRm7$ODof&V zLFQ;~=wNKj?y)gXj!pCUNkx0ROh+d*j1OsEe0S4wd;mWDP{BX^hx!2k+>-l$@9!~u z^bx@O_X+R11;u{UeYvbpj{g~femDDe?^_9NZm_ych?g3G4JOKJBH;o^<}m@b(yu6N zWSMbcye6D{h0t!^9gY>tO|KtTiOQ$Sn5S*TY5vo>iT)Vo2o`fVTDfFp?XWfWojFkM zzEpQaGfq?(Wy+$o?piS>BXgMp2~Z||dZ}7lEU;r@{S0VZ({ph`n~h%7sTa|Me=6c) zXct0HzO)F$yw&{JI+5QCI0+G7Lj=N~o42&!Pg_|?U)1qYzt!QEx<7;#JMrLwYti^F znWqKOQ9*DpW7?gN**R(G$x*{QyA>V&CG+vY$V_(tihuoo3Qh7`;O3)`^lygyx+)FA zW6ki#f6Va37XS}GV1Rq4wsky)(c{he!S7}pTXn+QCGk+&iiD6@$@~D~4FuRg=ArgO zs-zvsASlDKFuC(4(q;CuItR7iCKcz$M6ff+$8_ZI-B2K1FH_hnVjyf(}S#E-u}HK`Z)o3x7^4p+iTM4OD(r*EPk&e^+coe)PuNm zv=)osX{n58=)bvG$E@n|JX@hv--~H`{+&r|g|~w7Z-lf%TsJ^c^PkO`j_0ED&%9F_ zemp0BJSNyVWV82cC`J!QbCFc@qCV{a2+x{#xjt9R zlX>Yi^Sojn&zMi9BJ=~c-S6>)%#%5ooe9G3NmCwtc2dqI^7o=YLK1c#bmIp9@}Gaf zWexDlH02-d?GaqR&T#MSW}f>eC-eX9_Vy7dl}y0E47lzg&J~&)J-~SejH!aYVH#%I z4idLtR%!q#Nk-DnoV3G(qy}lfm#iz^BKV;;5#LaRa!%I{bom9{Q35{dJjDw2_T
    &2MdlqOe`liGoBb6T}uA@0~NBcTCG`=6H?b;_T< z4cW>&pUME7E%6nv>F31K^%8gFYlBl+GFJmwN&*GdKR5WyMbSd|tBLB0VO}zir$ond zrh_Tb&YZ*nfG4G)Cnd|&KXdp5i<7^w8Xwm1i@{w)T=(wrpWOt840!V)Fnj<6_gWpQ zvrwDieB8i*4=QdR)n$22sW~g0Y=SbIsDyhoOaZnU#eIqX!&h=`MV8- zV^ZNtc5^k^x%7w8SUfZA{1`{k^F(29<`+$w&ck;7o-XUw>c)97pe)=DY-6$dBHm#I zJY0${T)#`=4)F96Ks=@qZ#?*{>!ZDH+pF1%ZiV81;DtyE-4Xt{C>W-5;*%+pNc`@M zjd?OBcrqcgTNvK2lsZ)NW;7q!w0|H7oiegVefQXZ1;GE+J>a{Y*SHJaZGfBgYeDdE zZ+`H@AJ%_d>{bs)Q2nbLy1J>7`bNr$MjjxlEkM{B8R}nI2*I}RX@x~auh8*|yrQ9R zZ(L~`j9$U(fi=m^h!=pCE@l}tp<{S`7OmryxdC3@C$RJ0{g2H6bZq$<3QiFb5zm8= zTMyS$80_P_Zp6;pkGiJ|rWNr?LFRDIv^zEC$;6n)1>w#Fc(0)5 z$e46=c=__|{%9nBx^(>pv6g%1r&--|Km94f!-wwny?Z2dpr1%4;a9&hzNBD&UFMW0 z31%wo4|O)w%v(YRR6yy>s9{gDNR8WShAcS3GL|u=G9K3R9)e^*TkeWA--w8>1`DZX zo1hCsfQX2Q=Y{Xpk**r| zTh9A-Y+Gmi0Sc$yQ58({(i(q0CIF&`aJeSAnT`Nh$Zkp33w z{O(W*kM{uh?h(fL%xOz zw$acP(GO9`Suf-}&`B5zx51E2Cx~+-vgC3%ki>=5v?MPZ5fSn7AiNzP`dgZgU9iZ9 z0TB@qXXBily3@|^!~Gl`*AjOmH!k}AC>Nf~v`OUeCh8UThw6%8J|`YkM29oty*W#? zO^*uTFXljxr>fpNu4!^GHIx6(WKw+Yss{d}mvP5GbVtnkjm{Q!y0PRryCFAjFnls$ z81Ax=2G96*&1?pf!?He{>z*#i(2Tjxm}d&{#4y}Qlr|}Fsu>17ZAHaw;lrjDg#YNI zjKxo~{y?X;DgQ~wQ6#}eM7#nd!QjWVe?-KKfsOY=r))UKdKNQBQ{LM<1k-mI5H?&t0@5~XT&F@JCv{M<{t13Pi<@soTZ_hk9X-9;& z5W+p&`6%^{T(zpyIcsnP1X4uYk*% zm@cP~qry#KwZ%lkD+-m>B6E8u2mv6|Qs<)|f!ygJh=_=IsfZ3hM8ucC8A_P8-tR;@ zTq|5FedqtC_Gxg5{0(za0ncQ(Paz^d40{E^jCR1zH*M0+LkZUyk9AgAzGklC;1 z&GBy2pZvkmC(`S`^qIo;mzy5}sJ%T<(4VmI`SzAJ_1VR!J`5cKciu;M{8tdZ^({6Z zei)p90Lem}{K-klTXd3KO)vGfDIX3LN{e70krPE&*J$SjWsHymb0+Fz(g9&u%8=Mi z0azUZJA}kJ2JP&NH3FIu5fNVk(E*5vcy3wF#h@%ZY{QG(h!c=Vf4^n;OGxXA-eS}$ z2B>8sH*g_jzXE6`ef|~mVF42T^X{Bsw<6xD2=+>4_KFG)XIhW`aPRVx@$W+V{IC1l zGVxFF!Sfsc79RoN`;R9GM@R1IfA9|&T&Z9clRq3+`K_&L@DAsLnjkMx2z_8s;k06w z9)v9gumyJHI3#|8V9Q99{UhFl?H?5VQt^}AmRYUSl`j*Cf)Np4643#Oh`0c{wfjY{ zy5;$kC$IS-8g`hEN)R#iBk#gH$ZSrh?QbXgO@!%R0!!3?P%!UThIVJfyCvYR0os%2 z#stTcx;}ZR)cgTF5Sib#5MzJOtNo3KL*Bd5virXjwc&la|BE5O zk`$Oen&HtboT0Kce>-|pccnFbB3tVI6;o9b zOlM3dGot-D!~V?BZp}u9`|Q?$JBE0_LO7amJz)jYL+05hdwW*sXC378#QNF}{&#`i zzjxmn{|?*#A~?krXdQ?fH|#JWfSXKw|Gcz9`CFnAv9P7hAN#lK=iL_`lr3f5W~ zi9%0VLN}ztPS5f$3OR$48D>Nxk2fut#rPkLjS&3k$)qt)X2?&9hISf)-3F4wjjSG# z8k9RlQ9n7{s~>cF{nw#o(%)Mc@0DRJ&x~Mo@7%i=oB``-oCEsej?)TNu|inyMId~3$DwEulne#rBllkcRKNPwVf_%G_?O$; zMGjE@+f0?glvzQBpf)t9p+X!?h(r%56RBX?au@PDX#%@n7j+j&6?BJ_MMOlr07M5M zBH|o$XPpbeygQfTx4$gH+-yhubA&bmZW>@ykud(LteB7H%zHE9otfdsBV!(q!93n; z%+BMw+WoXC5B}t&JURjxhyMP%YuNsO0HFT>0DcU>Qlk&i`71h*A@SjM#ZUh>TBg9m zhpg}26ItIdWw3&i6FZt%LCyoUnf6mP$Q2HAfWBrJS08DG9qbb>%|4|8&;%7V3 zcWP+o#L(U`!2aX9JpL!6as&X9_Lok$QwWCc-aWu0fA`0p`TvY$zZZ;;0WSaREE)lR zhpof(;fRiZPhqc0F=;?DA~-h$<3#Z_MRW;KT?OVV6|hFeH1~r_+{^?Q`^!OS?8p*p z*=;hwJ7~TuvvmOc^acCbU7q19O_fAMeBBX807SfzI3qpmjDI^8E6m11DNP$-Hqx3F zYVDE*fYl!f`?0@!T!r#lcXWhX;-2p=k4;~QgUMDy@BDizsZ1WFLKbXxXbLN8*nlA|G zFQ-hu7?JrG6E?pX8T!qnp~rKg!-`>A5lQL9{-M=T2w8y628pZ!daVHOtA)zIa;IiQ zM8uhh8Gwj463=2C+QhbvI&B6Z5bipYYn{z0iJ>5>N|u2>mg)cG$k3y)r2FM|WA&C}6Uerz z8hA7!J}ESvTp~Q#R6NTRQ^J@aLO%h>3!+O2x-x*a#=q?e6xf^V_Sa%x#O!}W#M2NR zfQX2NQFEncKSwVN@w3%@MB(+hR&}2_bG|^B`{(-nM~0|j5T^gZlzDeV&EtJzzSwV? zM~@rW`E6b9A5_J0L)o}L9M8v(q569O^x$kGzM8BEjQ_6`OWUxf{(v2$?NRrRj!0d- zTBxn*=>PQvkF&g<{WwkM*IAoQQt5_HdWq!#U?!RWG4YlGhSaAj<-LMTaOeoIql0C# z;Od7j2%8pP`3{SS_!=TQ01*)$-H!A&w3j@y0&VlmzTHjx-nW{86Cy$cLj*J>q6T(f3Q9k))t#&^us-yW_>iy*$#y0YY|MxP>zy0_-ev{twwf=i& zlLNml2ps|nZvSnB$pl3`5boYLB35))v6rfw`q3%qH6a3 zQAvj|p9|Slo$juo@}Yi)amTiO3^hK5m#t1;MyrJ}8)WDu%~%=EujT-ub+yy+1FeM^M=Q z{@JSW4@!fHLT>&&sp`KD5MknXK)M6YnI9mE2jhR46i4rD=lyL`Izvgg;EX|y6p_ph zxWddsg|NvCn+jooG8aIBl34=64+!m;@*%Qgo519BqQOL-(J3Ft(#YwBl)@k)k4PGb zc;gTqfQUE9__u~&g+L~we6U;J_maNO2?&j4wgxRu029qPHL}4+AkQ6cAMh(QDOW0i}Tw2?AG18XK(I(0C@era}?fNu>HTT(SNr)0Bm=_ z@HPU><(?Se^Zu;*msE`Z=Vo)iO0CX9Q3IbeOveatqyVp{Kv#hH8Uh+28zu@h6C^pZ zJxF%iqSp!t%1rW^;avwK9mtC{v$siPWjG*UD>fI^~v z8Rm1Qp8smu`h$B`;m>}wr2i!^@YVJDpNS>Q?jvjW*!%Ch-_jnqetd9?|KSAY(~aVo zO;SUq$J5drG3p~$betfbXo9!3Qgy;i2`W)!vdsZ=;(n^w8hy#wbgTQG0BETJp$y<8 z1WX*4vX!G{J@Ae3ef?GcSN9Uu)HK#D7*mlmONWp;m;Oz}PDhvP{V=hSs3_F#=Y) z*$y%c!hn9XK-i{;h&L9|0f_iUV@=1`62kDFFjOWm`&Z>*Lxe9zpO}S5W{|-&c9;#< z2C-?FYXTS%z=2_qR=>|Dh92!3dh#!dX7~See7pw$hd$lE1fj0i09X+z_{mQk>Hpq6 zY3*x&K3qupzD{<@CR%(sL;bS%Ex1LiaZ$N;R4Kqdsipw@^oi_mV3*AV7~u0U5p zaJ5UM`#swKA|hTBLr2g8*;&1!EpMYlr!I#b;}2xn<5J@{ z%#C3vfuT^qGeDRV!K?)R}Y%$ierd4+um~bjP}p;X-bXK+D7Ibc!rm23_%}JM|&@ z0ukSML*bnwUJi5UO+IooU_&Ri)jfBI9Fn1An{ zW%%3Z|8+?Fp8-3r*g66CfvcMt1MIQ=;x|7a?O7RTp#E>C)5&`|rB#|@opPm^ry0`` zFm9K~+X=&^4B=AFd^tnBlmlMLm0?XPOAz|DXwc4TaBpfEp5%^)%LlkFPjC9I@T1*u z_sdy++dk->0C%I~Qba_2GZ7tth;J0S)}mwdTjS}wx}03D=lTap-^GHx#QU>PN;D{> zvWB@RnWiO4Z$BI{z&-=*5#fPRbf63#u%bgEeQXepXON6fijnm9FV*~4VW05Q!O7jb z)BxbeehY5;7P{Bfn1o3Qa{Ima-ZPT@0JtFp!HZ1nDSvC`lOJH8o2pk}N~2QijRI*{ z0&Oe6?HtV2q1IP>8m|vX)x=7rNIf8tBGRrkFwp&4Js`J5&R4WugOV8V2_s*UU?={x zP+tn%jm5*Ubui+ajOYMFe50^N3NO@VU0YXI1ZJg7u<#Uo%;(KgMGP|{nc`R0%wtjW zD~JwC#=V;9i6MSc67Mvc_7uZD6CLN&AE!#qn?#jQCX?zSll)wc*=>O*zqJ*NZxGY} zc@Xw?&^hStgQvk{Coub7rklE-%u1M#W<>dnu|F!SAu7GSsqy4$LgP!M3(_FE!6^gw z6?1=1v?1ic?R{liT}`m%!9BRUySpDWxVs(Po#5^s2qCyjaCZsrZo!@4?t0+z&YiFG z52k*=e!6#eS9h(dTC13IIK{h&cK+Q%JUAa|pbRd2y4ik~&2(9GK@%hH^CjuT_1}#g zFOk{f85hvDAKHSGpL1&0R=pwLC!~7q)^Bc}!z4lP4~CT)dgMW-EW6a6pxAB@1NN%g9V3||4a>a5AsXI zr68R#M(5-rNtrn*e<6M!2E5=FKsdwH(+(Q%b?7B*ol@U=`r4=#!o$#KGfZLhWC7;d zxIXX+ko}&&l6?U&f6o<^7{GNVfK8z!P^$X=3$(Jh#I25beL(*CqyzbedHoq5Fk+f< z{I)grkJnCxXxtawc1a0+6E8^m-!4iWeUM_m3Kw8L;&_0KXo2ETzEO~~lQZIAR7_S` z#I8IW&aeLVE1dJ$zTeKJXY=frTXd&?kB-z=SLA(@nZPAdjP(|7XS*2z)ptUZD^`GS zRsdw4xn581CghuW0Or>D9QrBB2W9f|7 z@?4YleDd>rIb<}nqjJ+tC4^+L8RR@WOGD11ArIK)#H|>S-&%Tn3PgH-+c(o?)xi($ zC0*+k%c1`lIC>hJkqxd>(qV$+ZHGxoRJ$Y4&?bv61!y4|qeipzsok zRZ)yDe}}B09{+jO!td5sdKW;w_bx_r%_}8x+=M)vblL^gFueT`A{PqIB&}~%JUpYL z?}!s&mA~J@1!(`2G~CySoq|LsEUdK-Qcm1aL&+jHmIioBtye&}N@P(2%M;zw8OdVv zlabDl;XfcQ3|y(9@D=T;^0%pA`sW=UN5}Y?B%-zS;mjV$cJ0DH4_2ou6Z?ap#k10o z-(Pm?GW`JSgmZe&p)J(VYyWq{g`8DpF!+~#IU_KQ;}!vs#nnT}{#G1ZC;2hpWk5-! z9ic>!SJ;;SlfHjP$&eK6DRr%p(oKAD+v3D&@5o_}5A0@jyISomA8~;GZq3$gKzI7b;t40S>PTrwn^hMO!Bw9u66hXJjF=4`J=BD!&8P?Kl zasebHI?K@E=<}MfA+KR84< z$fBOc{&R!cG1bd4r*n3f5hb{>|HNmTs;(POmw(}e9B`^a+LP+Gyuk6W})N0dRQ zsA6*;m#;jn*h!#o&~E`~5_FI<1$lSxp>S`r4!~9jWM0xMg5bPc4v2=~jD_`ug|x=R zlt}^E2b9_3hy@QAOKTl2D0`l5M0Nw!Aa_W6crufN9LdL6=?{x+Y2O@XC8Jr)lo(X+ z)jX>l0pBfRmW3QVMzbhwKGe@$i}0V1aqn02c}q69Fg@!q)RMrngxS=$6qN|ImNg0| z-jq;#%w3qERokl3(E(q1jlG%MS_dx9m_Ya~X!C8(*qW+fmk)`H89IHb%@V>ko9vU|A-g*|^^qQQC=W$)wpSqYz>sz?%T@ zMrSg^G)fg!C+^zyu*oNmLnj|c%Y}tVqL;W8iB+TBI3s$j!b*(ip~?8K1u0;``-mYu zMa!ISZ!xceqiRt8n}*yh`E8TpOSPZ~*Hx3h_Qv4f$UBeXmd_@}H3A>)<;(YcvscO} zkHATdm}{rB1cs2%5N}W0r)%h=*!~C-VlK70d8q}Uq!J+U#JL=3`(IJRl4v$@IC@JV`1P>2(P$E>HEis4 zn7B(k%UV|MeaxWj>{|M+0b|i=O(GFMm~T)ZAm{@f0EPwxIP!z!GYPNvXJO~7vTE%* z22e$yBO)JkWC+DZ@X|yRb3tH+w?EE2VIA!6`r0qITYtU58%CDZv@tC77SpL%%jP{C z2BqjQBN1MFCY;t-rn_Hd@Bq~Fj8tmvo%{<1n| z1KvkSP)QgY2XHv)y(ZX`Cf>W#$?I#}$8^HDKVbYKw;Ie6FwV?q@bW79BGXHY8J6kx zW~8|jdhXErydAvo%q(0vABA=u5&{2*_Vtcu`>Qh{4=v^zH{fHY`(%&2Kk||@N7zG$ zBLNU zb@LXLC*-&z!sUDx%`Gd(P=XNZMnv|+U^?UDG9$dL0j?+ulo1f*sI~_vZSjac z9yII{Noz1zVqQ=s3U#S@wr>gRsaQXj&}!dV<^ACw-s|PmPVFkyhvI0h z2BiLTr%gIGc1AoeCKz&z_8oWi;j@>WK!N-)A&1Cvrj$>4wb))%%$LP!eYf1-Z<*0h zDq}MwL2ZpUvi{wceC5%~@Y@3b?j3||&m*P8{Ye0p(nqo$`6!wQwsQlJ z@bb5Lwe-B1s2=IxicCbRXeUqDM(n|Qf!H0$_htYHFvjYb$Xq)SVw*G(4$bOtJ8FGG z3IJ?I8I*=*4Dl+4zxBsWQ}&D;o%V0x%GR{VO=rIb*Mlttg5Ap3j6xr>aZ^P2pm!Xz zl+j#N{|g%c#d&DX0 zysxfZ{EeqI8?`rr5~@fW!tPZSYowj~Q@jHvkQ#_oM%#wQj`xlGsW2P&3*68q>uf}j zLCQAmZ76_0~n12qT!P;JU=m>rJ)>R7O}^b4rnL?D0?-L4<2_1AoY@<@M1(rI3fVlaV}N zNh}I0r}B0)gW7)dJIu%2hk^Z_*1_Q>qmew^#-Xo{qT<`_?RL}N!a0TYMPi>BivOK{ zUjZi0M)g27>cm=QQH|Y;YMBY76EDhfW+9kxdDW4yy!F~kOiuBn*pQ}4^X1W9-Nbmj zv8zm`lU`zScNA6Ben$=takz_hjE;-Q08OOid3UBp&MI+Kjsq2=l zw;OX&6~vrTYflbxa${eTMV|rAvm5O3#f!^DWxt5WB#)_&`_bspt`D&IbgZnCTV7_7 z%$E`Y!JvA$I4Fxd%W`W*<%Qy8=Oi2MOLFaWHAU1}csFYF0t5~vWQP$Ca`^{2^|UwpNf3uN@?KoI#@(wpQH!0 zqqv(MaUq3_!||n`o~Q7=awG@I`5a>^D(N#M&u`Rt+6=^%oD* zww7309|rJ&us|&E7rTRv5R=26nW@8HE8teT$B)?sBB*4FM@H^l%<>FQUm20R4ai3kz|#Y5%fkLMkrS-034h9E zxAKXVXO)%r5JK;!D$~c>b7v60Mt82oBxr4U+Y?Q;D5SAZfR=>-MvBr@TnhxrgKpdp zDB3kkEH1C*@i;=Gj{k-kwF;=$(qbl1t?cexo`q9t>1;j^B^Fm)V*F#AxihXa-hrGQ zwFDSFk) zOD~oHg7-hCT?L7WpfJa7@th0B2e4l>$D^i8MHU-}U5?Oo;{LOvE*z1CK|GaTsBzym_rwOM?8uPky%5 zSgeeWc^jd#i&~{%$4L>PlK4DBTrGKA^cv`$#MhIA@#JO!>v2|l_LDByZAC%7NXJ46!Q@Ap&3Zueh7C6_Aq=VU)wrdRGS1g(0c*+tsukCnH! zx4K+9q`Goys7c4p6&}U_lPEhHFZYBY^iaxp&JiQ(=5$=8Aw3!d_eALjQF9NGoZkME zTxAGfIoW98P_q5>unPO|-=C%80z1^+-wvTKyDuvs0GB9{Z|Lihe_neovqiAbL8MG8 z|R=5&8eNaihK@EP^)W<^GnOKi&AUw%x`u8=!@0eY%*H{wVE` z6sC4Z@Mn46AurGS^Hqj<_#UyB-puM3B>eUvnl!o-8J^r8&?;*%~A4HEtlq+vL_|;N#~MXs?77x z=7^+y2mOF?c>d_PmFK{!%7@gM5$ugWct z5Z$#M^f-@}p- zv&ozVqn*8*WW51ULU{O_BiP72r4V(;p&?y~@PylnmYd=#Yf;YhQkzr7ad}j)%DRJT zk21K|37d8_1)qL%>vpTx;cT4? z;(5#Njui^l4w`Q~EjGZ&wa5*H_661#3^CkRVdcr#2}Z|GO`k-%mur>qQtS>AZU2qL z*(I&`+qo~{vMAPrR9RrR2#4Rb2l_YnW9Ocw(pFwoi_lPs!37dS8Ou4&`gjZXH|aEj zx59v$gm7grDpFoQ!qraNk@MJrv4S6$!;zPqC6|MjA2L_AE9XXq)nGA1@}wEtC16Gr zz?P31Xd(K|Z#i}+%U*7Px7+<{yi*zcfn9R54Y(j8%DY2`U%BcA$k+wlfu0{MdFbYh z3Nr&pZ{p*rR?f)dTQIYb5f0W6l1ppVkdq-)AY%~nS<-k3`@zV%v01fYhKnPwO!(Oi zQyy_{Ku_;4?0r6=E<@kjdVK2clCWF$%Pujm_jeL_J+cJtGP8G##9Q<=Or>6Vv;FMp zPFK10>u2lAPB%a%r7%(?h6NK(&5s0;L=4Gb1eu(zJ(-Jdsmgls zaew2oeY)zI*$I&N3c{Kkb+Qq8*>mB(768vpEsbR51c_dCcc8ZuuilcA3B3WNs%l<0 zMCv~}R=XNstN2i7pSruO3zugotPhdkZLXT%JUC$;!?n9#SrAyMRJE!~PVsK!Jo26j zzl!tMorekomY+^n4$eA+nYPZU_w{A<7-xPh>KxMbN7Qm4&y5L?@1O$i&ix>!t9~VL zD9D|>1q5p$Gle3C8|^JxN+&Bpk&d?Aw-FBJQEsk5!C*S~rZ|j(w&Z|e?4%Ooom0yTV=%sy@mNNvUt_vfCkmP;CAv2K z(%S8#JQoq2%<(}1wze-~)!LAH`v;>qk|d{#!-?;^^<*N1 zv8_$9R0B{DFaK1URx4Kx;t>0bXDkuNElUCLp2NdK-*Z9nQ^O#hUd|%o^{b!0zXqi^ z-_T02uhQ%9vATm}_rAnhiZ*9}Z~0?1F5PwOUR&@SMT2}FAxKzG-dw7DCehra_`H_2LxZNQ1fqkoNrps|uzC9=s{dz*Dse9*9O|4ZC zlj&)hT+o;@Fx{D&q@uMFJI@?ozq0in*4b^97c;&hT+L|S(=b;#3+IIZ&z&*orFXgS zDgLpIEX2IAgo31n6j*N^HQVAK6Ob28c`@o_8;9pBzgsdU4#qnPM zr%b#$@P)kp!qL|eG4mtX;rtfL+|+K15QcwR^lsQdGS_hv&gT{&BA*TeAG-#_PkKyJ_QlF`2Nz9C7f@-t$!kty>I~1=zyMhR6NH`vsSlUS)(_<)AX!{C)Svd zuJGkgF1cGqnV$^BW@N%_h;Y&_TWkkKeB9E~2+nFhWj_go*%s_n?tgd(MM(fQ0BVO9xx9S$juSSzg~ew(KGw}<}l)9hi(&{a+J=nB?Ue~U02t+n}3 zg0OWHDzRDlkYx4tVB9({krmFF9dBVifIGA(mBUSX!bT!lvxq~DhFooQoy*3AuByiz z+qlLYkE@7>>&6nX^%t4J6t)xNfCI`W#toa{l30t4gCPqsie(OcJyIM{fhCyz z=pl7sbTqzIjY60T9nt>d!>h$@;%UnVXK1wjWSb2fj1c~4Rbx3rQ@g^JtSj5lA+uURp%$BY6sT!UX{+9ioLF4f4C#~FLm35+0 z1R-KG4_O=63>z^O-{cP~gl#UVQOF)?*mWyl2^V}8xdbZuYfQc*1vAS9@WF<-Kkgq@~Q=qq+~H4n!Z-XI`y2ITPwxy-`o59j{l9b_l@j0RmO$& zPEL4>!#N^mZ51!p? zCkPyxd{S26500*iPr*e$(xql3;19kWnM@o5N!mWH*AyoQJatT6#723MXG6JuZ;~h! zXOMNC&$aTDbhb9JqBl<`LEQL;U7)ie9&&FWfUtSqJCp#|zk`aA)m7|sJ07%e0VAcn zBSf2J8AVu@3@Jnle3zDk+Xc{l>dW6jxb&m_bNfO0U}zK!PXpW2LJ*@}JF=d0;1kK^ zI&#*xGOY`#+v4N*J;Qn6whtv85fYgPsc?`d*CH@lH>!B-iIfqoEu_e$c>pIaIqU1Zdp;dI?E$_gB-2!t2)PyM(9=XC-%Tf_sJ@0H%e z(qx~5%{er_ZfdU#zbUxZhI;swkH|^$A{!vhPX7jHLy^V!Q%8Rhxau@b#OP^+%t-~# zhj&dt@7YcL_8yKl#VP+S+2Y@n@+`r-@@IWP3C|l6sqPGDroaf4dr1BXWBJ_DLMQO=_l+zX6i|U&5@(jhAVnvpv)?s;Y z;)s=azR2rl14Kb?gNeKV|L|p~?O-vi60%f&{qJiI#b_4jDg$vSv1GyEattJEed^>P zhz9~T42*i>JnOYSDbgWA?;30~ZVUSkFKgxGVS|51`1^jSJx4`d@M4yZtN}khz%PH_3|mDyj=j^l4Y1 z_Tx(VPm6i?8`L)P`q;3=|ExL#mtfx zdj9M09B@5bgJ=!I@k(m$^C!B05n#jom@Qac%v$@XPiu^S>8>O-3N3lx6@?2S3O$zN zCYs_l(~`3KJy>(3?S=CznU@ljRcF7#M<+)qVJur1e?g%=B@JEHAAT&Lq|8MDz)oTNPA=Qm&&^rp`}a1Z4dGR zTHlRhFTNLH435ZC8KI9X2nouUP^=lzDiMzl2t6|we(S%kLa2icLHt#FS%*VelV&Hi z3~>EPr&(C{J=h&DQ5Hk|rezTVxZU2o>)*Ex*T(!M*x`c_c@f)B(a%!Y`%rFdg zpixTjACO4GHE5N0qCkV15ct^vGX5EokVbcz8&ALT1{GsD!#0S*5};4RTn3HthntMd zL!m!p80@+4H;{Tg#3 z@5VO1;>)4q(b95*wP--opg~UipAbg%K8Co!fz&Gq9DY0B#PF8cDE5?ZofZA^j3hYR zE;d*ik{)r@e6ZLMF_>h9?!v(d;Ul0BcLI$;M7v1?dKK;%%Dh&bNFWiQib`KH-m*Gf zGuyfv1>a0%WdcZH=G|l?SBU(hOtB!|23nJ|xIP`LeKULd&+V*0#&rB=x`#)!{Y8?N ze}>`Jf1wQg)iQ7ZNqjqLd7Pc8`DQEI_h#8&kcmOa@(^YH&ntznl3=JYGO&B!$Spg@ zRPbCegGSbJj0)e{kV{%Uq+L=@?Wgr(5lT5Ua`BjPwYm~R=q{6WA)gJrMUeDZR9PH_ zb)s5m7KO>!NEuIoM%KPK(9%d^?)Dov%5aSkfo+yqc(;i|Mw&X zw_Pj%P{J{o1FJHRS!JwlHr^mY!}>uE;a5VJ+pIF|R@D z!lPte2)6Bnnfpi+{-$SX{F6P6TU`NG8}+D^!cDPfI3A^o>l2;D7{ihf|686k~O=NfF+=- z(*Je^TQ;3PBFDYf{slA#hSg1bYDTHCOiQII5~8z1Uo@c{>aGl~SY5Uc;(nrbR^>8C z1`fuRR?29isPprdXYeR$(rLz*m*xIoLUHtOdl>=wUt_TJ`}pNTxU>Mx#h+VH;^}J5wHnfkfe->nS|!MOlthmYruV5swq`MtGB8BWfAxSu zfHT6PPSj3LV?Y<~+X}?h>umiy^DSwf%W+^e!;tr2N;C*dQi}eE5Kt8&+3j~ZMD>BR z+LMM-ba_Y_UwNqzxjGS#ySs+jeNlNiE%Et{(wGR|8*6y@B0&6hKDWHtvAbZL6&|Bf zDQ5}g__>X(TCgl&`T(>6`Yht0ZCtvD%wFSgJ~Fbp2>OOLGCh3j&G!C9EJTM7hS#b7 zePvo_E^ykVFY;8o+9GzV9YkQs=>pF|hlp@;Hq8Tk@E@5Qx5+TXcjnO$=q}2ZsEubB z$2dblzeTQH!W@>w9k1@&429#DF|a1x_kBVZ^IZ1eM!SN=+J-=Z4oabki} zX~RJ;AtXOjPRa<}jg+^MUhHj+ZA!dOY5sCWQ8`2ytEE!+_W)t1885KxF_GHY*$@g5 zxBujV7ho}{^H(~WF@eRjHE@YyM`gxBRhBR;kBs1?`kKzawQ zRch{$l0DYvU?oq6?Xh_Y)BD>O+(gRJK8gT*IaDcD(LR1el5sNEeen48J5x4EiQ7CC z%L=8;ek6^scrJ^2uo44uBCZImt6{+o6Tw9-m^YFHKWl6YC1U|cq$|cxN(6iFa|&Qu z6n$0coXCYFw8uuD&64R(L;sF@6NR3DaFvLCj=7CqhmXIVE_*^j zNXr9IK<=S+Bx+jrCjV&QgF;+Z>YtGwO$P43)1gD_(aG?GSP~ob1`54g!W>Q3rrYLn z={r{7{f9gtbZGNzfSP>+tg@p**XmG zg$X24)6R7#L<0f1`>^mM^S!O~FX^@7IT0G1)*Y1j z$v;XjfM^$UO=}a;J>0i~k2@<-%#sg_-|gbftk4iVnjba@#{s(3!~WUpbn@U0Ci%fU zF4-iG(?*k@Izyr+eTYBf6z1TM6MrSgU`o!#u*q`SO3M_5D}-4~I*%w(o8z;`&Xw5j z5Cw(n^_2zFqL+{fju3okAx8a6Nt*iN(iz2fee9LqBVhKMKA41efD)4XKtma`{NT4z z3`P+JK(P6TL*t zogaz2p8pC`qf)A9}aElVJ%s*vsanC%KQojoNqveRHH&r|xK_kF)=IlzXzx+Zl|x09Sh zQVt8joSscpn<~t2J$a{E>1@1DnKJelxtoy;UG7S*F*b7-q#!Blylj06kN(TD;_cZf z<|sZ~*PcH9xhFA^f@7dL7k!mF|My+peC!~`(UEUjbR#N13h40^${NdF!2y5R)1w^7 z-ukzmvs?P$?3de_c6vD82cY>2i%KIY#PTIMq^AhVgrX)slbi$V7gnlsvP}hbYW9|# zESOS*3iDlR2D=EP3EY!t-sM;c$-8Dv{jUkZkNFn+aFzkuprc>ALTbiTV5v?BYm0F0 z*{aY$DI-U1NIvVw?MH|!rvO~Gnk_Yb$tQmV#1`582)09qPU$cT$wXxQVkst0|Ke9F z6H!~sGq?^202DhNNrN?S7nV=KG!my>032IT2)t%O9giuqj5(Rf_Zaso#a~*|yxyEF z;|E2&?1F_8Z>lM?XY)bx+)^yLDQse}7yB`*2VcQSJf$hHk-4VC}QTnKOihM(Df${)^jBR?O?GTr( zz{#A8tUsKA8%`c)$)ltAKGYmolLZ+!pm&-R5pe@u*AmzR5ZhzIA9g$K0%^xm8QSH( zUW@w|qRF#5!G~OX-U?Z-cp^u1&zHBnJm$M!wljk^!#ts)kDDv(0={k4Wfq^y!mHE= zJ*WJ-ppm5g?60JUANnxClY|bs+=5MX4Dqz+!~ytmDI=$-y&9G_K>Z1 zU##A7?rLRyw$MPaLtXv0fLLwIDY#ZxwmqwHgVwq--hW82K-?N^0Ju2(H*${8k#9qX z9R;F4Z$)&cDcI|q03RPP`T)#ZCfh7yf=nUr8`gocr+mBd;@<$9WR*(9jolKmKHLX_9LJQv>HHhh@f+2&aBy_j|8vC)r^%*QuZdtjn0Xgouwxsn72jXSrW3jXa8+z7B9xQ|w~PAm&D57ymg7$o=ez|6f~ze{$pH zGUX5c#!v-)HGAQjN9(o>dRdfWGBJDsLspUD+B`=Mb5QKw!y4V~f|ll%49&F0(X3J_ zQ_jCO;~C}e7@LyuZKFZG!paqnO#aIRci z?XJ=CEVcwOa(>RDTq(R2pYqqdt))B|J*0>gX;4ihxE_c={Jub%wzpX z9q0~2<1Hu~{%^*`?yrl^Bc#3HMWfI4ZsLA5px>sOeroy^*r@_ds{Z!hfoPC4Fc0?D vdftlCj*XRp`1;u%{r~O%@dtVcLq9~BmjD!2L+%@1nyWpp8YL8 z@IYFR(`&jz-P^e-;_uPc#w(J9yNmhK-(mpqY0KB1vakQYqT?6C_mi1+0?=*%o@{A3 zCFA-3jR0peKsK+B*&rks;4VMsUA}hz{z)D03wd@YE9H-&q0Jl5h{q|tqO*4>`ppxO zbumIu5P|che})i`Q@;Jzq!tgAEa%L48P^t4pj}>5TE%;YJ9ztad2`t+h%2F)el8VboY)`24KC1_zVEJa@ulgq%QIMqqR*xn3C*GxcB~^@V<=R(_HPfhdg))4V6Ua zO|cYcC4EX2gpdPRxmaG831Alh>;{183P#F7GD+yAL`?MT89)+*n6i>2YZpBpPFCR` zZf$xwAiR=A(peuWhFUjHm?XU)@9aJ30btG-FeeNmtT(gGA*a+w|@m3sEk`#;(lzl62K?3Q#kT|4@46{G9s)3s{^>2%(JJNw-2^${QM;3cAnk!z-X~BM4ykgt*>K=e}q8Z zSYeErWOi+Zd=Ma*xp>KEE9C2hYVzW1SOA%3cB;@2*47feE0#>YWOz&CTX9d{4;E&& ztG%V{7xujl&S183ifx&eX-O0=Yk2UyrcH?O7XtPg7V}}Ua#&36EFpe}Fdp?8u3yx; zIFFpLVj$EwX<5yGQ)j%l)j{G%V_TlmsKTz_ZGd4dOYT;CBZ%H@!(aKDzJngsM4JN$9aNujsS> zegbcKnf}P>w(Z;9_(PIEL6HBF1U^V8>v96u1Hx!bytmYM_k9ZlYmbZ2sW7Opj9d0A z4P6AnKN{T9bb(U>+qT8@@1t89+p2_qSc!Dun9shh$Mih-15*VdRU0yubnI0@WRi%a z>!V0Plv4(TU{Wk>0b*4ie*V$6#<%GGs*|(aH4)j<_Tz}|Z}ig7%8&k@?=x7YWm*i) z_`n2e1aK^uuCyW$ixGYs0nVW~eMolfFbp!_fzEzde%p9QG&dz-UtsA!{fW8+aJX4hfA-DlP$2B>+CZIEwOI zAWno)kYL@2Vzl!RVO5f-CXNUx7RVi2sub`<0K@>CVSxnTrw3b`_OI^l{kiM_qt4Od zYg)mvl<@so0lzp3khWl~rOC%~Ps{Wqr!^gJ7&Bp?0eP-XWRtNB93_F*S&)Ctr0uz5 z2kzJllvwY@4OS%(=k$tr2&BL308a<&e&@U@y?+^MYkVUB+>lh^ z_t};c{>bUC4n~~~6Na>X7Y!(LJj4)T)bfg)v5U4OLe0S>aMjB0wqHMZ#&6>3WRe@> zae)=%W@>yzZ8qVrNi%QA2wWxze_l;yZHieXnu`^oe)a{p(ol@q{?ph6o1Ovf2S)P4 zOi|c#)ymBG?jMAXL_B7zi}*U?Te4|0nt%K{2V8*BH{0QIhJd4D514mnnbuB0L~`B^J0W8NklWn>;$NkMSuX_1hd2t zxB3D z@(`qp!j~zPM*!#sfLkUvZTjd`oZsXjTx$?3vLdtYoe(HxtzYMv=A#*YCjxvxb^^if zLcXxWVF%4 z-MnsP4iejP3|AB2pH&ihQ_N0yXI)+6iy~qGsx3$u&7V!WzOFQAvLe|jYj3fFUz(Q0 zJ?r=GJ-X;SUP^d}MXejT&|clSS392wB-wFA(O68ySr28y-dx4FWY;Sa^f6YSW5u0=<|jX?YC zw#$)*b9X()HWo_(-+;FbY{yH~lx=s{SrE|bM3ZP&?*IyTTjTh-ZD3%A&nFUSD|AL9 z-qZJcX>EPM5T;*?AWs8b&H8-(>fiPM#Qm;doEfKq(&Qxejg3*`>8D%l=$MH|7bB*Q zY;L-&g5bIY(Y7g8A6VJhy}a-PSarZGqDE)+nGR``RPn=VTjxRn8uzNjy=@c#6^ zRv2R@*nfBZp1x0&^97s;3c*-z0L*cW_G?)oR>40P0OwkQsFSmlqMF6aj?&M*z5Un{ zCjy7fCFt6R^9fn>36usv5-_@t`JQ1k zk-%frw}Z^)Gd6S4{(cB=x)k+ z_B%U&_Ha|v^ZW{XL$xt48n@*wfl|z?@YN8RDa*HJrugd9`uhjNff;FhfIQy*&=fvU z|E_HiTix0Fvw_y8Pu3dTHLl7fLPzA0&mzqDXR>YE|AqEWzj!d;f9LwxS+SV(<$d1> z0Qcu|c-8~2%_8hBuo(KDw=w?k?{j!b{y7@~VyPV74`=!M9B$D0IVBO#o#!*HLy$T~9>^i}oVus??&vymDNgr& zWIU9i$g+vrc=X(j8{&r6GeDdW(>`YoH^l4j!}iWxbiK#RB9MIE+5rZen`w>X-m@PkS0TJ$$rr-$)yLE#N7-X3mkIy;H-*9iU#L~wnz3EHqgFl}vWq^+rYSoo{M({Q7Eb|scLIygtq z+xQ&v%mo>SEGd38@*b0r2&O2O`xTf@Y;Jlnlf2OYZe@VA>fm)tER2ne_!Eal zTAO~F760qF_rAS(yq++V`{q1lI=Hp@e8YSdB0t*-u8$)o6|oiG5?Bz$f*cucYwj^1 z-$~otx3T@;qxo_bHjAzBAYHj>h9Pn>4GpN&1t3Uqc7^+5U?9jHkZgj{d#VazE>D%Kt2agl!1vdynK#td_}T=*XA(qp z=^$(g)6Du9LzpEb6ULnH0h}+n@z)$~YkV_y_1ziHsxCicpmpO5lZ0QWAUa*!$clh2 z@!TCEpgKSkL>CdzMObOCe0cMQx8dGO$6JDtjr`J>9g$lp+qeoO9L#+1@wobA4rlGdRFHS)A!x*4VPlytbQ1~C% z+I(7-m0iU0>S|*W+Mmi_O2ffv!%%+`XHJa`rv-G4DOoRzN{3 z5$VIKG9C=)>~&7~OE{uucvn+JmDxaR<8@WWd=>!LPWWu6nHB2G>d4c-`dPzx47h5A zp-&98HhykLd%LJ5u%fqjNQB;_nrA-fO8CTd;e72W`wl+pX1S9s+|?dn%40;gPOG6` zdg&852L!hT=d>U5Q3&L;KhrF}`X{27epaWd2o)|F2=(DlPO)%`#YV?xS+bTjc>*3D z-rT&S0_jEus2lfLa}6RSqX_|5Mq$h;W;<@#RvJ3L66wdoo131mk3^rraNCBL#gT47 zfYTMMl__dN?=xy+lC=%1R3^ozpQWW zdB4*!yUQ<`NY^O?t<7(&Gv+-C&D94GJ?!um!G0b5RTY8`usYAYd8n=NTI}r1sm84D ze))IPmjAn&h(c)XEDD@3L|Ktvu54`kPp28%+fOdx56E?>WX%D~awvu_8>wI>QqYvn z7y#I;p9^OyryKP77YwB6bIDu)DLh7Bf*4D==qfxI~txhR@${=E&xAHhW;cLWo|5#h&uLG@{ns$Y=-CR6}^PRqr z8IQhJWz3}`zO_-CMd|3ysTPer6|_;G{Up&v)oi|hw5|E9Qi^BE4|PZ2|2VR_@$w3y zAH@kbjrmr`w?nN)@^I|-f_^EM5mmlpmbr?|ODlc2b+n~rm5vwHdTtq@uSD$K)0XeA zpdgdAK&c>jstgl|uR5}&@iLWBwI1}m9Xr(m-C?pwe_ugV=`>2nn>J%qMp;r~>7Ahu zVopB-xlTmcrHMVqEXDGqVJ|_TNvi=x*g3BFem8^cW)I}+p>0$ey(huQ7J)B2!Jp3) z`UI!i{pXGeV1L3vmS%Obo3;hz6j{GMc_OZ_15dLkV}utQ#8nw)HUOrI$Y^v$UnYbz z&pH>uQxAEPV1?w&7lgboCk<6)m~$S2r)|`PZO|NTq*(lFLu)dpsN19NI za7vu$={jqLP;zZw5nbqoPSs)vwi7f2Z_KxP%_b}I742K~bT2jAGooNJ0z2G#&Us0L z|Eh^zF1m#fD;D~YufT5-yf5aH)E72xZLZqT(`?ND@?G1u#X^t0n-$jncK;DZd6(WZ z&F(iHJG%VwJcf@QZf&j7kg;O?aX0XA&pofHHs;a^>-ug+UPVBv3{eK*ldJdj+!Zcm z=3F9?6?s)P(ONZO7MhR~px$(i>s4h1UlnN>U(7G3tc-hvs;!D07n+!DCfSa}M1JMI z3}U<|9qIk*tfK%T12CZWHHPWJ`Q)@SV8djsacg>wAD;kT+$#i}mn)(?YBZ~il8mXfTtjrl zS-J5ys)AAcN0czu(A9TKO89FUGS>9=!fpjp)4tl63u2OfgPwCqM+a+U29b7antBM5 zkDRMqZIH$+-?4g3-FJq@_DYB^^5L6M9MM?3O-v}TH^Xu!=|2o0=Q3A=B$}O zfHm(Q0q(0E&pvXjPft$T6?VJ`47r&;NbfD6z=D?QN${%la{=DsIFfdvf~nHXcd#dy zG<5YotG=smM?+WNGfC)K8Rpw7$vF~ADFIZIRhJIc#!$xcZC3c14PCvL)OYp$c|&*a z(^25EEK8RIP;ga zHi9aH61-DNy(NU#4xfAOnQpKzK=G7g1p%ZoRdwwOW45_w<HQ90=#!>Yx8M}od^|<` zmvT?=cMk>`wv3*?tH#h78BIw)hHsD$X1>MPXK<^2IxX>Ju2Ngj_+zqT@+AXp)GSVm@dI zuW0D(yF&*~^s3u?pwoiB3XpgGqoWJ2r0e+V%l@d`bR_kPv`QMDl#Vfmd z-&Wt<`)~E#_x`xPyXOn5y8B*^h?l0A_h~=mD=Vj9S-8eCr)7M8t>aU@_~BnI&y^E= zy9oGZwsOUshKfD2vWo4sgXcC~5JR|T%9;KJ6Ao7yG#M+`H|#xd`$A0rWBdtL&Q%R) zF4Fl81&mMG${D7G#zW?dZYIz&BH-e_mu%mz9e>hV01@FlaSs>PF*+tr(Dg&_83SCm z`ktOgG%6E1DV<@WpBuXR9tOZ0wcMC{XvI7tP~hp453S#O=;+NFi3%bEt_@wkuKivA zy$ta6Dk7D5Tk6>&;2QnhSX(>q{pw6vMTk=(x2gZBL=c?}h|4aeD zqf|*ZbyVsyLIiiH6`GG+T_~NiZCfyl=IGTc$gEU>D_HYuf|rJ^t^dV_uD(~)?d`iS z;4kk5K=oa{zq0Y{b23)CDydM>IzHLi>{mX#?bJ&3;yZs^@Yk&E?)}lUwg03_hKn8O z2sG}yDD11q$alj?%#1TqucPNp_b`ptUZCBFB;=mJg~!_!q@Muy*GlQb97<)%Vq**2dRV8gq$;$s%8s zbAswBRABRPEVl&!?p$miZtLvy<>E6cMpE!n=fEzM01cO#Y2iET$8zs*!leqMv(vV> z=N-8Nu=k#giM7A&KXSCK`5ji|C%S~l>t{9P)xE!|(qC~Qz~S4}^3e9f$O#T*%?Bos zFRvnO(QvQBZ-w`$8i7z(c~q&9CCvA$DPW2vlNEmZse8MpLd?N}WlAsd`&jv!dUKJ) zg7JS})f9|`3=tr%D?mx)_h#_*sp`2cg-<@2NMSun2w1kZL6{_v@kq4#?};Ey<5V8{ zKl!4bBVjGEg3iBWP4J!dWh%!m>vEQoS745|(a_T~S!(cTvWshj?07mDfCVOUBsL56 z#H(ddMWnO$oh#!jZrA1DB^|Rv$Bvh%qXBPHOQ*=ys*?&=8uUQuqJ><$)HOzPiKiIA z)@el`<=q==j9+v*;}7VvRs9tj_E!EiTsZS;2iv6UZU9)>)pJ{h;U_hk&0gr(vlUwE z1?6)6uzGvo5O#8~6HxOKEc2VRQnb6z!l;5^Ln>bZd zeIvveoSYO+dQk5IuqEn_x@Z%gMt&=Dm_wU;MCjF?tzZ!UQD`Adlq1Rxi?w;df} z2p>d4dqE%ccMP^QeY)1r-zxAZUmi;&oNwp!RLai!bjI+YA5tNmo|E0IC@O|7{bh)5`Ua1!~)>m?o&(ofva9~{X2o_2dg3R za2!ojT68TF)tf{N0`a(BU?(Xa1zj!qMKHs1u9NoJp=&kUy(Hm32AJ%HwU-re6{HGe z30-=;dP~+#Y-_4l=Un-X`06=C z#qP@y5$T2+AoL*(6NPA{dDm|`_^S<3PT&)(I(znp-eR2_Ua2NUsrq?gCphvgzh2K- zKtj|Lnl9+Qvj#N(A$0fW|E7HzEXHsEy1S?02}~jG_Hk3j5f~^3a=-gRi}}w(>-HWR zRab^f7cGcE1fw27YLB$uQn3_%2yxDR7<59LUc}4A=w~;B>fm62HJNO-e%6oSV_5y` zo{0>?UrnpZ5p9js_?secOJ|udOmy9@@H!d8V73)kxqG)af9~) zRtx9V8uE_%_QusKPyM)hh*cpRZQb;OB$`W|8C7N}q1sGABMHJwjaO0mO)JrG(*S9&Sdj2Sbgv>k>p~ z-wXSaxUR;dM)k{;Ni;)64#tsc81cG87hmiwpQTJPGa&;#dF7tIPfhsnv?A@QjA;-rcZ0@HqBv+?Zf5cS9AK=5&yVGUq_o-%}#ClFTMc zng17A5E=%8lYm?&w}2dOU8~NhSNK6-z1Tq`i0F-3O&-JF7CW?_%;XXz0|Z(;<6+H0 zEP&90A`qFuo6L~7^{|$%o;VZ6OIZk|X}F+$jee9QZCgFMbB zAQj$=j2i%8&pi7dxCwszf=w$WV^;>n4GNDBeJx{f`$J0&w~m+NF(>@Ur0~ZLcvGz9 z;vWNvrbg$u((_`$q!30v+#R)pjBGon7JU3njI_8c6jYkZ&pe!I5EWnG%%~3@Zf*Jx zZ0|j|*cu65@futvCk>=h*naMQjc@knF>owRv$Hcd+}89A0=d4z&_#-!E+_ou07jaK zqS;nehF+1)!1|%q#&=m14SJ75wQL2M!EJIp#N?NCrw@Rr7J3d5JIANw8d-#@871~}2=(9Fm3w98fn z*1^>Yxg+OodRmhBCPG}5)?}125LHF25yIE>bE)dTPihLG1T1p7?AY-kEtXOie-J8% zxNn|TW2j=xw_0%Hm~beH(Rr;ou(XyyEVjF_Hd82EB3xt4+To+QD+J)CzKt7Wja&QY zh8quT$8wP5nPQ-MRh5GRA7F>KG(VkK-dSu>hG>6T(+{G5?<7w#I!TcrOtTmhyxutVL^_d*Y?)F#VmdMAV|8#p3n_ zaOV_vSQMXibg-?hZUs!e!2@}p39$~P?p-r;Yg|QO`{Vk$)9w)H&94c!Oral0z1Rta z3x|5Twyy^Gx#j^tQCYYM+@gO+1YVsBz~cpzw9%|U%Q+Bl(Jve8*lGVLw`^=5z15qM(FU5v^8BB6IdfC2Z>?Gi{s!p(O~;;4~!~m zu;?6-?dy{xN98j~L$N7WQRrBfUsMVRnO}iKFRmi`o6#bWP*|;v49Leu|JS;X+Fu?T zTc|8}9^e;aNOK;kv3-LE6bG(#;zE>C*=@o!)L%{HO|sp`3~vI0FV~Q1p0L(e=UDN< zihI^lOXGeJLaZR%8b{iyJ>{2i6bDH*2oqYQ5!hIz^YZ}m*6EN2>8@Y7whQ6FqB~l&y{^7T zMp_$xQ(@>uIdw7|8**;n$0|eT52q@wZvc3ci{?2#!#xXhMh|!m%c?a!BZhE8j3}Pg zU8E3f)|Yez@X_bAHjNL1{BFumy>Z?Cheou(hg#r6N8|6THguuRVcWPhr7jD+cG0mlK%wS(0<3e>BeR zvYBSe9+f*(vze+4X{DG!vGlq%`+DvgYHNITTKLvVLu;q4D>pSu3538&*REO?DV^V) zFHy6M9PH_O zLk!_#no>IpIjX)EgOu{&-&NUaL`lQai(+K^c5-`2Abuk&^5SC8yUxa{c&dp0YIIAJ z2U+jE*wNu~UyfOb9q#SGmZs+;!BGC`v$jn z#mLsCR$_U7Dlfl3xHVv{i$2Bt2#S2rIrr}Q{R2a7jsH-A_>r(2eLkPMTbCH1cMfdc z@a;A2vkJAABo)NDX7aH0;JbqohL-lN=a#lTP@#Dp@-J4 z^h|8n_rKi`Tk)}QBa3Cze-6_AoMH_L(+ef$|7x(U>089IPe5`&bmnl+p8uUBx>UQr zcz3BlKmfWcA+uht%qjjt+USBKFTP?Y}N*aD#)~=kaO9A#{^dAfJ$ymKVag z=UQoRpSva?90v-KgIH&bH*NWzDY1VV?zUsj+rM9-(dN{yzB{LL{DKrqw;~B$Jh)8p z(8rV1oLBRy7|yAJuaHisT!W&E<<=rv$N}pNY25$cQkAJ!>jOTK+h_f;P*ibP(W5cM zbLtGeFJ|Z)o}u;{qEBnxJB_r2m3WK$U$nri2>!#Wdw&0bTWT%UX}l9q_iIFk_v@Np z3v(1ha8=O8nK_QQaivGQ0qIsk_^F`$u#)I=)gHYxO#3a)xOAYM6^1-3^3{gDeLq!y zhgvGND-u^#lX>=3k?_0i^0WYn_5b@(-@75aH?)Lp7h;p4~CCy5W46FcKMdmq! z#(#BKcXPfPpG%_%73B&NXz@uz8MpoolR5A5Q@if{T~u2=>^u#DnYF*`>q<@8%g22F zlKK_&ybx|&P*m~pq)>yJIUt?0Kt2&HTHjZDm`qY-M}hj^?Q4Nm}359 zwJ~n&&ZE0O3{-f6MFV&E69>gv0C-TwW$CH5>&X@pnSEx;POd;gCQ@~0W*4lPEZ z4>KAJQTt*3L0MiRzUOiJ6&$<7SD@&WFL4!IQER_xF-X0@q;J0qNSD@k_3aE{&GQPs ztEp3hujsQWwHtDfZ(MNBvHM$aJ%{pb%v;zbY+$Z77NEq#u$I1kFembZnqUy?l2D;R zDtIB1a%Mk+!qWwE>SLN>J}g47Jhl7LDK~@9zV_nobvKAAfwkRz?;U6V^J=BKLZ3wf z7Wlwi{XS=zgdy%(ez9n*sPIqtd|Se!D;np>dCB0UTvorQ?*~y<;9Psf?TiwT_VoU+->Z2}M)-$UT<2XBBo;bakFFnhU`qewz8f zl+T~D0?(@N?t5wdo}ORl-TS;k;3JphOF!#1bH$Mo=YxfK{(a}4uaoI~*8~xYj?Zty zV9-YbIBy#$GIL&j8=5k!3S9R}nU{~5w!kxhZ=SaNgL>T|AQU#Y8P%*w$9?ZGFtwhE zr7icR5njA{Z%>b#Y>H|jXgaV~u3FG%V&*zt^$iA!H}ner9Ye^Ze@01BZj{?>hXU){U>PBJ*Oc(Bu(%dHi9u zEAM6Rk8_2q?7M4sQL1EnAVUpZy|<>V{G`qpQXq8HhI{<|IKF&j=h8z+QuVhmZW*HC^}3>{GeW9dK2P?VdT_d2Dad*FfvpO+LpiB!CMA zqzM7mgFx+EELjDF2##>vEA;$^TQ^(XO~=ZR70FnKNm>-4AWi|x2m(9;hz~G8F9UW# z&fBwAfl3i03xr=rfFNXjWJGvXx@A@kW?|m!unX_*81b@e|<{igJ!IyV2q7U;zjqDx2o2Bm*K6Oege$z1A*tO#iu`}Ln)7ri@eZU zDkW(GGi{P>Hp}w;l=#=L?YhsEz>l`S3rnZJCo4S80G9dK^G5{E0T>_XZ&!794@h^> zmSD$tbd&6W$VaEywt+x}A1Kazn6Nb<2_x{{p$j+u3hwMLxf3$#rUT?_8M(P>4L~-k z{1Z{0R#_5uKY!QKVgb@3j{`cp&qM5aa!Pl1s+0h#=k&mlBNeMkJnV(gn$=T&d&dL0 zQmCqEyXvLfy8w6xnwv}VHDWCu>FG&b3dcY(+RwjhX{lUYqAfjs(<$kkIup2XYzbXS7*-Q?`%9RhZIW~-B9QlWvr!|7^VNd z^T4C!%F-2Y>N$LF^V2QKOJf8VvPh%m%^`wIJr0s+WLgl0NU&EBe`5@OukP*xoe`hW zVLDERF5I}n=48!O+UDw#gfFQS z#>WyW0V|x*;f?3rzx;{N?(w36J>8c*{PXtJLKFZKwKd$+qub6G_>2y0t5aprB_@jX|2-V-%eeZ2VkW4XWMaUV~V zHp;ZiimJ;S&;^+P#m=P1lTR$P}fvvWzJFnFLez3B6wuJSph^1LOWU`xgI@tpET307*qoM6N<$ Ef)sho`Tzg` literal 0 HcmV?d00001 diff --git a/custom_components/dimplex_wpm/logo@2x.png b/custom_components/dimplex_wpm/logo@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..b933ccb0c78eb2ba9722788a36ea80044a816c8d GIT binary patch literal 28548 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%0AlyDps6A@TK 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

    H$yu!WWZ68|zF)T36L-_qLp;645Qxk_nHOGUUK zNHB>N;u*syCqeIHHhT%_AR?cT+%H6GU^FdR&8u_TtObOt;z$d_R5($VN>Ty}?oREueD}{GP6z$1U0d%f^R6V&wPU{TktM4t9imJrsZgbqBZP1n zt&e3i95o2Xk!|gc2?dZyGcQm3=5hpXG>ofil}d^-ApIH6pHhV}P59cYSL&)MZMqL_ zTJ<38I6QXVAEZ)#SfgRD*Rl3PIm7D-p+zy2l?p1#LY~1m$cuf1-yhoF_CzxrILeuf zRe%RKUA6)k-s2!P%kE#uv`XnaAxQI?a}L7&PdZbg9O7BQj8%c-8@^8BvBaKLnIX19 zg#IK3s6pnJu&4e70LCRGVJ`HEe|E z906^;%TuILin7?x${XXLdImDru;JufnT+QXa{o?X`a6hZ{;p|z|G%731f>`~JyJdJ z2LSMc!A-4?=Z)D)h>}KE(YE<4MnT)Rc{aNU>2F(h?EBoA?dY)qmp3o&W$4)}l)_&> zv})CN>l@v7gQh=m5L!Y~owC;iWM}~9&20-71ozw3h0r@UraQ|d5fBTZC$uMsJDK?l zF2h$B?CO8&T&cnmy}uxi>l}jD=aJu_5Tq7>Di8vf4s@o1puiXT8oKWR_~%OMB2)`o zi&CRioL|cTH^m7}zKqFK$ioxmQBw6LwV6QRpQAEg&1v_+L}TWeg}sMIr;oq!vgBx{ zc8LeHp3vMerOhi4VWmQhOMjNp307Gk*$<$(p`M-BAi!tN*PlgoE6l=#?j6kFKO^EF zB?%LuRY#TTVisj3iR2pQU+@6$*nj)&pTY+o$PHfK+Q2z@D+1mSL!yk)S^^hm?nE|8 zhnz93f0lHdk9MYxXXa{MbAYH#7EtjuglQD>n*S^lMKfLDdfG1Fdome3gVLDIpl>is zV&17BWkw4JFu>r-BS$3VtQ`912y=^|c#Rexb`S$tF03yx$S-w>dD;bV_a!>cVJ8T! z?KmQRaMAYuPv6(wt?ugS2}+<|K>J^`qyN*xo7=K%_+BEco9*3)H8c8{@y{3T-1iO1 z2N$34ne8|&lH*k&YsZ@Z)XaaDKzwE9^hCyzg@*)^ zIqHfDV}9{yXXmGd-R-#${)M&i9@&L;u4&GD_*)6YD<-s&l<;R4^1HyP?1@;iE#Xl_ z^Klp9{`&1t4xOv~Ek68=6s3BK0iK5L?gvKp?rb-}@6ljHaMqM{6NpOcPPnL6Yv#L7 zwzqs`N#DTac@OUmXndtU&7t;H|6K1TUhXjnWwR=0BQl-IBLbDpdz+6P+yekVQd)Hy z!@ox)t^#8NU9l9`HKH2x{XrmgUrp+XM~bdf^?5QGWd+q12O>5&N+_*w8eG5rYUA^36AEqoT1Yf?;pZoK)AdAXkiP8H zWu!LFoU+xD)a7`th$xCGm!SsV`fzVvy!cEF(NLZMyb2_@y(cMIjKl$P!k#^%fnAEi zA;QeKWXH9z+X%v;&0bIAm$A2RGOU$x!Mf+xUmLKTQQgk9Lx?2)!ckc`!~F8nOY8=U zl=W*G=InmOki{&?2& zKiHHyxC{IHbE2RYXpf7@g^K$!==UISwEkziPi9p8rvc~>J%A&1ipZ22r7wK_fyS^H z0A7D$WF)W*hE(CtIvh?b2Q3Sh{g+15lQm`}PPW*DDO=t!qd|BJkoPyt@EQp!4Ba9# z34f_nD{(iL@J9iBw(&ebf0 z>SbXqB6PWgj?^+JCA&d{moT<6a3*i~8|3Bx_howz?H5Ig0UWWWh%$OUC30H13aY}A z2t9FyH0{_YY_K268FR8mDKX2>s02tRh4iO`lD)~dkWMd^&$pG(2iV)I(fFU9(&oW9 zk_g_C1MUg}W>VX0Z-^`WB|^ADl=>0|vY=dw;+@U_f3#^R{d37wfSczwGmjW;A%*9B z_mprQNtpoIMHGL|U}92Pvw{W&b>M??=?m>A>(^VV;VLyb!bJorP7jwVm?iK}w!hutEf@4IKpykFH1^Ps1s|kfF20 z(*%}>g$+*}MS!C@u|a^P9WJdMg)=ZDfVpw{q*WhZn{FThc>k42HvNa}=EFin<#EFY zsa{s;F$-e zHto@rp};y5+WZU={%_M0PaT$Bzsz4rCoHES!MwLbQ}kdLI5e97SD)eAv5;k=gx}kK zcWl`wyo?;$D2iQJw)dRDyW!rN1S>g00KCH2ziN?uU4tga(R^jaSE7h%EsA6T{Q3#1ZW5g zrj(4r(KbQXK!Y9pDiSC}xOh-tS34^R@RERi<}i5p0q_}g%s%mjmRl~?M&k=RO)Mrf zbH@lEZL18H9!PCTZ=^twa5TkS9<-lMc9t8F9+-H0!8ZWMvNrFzbVVdMffde#8pXt3 zbyK+Av62lKdm4;>XywBXXF5}dYxqP`NeX4DyB0MJuCY%!DkILqq^&8rVD~VYn{}K%Xv=#rv zZwZLUng;xV!j8U@1yr6NV|bE*g^Z{4t`##|P;?`Q$o=`4W%>hQsDlbYz>?RVDK~&O z3>BYCWu0wZb>fGzD)z&o%T|28!aIB3e=)+rxXoHF?H<@>LRnQX<{7*elcruiPaO;d z^IW5II#rIY00v15w_MYuM63seY7m+D0RtwmwDROdR=4BFDD%x}< z@d`4L4Zw;<-w%9i5iS-40YG`IlPr)z3S6mU94tO)3j!g)xJx)5ett!KZfWsp3A;`r*4+QA&;0JD@zAJLPsV8NS3I}DY=c{aZY^FwjU*xmV~%U<)u z(~`X~o5i+xW3hYRQ%@4Z6InJWg1tnms-=7llL&;F)hYmqjHG|hE$s7>O4&41gVfrf z)FL=dm3+bL7oJt?vxU$qVOAwdJEdU!IL@#2?C-_@^baWIof?9S`A0F2fzx zXA)>Rb6yN6goz22B6FG){BU7ir~oYPwf-3CgrPBaH3KXU?LK7}YmgrV`I&gRcJ&el z=;OA(}Y;YGXETnWUoS=pcM>*M))7LTr3)2tY-s>E88H=HAc9IRbiJ6Hqt$F)~A z)3whOtBoy~U_GXDh9?wKQ;{$}Z(~J)k#VjgRi823%H2ZIJ998A%B&6PcKOczsgbX? zeb{IIU>#w@q_$SI>P6)<`wfnA$9(I7O4EE+kUG0)X3qc_jk;=)ta2Sj1lv;W>oNfnnq% z1G;u}%XIRD=|h4%YjsH~0B1ktXS={EI;U_VR1h;0nhnI;HVO^n)#%TQ%@t#Xg1uM9 zJ|uPAt~s-Cb3E$;vT>wPD0dCBd8H5SUbsEiza4np-IURH1N(Q)6Qvb?bY^69A zFuZv<4x4~E>Drjj>cBxz+MAdpq_qmmCk&G;G6cACZ12j3MmS;x>`cNHgBYA3_;t1Z z4ArZbX@)yvh{_W~NtNPK2+@(O5oG#I!>)Zle5?~(Oo6p>%om)?_U;`T+}OHpgz?rS zQC(JwH_WjWK+-vqid5RyX+ZqXmc2(#oE`LXptaRm>0fU>3O4LGS_I#tx z4wxf@_1~a*l3m}j({UBngo049ybOvCYr`j8#Esb!eCZ*AMGlc11507ms<<#KhJko< zJ_ahmi?xrJiAya+>2)~}Yhs9ZI*4}`I-y#S^HFHY&4*1ZCo4n)6B8_-bdtggFjDUN z&~)(N*ubW>AIq}2vVo}6)5e#eU6=)?3)KH`;(Y4QaJTh1E5$tLQ6ai!&ZD%ygU2+LJ3wX%WDL^F*AE~M_~hDZJR0{f^S)B5n= z)5J8-1DtjN*tO?ClamXgtu5JN^#O z0$9SI&M3he1HGTby{^>3Kq#oNHr&}74vR->Ea~ql z9|3(w@M!fy6UM?G!PXnGmhXjEK5u33-%`ITsxCr&53AN2d&qSv!4*XBF!{qI;a$;^ zgYgKCC;k;?2{?3nle2;p*xa%C>3pV`V@-C%#VXpp^k#`I$6#;NVK|2-+2dfoP%+#- z${4Q<4Gqn}NW+Up5q1VAVLTp0z?2*Y2(_~Wd5YRfz!m{!fX7nC2r0X4IQit`2AiEA z10)n`F~^98eVwW{@G^}Y__8^ZZIo3ax_4*oI435f)F%2AD|lLU03DcT&_W6?H*}d0 zd-=m7;myKiFc&q3SM-l0yByL-LDo-CAujxMk`>Sfr|tO}emw1i4e?WQ_*z0TB^%1b zc@;&7f2E($NIdP4|SyCBMe5H4_bZQgETEZ!UyaBLzEb$*FxXlYNx5+=(%~ zp8lEG{t}e#xHPI)e4@N=^}2|@;%FuOO~9Ynh*+Dx_W1q`_j7D4hDhl=vdKu0)=Bbk z)%z~P(VVTZ-qlot=@^&#aLoHvmqzyesDxh8l^=Bdo8*&x{}`3p3O&;6ddjY4agTq{X==jHu*{OSksG^I zwg_?c-@$pLSA#7$NBsI(eQXL=S6}|;lTLbDxQvv!pfLA9b@W$0Hdz!HO38uu!yDuu zcOH{OtjQ#!kh}3-Qbkas`Z&glC?${%23klF%>iJx{dD+MlEIKgAK*882^Q@jLA2g> z-aRS$_Q}NdW|tKwuCP8Fy{z_ota?a+Dan#u0+@fy(9`KXlSf^(Ms5)!q`kRiSB-dx z|GgXHX`KMRaBZ>}xrBs20&AEKvTYmiKHRt7{E&3XYUGxUs)!tM{xLF=*ON5o7HLsR zCV?C>?ylMwn__p@KH!w61+N`iQOMopH+?+HP-{YghJJ)9p4RdF3}cBdm81%en0l-G zpNhiQ^=zquwOYeAcaS`EI#<>rnQ#-uQNMUn8gxD_yr1UxTG!+g;{z;tvptN^ z3px_{ZXJA9%H+^4vW3hbX??QcxXnxD^M-F%WUj$z*P_{*Ig>$B`Q$ z0$CX+zf-2vS7SB7G9Y1ge#dPBe%mu>&-jWgw1v~aAUvR*8`9N6!Qi^kd_3|(5SFImw0i^f(UqgRNctwJ!hRI)n1${bSZW9@x7odPv-@a0|$22Mss}e`arC~mm z0JYJ-Kle@+WYwpgM4=W1cOi=k0WKlzk`*Hh|CoWv19^TX0s4HeNVz#Gnryn6=9n@h z^5>IrlUT<=E{|641Eg@Bb97Wt9h~un^Dg_scwPqdwV2S)>ZgNax;ra8|36&GyaI=cv*QN!St$b5FOcB=i|(=%qR-|kJz*MN8vjJz|$t^YBxn9dzjO_$WCC(`S1byIM zOv6J9Bu+8AD+@4yS2Z>{Q{khpteqkqjcgZ28lI!@^6YqlC7VBxScLp)T4w71lNa>K z`Bg2F!shSr1il$`fJIkxr1R#>k!CA=!ZSkU-|m%%xYQRK20$yjLaQ?0RL9SmQ8Ay!<@?YBsl zAw@s8j=a_D0dHC;R2-&PZiO}2wwKf801>9>Z{m9c(l#;5{rXLr|FZVR4|3ARJ(HJ_ z4JBTH@k2~vyCq-|GT!jsCO%55pUiR|@+dX$N^>SK%4cG{TC9 zURx;YT>Lnhr;aBLHrHRARu}TKkz?FsxXiIN5-NWhg{@WU zv%|<89O!++g`9yK!_95SbUp1XBpOLo2`XS!9$V(< z00KqAbBaKgxF$GRM{s8z2nO2Z9?o46lYa;Y&N?ASKaqrgQ!DlmnfI$9?Jl9|>){0- zps&0R zk>c43aP4~Xm+a&sR}IRT6E)u}2z#uE!QY?tCk7D`>c@ayjsKc*?3Kj49;{8S^^ZUE z{F?A4xQcV9Ep9INp<+oC@o3*aUJ}PF$eJuG1>~)q1&`ow$1%(Bibwhh^ z7I*!6ED^?loH`xstzv*<99WO0*K2j^&%x#j;qO|4T!EvcdZZvivTld}5Gd6)rn=%8 zLITwJB`-j5Dz~&=JzfC^08;nie-Kb{gpoOlaaZV@-{^jbiEB zG_ozCa!65$nP9KRE9l|6v>Dkp@H3Q9j(B}`C0pp=yTL9$=bBBeR_c%#m4Q>OcMl{< zc(4)Jy)2T0hw;SvA6v4=9o*+BxVfq9XgfYgiBJ?7Fri2!r=1SW(X@+L6qBPd^!W?$ z6;#G-aBH!^D^TBnU}Y8{Y^uM0+Ct`f?=O^i6l}F$R093pu{=t~0^+U+cGGpOFj;aY zwh7&ZU}2KfexeUuJTMdO^|xalpiXfvxalZvgpb$1tEom&er3tY)069Btx+mhg{iY9 zzBSAl%@o8GeI20rD%Wu65tOvr`a{9a$ugu@0aC^+*qQMm+%ytx)e40tAyBbg5AsH@2@G%~dR$qi+KChB7WX6aYrm z{Eq?O*Zo$}ies-B(WQoUN3iY^T!Jgs&NT$Q$ENAvsYR{y;uq5Vf0GrhJS1;iPShw9zJfALq>V8?^UCCEn$VQ7`U|3|!4_TD+;?>hbL1+( z_^2025m*=$z$Qv?D9G>d_`^1{;8g3GGe!1OI(DGMDx==Xiy+Vk;Q$k!u)|#(UyO3R z4b(3kzMe!&yu;bn{_ZSa@)Erzw-oML;kwnJI{+NkR(+6FKES`-)Fy+^%%T|i%YQU+ z*Aq}J`WG;(mn1wj53e&|IcYr*Da-4@iI~2vTx1&LPC81FGj$41h9}OY4oSgFm@Lgj ziMjRd@O1>2=g(7@=ljcnkJ8){Ovc+y@o<&Xn;ZY-OX@%3KLR5f8P$5`!*!3KJ|)3? z8g{#5{dK>&!<2}r{Y>{WtRb|c?<0Lze4(w;LW{`-6L4k%hkMa$DEg9SPkKCqxgvTU z026<*5^{d0zysYzh-=fuX?m319Z^!jGw;x1e-TzAQc8O{kNO7n$j0O~u|tDl9TpQI zqb@H)9C+fhbBg+4gh21_;}Ieh*0e(es&!?+UZ2=&s{Fk_n~0r-@0Ji6`&u->-rvj1 z&~DEzf0U_+)o_ho{gVWkC*ZqEPMdZbOLltcVM9<5+zb^L%aN+Q_Ze;_`H~RKV_# z-I-g;4dL;C(`C>c@EnOtNFgr{`{=2Zy6^TvWCT1HJH*7h&TzC3Tt5RcR-O+C_cs z0ix8N>oWtBY2L8hhPiC}bCZL6+WlM!B(v2baq;tiaj{+2(50ED!F#W~M*c!fco6Yh z(DFso>`X}VwXMQDgPkR>^k%AiX~df}vmgZ{siHb=JfV2Wj$+^X6Z zoL0@cNR6`{n6Eu>#k?BXgC3%!6*;IT1wuP)e?MRAk1V5~4~WO{?>@eln4SwzVafimk^$wk5EA}uNok*fN1gx^2L+F z;N($-_8MJ-b|Mw%a}O{wfip&ts##0xwJNr!DPQ)OHp>U1bDpP=z`QO2XaT=%`DLcI z?xiq%Rifg+WVMjq$;c}Y%q2_8vQ@`ZIR;I!;@ims`|G{@YCSHbPLI?qX9L1}z<)WC zG_je{>z-yINS04TR6DrkIMrn*J&f>KXT&bN$Wp2et#0C1r2Is$z%2-frk<;}cO8Uw z%ZkPY*pRWi_-H4hooS%eef>f}XAagLReFqYc_fA8E8B^q3 z)ExW_l3BvN@U=^XpryCN2K?lhrCB?~emmPAIZY;Ul-F~i--%?C01!ey?x&N8ikz<|ts8@*<9eMRRuum`us_=2f1T%37GC@ zp!tnhfh^mzu;6`ik{jZKILpP)#@T%5B|g}5+JLO_+xNRV*^ zhKuav(I%?lY9bE>m_3t8C+GB5NcIMhSm;{dh35LX`0UlfH_uc3PfubO87pv_E5#N5Z#1f<)xT3bj4^y?2IY z{gQQO7eK>mkZub#YWvR)Xy-fJ`e#e~=xIsI55vcYQKR+#*sRbv{k~;?XWdxf+02=F z1F+A+;stQ% z;SKBmLI_N}G>G;dG3UvcR~u8>p3_YQ$1_=uD8N2@vwA|(2$U}8R23+aM&ZOf|nb_-N_BAnS8X4Yqto$ zHNZawU|n**VYIdk7xoGz@FC~%w*RT<#eHlMRwIT`bW2tDG9bu3gTuRzZOSjylcI0wnnv@@oIAIQWmYOOXCKqAkiD#^Dq$6m z$ZSOL-60eB7gc5`7HTU(Ghz{!xY$auFXviyMRaa$$5QiRGhybVMDj035BLjUzQvO{ zh&ceLRgS!>RB7_x#J08D8)UXq5N2K#KM#?AC-;P!yS$5^UaAaxz|2BBEky0Tj2Z4b z;*GGSW;F1$Sw=8%Zf06FMtr*PnzX^&>bgYN5o&cky%eLqf}Lla31SaO|F)C$9Xdv$EnTI^SB(RVF|Nju&>>^Yf=i z2D_80^nS~sFG{iI_)`>1>#H#2({FRJTe(8`vDLfrXEj=Yo}@6|iK()vO9?_WF&zvj}IZv}6&Ni%9iz6X82MKbm9m3PmbmE6lq zo~cbQ@Ke|W)iI#qb8xuDXSkWuUAB7-EOsq=V&*|XKa>V||AkbmJJWHbs}iK`1ueiS zD?jkiSm-LCfDa{leHmvo%Y>l)Ak?6yxJ<2MS@c%r$pA?RnB}skB;A7rHX6DC-uGD? zMhdknuZ^%ok8H4=t4Yfz!)!?3IT*>Ed!|jiAOOANl=AVC;j>+{=(X z8LEAfAKVc^>I*9soE!i3`fk3k{tEWI1*{VUA*8<<>+RXJo*;C@Uu%`d`j?+^KgK!P zzm5YbJgA%gVI?HkNv~d$#GrF>r6y8LxW%4~%D{qP z7qpW*Gma1g@aI<5c{9$%nTnlYGLiY=+Bbr(TfapHHQvjk{CI%z%(k1dpHFkr_@m0z`Wfg_rt8(!%GlaCt$XujTnraU7chdnF9K+8$Q7wUp!6PS21|W87|u z0$#)JZ|KmmFi6a^D0-!4-^0N5C4kOA%t)88-fpJGa&q+nJb%2$=gNhAA(+Xsg=`wY zLYvvnUST|kQP%uXx3k2QjzN}E*NOgoiD`dY03&ty;~~($I^4B$Z13YoShEtI=^k$# z(kqU+W>%Li)V>DNtc#@~38d`)KsskCey(8d-3-fcnP4;MxaZe7&M(!lEg{*U&bj#I zDZHuLFMx~m|RThU>ahFM*WI@y#P|f!GXl;^D%;}S%AA{3e&_=1P719 zRVcj1$)v&nvd@Wlt?lKyU)|QT`DBJ!7m5T_Qg5V0$2nxV82BtLN-fw>cGWj_XFEnp zzWJZLn78huqSs#477!HB8(v1`(4_52f$1Q4!a=SM95^3JOpz1DJ#rwr$!Az=JVg!n z19o$Q_(Vf_M1t0)NnK1Mys1g{8EB? zbvi3QGh+%$hnn0kLZh}SX+Gz1_8n|kbW?6z*wNfXyij?a`@(e0495dkaKGU;J!FVR z3xq-fmPc#0U^#yH;+DHwZ35&7f{O^U3F@Bh)zJKHm2@^+4NN~^0&tcLnt`pBvGs2h zlpcxQ)Xq{?nO9QRy<|G%$lGQXr)x~{HWtRpH=huQgz$vN9jG(d{q0G(;MHeKI0luA z%%yR!u8^xkzbYEBf5xa>Pmu5eDf)%%CYchRXM9;c)q8y&$H1{a+nWx9vFLYNFT8J0 ztKdIJCj;`9N{yx|QHv26Bshz9>thf&`!Tt9c4@TsRZ3%@8zSzh&k% z$|yx4bp~U!#q(2dl1(^m9Gc?DsI$wHV@a1fBAsE!^WS|g0E8kJGwT+JYra6csObg>MI12;wjA`0P}I78UR1Z#O@=`R z`7&$3NF3<>SQlD7+y`mSoqH35_>Tj~dfE`jvt(gS&i-7Q`%3b?$6k z9c59Q&$dE%CM3yG^X>$@pUblhEshAIz(Zp&NxQuK%n_UR$=FRk=n<^3AVIaLw7Kd%{~gJq2l_!9cbIB6 zZ5g|kYVpJ;{q19~h*0VB2Z1S18-r;Y5S8iM4S0`7Ey*bJp6R~>|1IlKZ29?jUgI09-im^yfY$A`V+t_M|3 zfH+Y4YzX0i)hdp42WIL@r|GQkYWd2`RLS}>-5`LHism_&2E4-A$0$H8`}GxXFBkf$ zS9~{vOynDvC-$DNm|2vPn5*q{h6vz;;dy%l>uUA$F^0(7e4c1=gPt;zxh9OYpEBy2 z2OFp~$v(<3<{Ls5d+Y$w$62jmu__NfFyfBZ>*4Tq#%3S>G@|JI1h4Ats+42g@IFj5 zn>4Z+FdD`ESJG>##^KDQToCx!%p(c7a`Nu_G$VT}Dem+&%J~#B{0eUbJq`o$?RSl$ z+gCY`3dh9jaP_8P_KyHGMAwjjI8pUEPeYfBm6|e+O^Hv zE@ZoYw-_J1uNlUgfU>8r+?Roc&dTyurl^B9%|qX!8aN2w5lVC92za7%ehX-QGv5h4 zM{sJ_KGV7Xw=VoB;gjdfdujOzi7Ut~&f^>vX^t=VkV6aZVPNtPqsSQ?_trJ!{xYoR zRpNPMYRE2fmII=1_T?enC-O?HkJ73U#sKJ2#*_kSxjHxboM0Bv%jlzTJ8Nl0N9hZ8 z{hsTS&k~!DRiwVh!te_M9xMnPqH9~qAK6KF$y%uH&&ouEn>;B_X_kJI$Q~4Kk*N?= z5gNN{w-^OxacL7z&hj-m;6sVn`W5Y00J9)N&NPhu%g8evcS;O+1e-ka>W7G9L|&ox znMc^)msr+T_5+2ucwJIg8`Z|GOz512v@@Y=Z0OD~TQ?@dC zgv$aFob?uJbG()V#t(IDR69(atF^@AgztTw0)fP&ncI-ue0H7}LLM*QteoAouuTHe zId&*T%W@2q3iE8s4^*rp?zNkTYo9*G?O~!7ns_e~?;!u=!(Y&rud1I^r5@M3cz^yr zlm%pC*luz~eV6$8vj+d6*=k|6QruL-O(AM$o?{&P+cDr7eZ23}VH5K(4!gRSyvKgzy@X5hS?uvZ>qnjD$D6mYU3{f1=+S)0+U5N%+JBGiTV^B~~>`uUlUw$C} z{35gX*KpIdn8kxqtTeZnRgR%Aus@>jI^bopHkzD!K2Np$XUP;#WvB|n%-cT^>CGMR zYZ7KdeU)5*Uf6tAKP7o1o-oPHNaTY2H+I<5v`?AP;}q6JD_8Tyfo{AhCesI3;+y&* zzQ;@EH+?cXF^BRBQId0CF+wsGMzeCJZD*_KX{vHU%wJF!=VCsf9kj%>6z7GN9 zu`ZcuZAwiSi9zU?yTXP~&6Y3& z9*n?-Zn>O%1bgX>G2F#X1#6E6bKA5_-nN6);pJWq;m^ixm0V6Z1CNv6lQgST6PFQW zvfrgi==NTe>|DhgpFOEX@~wT%KMO^5b$Q@htmGy(s0|rx`kggpH_rK+XwNVYg=6f< zR`1==FVA`08vK@a=(+q&=QE5*L-7ZLld$%`XHyzuzpg#q=7*iy$0g1vG_d_mErRU~ zTBL>kz(Vn@_a51%?mx(F+#K?9p!xXWY@-8T1V-mT``d+ujdpz|4r0m@sPFG&iY;BeRbjP zn^s@)K8vUyfsBWZhDzHXQZ&RNoa8BpVpL-|uo1*NRuZqIL{HZ=|HZD_0IfZ)BHS&0 z9@;|$-4k>ZBoiokItv-Rf<`q5xe~AEgCMd#Kt8d~&N3KM7X}WOyp8KKMSp5^dm32i zXHU3_YA_06gYH!o$xt8~9O=cW?$5TW4k*N7W1@l<%^&9t7Q-8l?UEw4<8-u}pJ?)w zN5V$t0$H)@Dw1FJZ~`>|4DK!s`91!Tl;ZPc9-`mtofVBwKO$3bKn<*a6Db6p&d*V2 zQHR!VM`8dincr%%{K*prbuoyxTJ;r;-iL!NearGJUox4=oTKmJ(&uC5mfc=t!U#cs z4-GB07%YljCh1Cnt0&(9BT%e^Qyru^fZ-QBi6myD`L`4gij-j5fB4F61r>3|KiK0n zWpq5#6cqj6y&Neng6>9;`i}7Wp?QQoB6(4Gp#&i1n!?BW14Uoyghp}LQx|3glU` zn~hMtVA1(2>}XAwn=-7HzSY6&+ehNebq90=TAF-?TlZK4%ETC2^fotGm(`c$N5}yJ zI4+$I=KaMZ)t8UqM~2m7V3N}iBck=WW1z<49}ZYQC7L|eo&64i@_9tc6~qE|fR&(O zLJQVCa~kPM^f9TmFmBO2v}XS!KoM$skVt50=@(fkbeG zw4FO~MXUsBqzbksEjyf;An92yOcU?H*!*xE@JjMv$z_|Xz30KS$EAMZdZFOXOS6~* zB4GDy2CkdV+8_?R1?q}yc9PlFV*F{KG}V=qklkXsa?C7haXu(4xNVT)pNdlIB2d&r z825hhm$XCwKxTaGx}&32?Yht`Muon6GTt_-PL#T}JOYr5&*EayeJ7IG4_PyBD0*I0 zDNuG4zzx4##L#?XI>NM796Nt66Elw z{Tht$zk$aBU&<)aUS2&Y$*v4Z(erIRvmu~Et*KAU6e!VLLh!@(d z^-{fCi)FB@qx!yi!C-C>+OtlwKC78wvAiBQG71s+WM+BSE@AiDi*ET{2RyHFB`KIk z-w;BW3w@x1bOxWIuW2OAYc>-#k-uKG_@iX+rdz1S?eUZ0o@jc2Lu)I0ZZ*kleYEdq zLoLHG#(YZ-t2?kw=6JJ3PqjNC^$c2gyQ$oH^3HlbiF69dUA&7VbCJ5s>aFDTJ24gm z4RCGTPE=^54&9JU0NDdAHg2vSN{z0@Hut>kJjoI{CD)hj*vNUHvh;e}M-D5JOg=(a z@2nyIAeze<{0P~lo0Y7m3+k3yGJG}Oond%(-jsx9PA_#e&Nks7ic-#YG8jsR)mN@mWhS^aL6ILgg2i#)2lfqo0+{%uSW!33*1uec&RZfq?IEa6KW9f3}73VQnR|A%VB?f*w*z_45 zFAlVWX0>8S)!sq2mQm`B*_0}#p~vo@yaDQJf|91&JEfH?ey@tnof*5A>?a`#3(~G@ z2}%+;8pU62+8bW5V3bl1ro?x^bm${lu#>L{$9_9cgN+7x<{%oCeu;=tkh~VIdHn|m zxG^~!a{~;TIjS`RPI%BHJ8*SGP-uvWAnO*LS~C~vp6}Yny?D#)ToI9J z)7h)(>E+f?HeZvzE`xcWwd$UTP|ujM%H4o*b$u-cJ7!1vLx}JX9by*@lOqN)=m#5# zCIB|Ho};+Zy;?NRS&vw6Tsn=X{qhWY$*(lSJg}Mkc}3bjElvs;oO>UN5wH! zISBfDFif~bt;VQ>)T7C=^!SGV^}N1>g9#hHBDq(~4vyHR4f;Ca7U=FmP!mGH3-5s& zW3d$!c~9%2BH=j9ms!IhAlsz!E#>gKyq%w|d)2e$C$9;r>4!I{9C=q}o@&ksNJ4Ud z^%iKOU|C@b?B{54cc+Dw-PTOs*NZmQmbLu}d<)5Ks;-H5LTb>hVEks;Y&nxkMz*|_ zEwPqt{3QvV7}SlsTOalCnO-VY4Fk3Kai+g(#{PCa!i56JRz%0@pG}M@Wg=P0^>q6_7=BxLHC#vY})fMc1K%iP!pRF(oyv3>! zT7hkK?y>M+V`_J-2OA|P<_+P!UV5YmGs|4ZIa9$n;=SUDmRlA9v3BJw&%`@^!;hmH6Kz z_s}5OSYCSm=#WN@1Td?=1^C$6ji$P29RAm2PewvfyhhYG`2PWERn=Yq literal 0 HcmV?d00001 diff --git a/custom_components/dimplex_wpm/translations/de.json b/custom_components/dimplex_wpm/translations/de.json new file mode 100644 index 0000000..eaf1d98 --- /dev/null +++ b/custom_components/dimplex_wpm/translations/de.json @@ -0,0 +1,57 @@ +{ + "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)" + } + } + } + } +} diff --git a/custom_components/dimplex_wpm/translations/pl.json b/custom_components/dimplex_wpm/translations/pl.json new file mode 100644 index 0000000..2f46a00 --- /dev/null +++ b/custom_components/dimplex_wpm/translations/pl.json @@ -0,0 +1,57 @@ +{ + "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)" + } + } + } + } +} From e822c0b39f092b0b8f078bccf046a84862c76669 Mon Sep 17 00:00:00 2001 From: nb3rt Date: Sun, 21 Jun 2026 22:26:50 +0200 Subject: [PATCH 09/13] Move brand images into brand/ subfolder (HA 2026.3 in-package brands) Per developers.home-assistant.io/blog/2026/02/24/brands-proxy-api and the integration file-structure docs, in-package brand images must live in custom_components//brand/ (local images override the brands CDN via the new /api/brands proxy; no manifest change needed). Moved icon/icon@2x/logo/ logo@2x there. Co-Authored-By: Claude Opus 4.8 --- custom_components/dimplex_wpm/{ => brand}/icon.png | Bin .../dimplex_wpm/{ => brand}/icon@2x.png | Bin custom_components/dimplex_wpm/{ => brand}/logo.png | Bin .../dimplex_wpm/{ => brand}/logo@2x.png | Bin 4 files changed, 0 insertions(+), 0 deletions(-) rename custom_components/dimplex_wpm/{ => brand}/icon.png (100%) rename custom_components/dimplex_wpm/{ => brand}/icon@2x.png (100%) rename custom_components/dimplex_wpm/{ => brand}/logo.png (100%) rename custom_components/dimplex_wpm/{ => brand}/logo@2x.png (100%) diff --git a/custom_components/dimplex_wpm/icon.png b/custom_components/dimplex_wpm/brand/icon.png similarity index 100% rename from custom_components/dimplex_wpm/icon.png rename to custom_components/dimplex_wpm/brand/icon.png diff --git a/custom_components/dimplex_wpm/icon@2x.png b/custom_components/dimplex_wpm/brand/icon@2x.png similarity index 100% rename from custom_components/dimplex_wpm/icon@2x.png rename to custom_components/dimplex_wpm/brand/icon@2x.png diff --git a/custom_components/dimplex_wpm/logo.png b/custom_components/dimplex_wpm/brand/logo.png similarity index 100% rename from custom_components/dimplex_wpm/logo.png rename to custom_components/dimplex_wpm/brand/logo.png diff --git a/custom_components/dimplex_wpm/logo@2x.png b/custom_components/dimplex_wpm/brand/logo@2x.png similarity index 100% rename from custom_components/dimplex_wpm/logo@2x.png rename to custom_components/dimplex_wpm/brand/logo@2x.png From 658b26712488f4cf0cad9c401114849a719adb09 Mon Sep 17 00:00:00 2001 From: nb3rt Date: Sun, 21 Jun 2026 22:35:38 +0200 Subject: [PATCH 10/13] Climate (HC1/DHW thermostats) + apexcharts dashboard cards - climate.py: HC1 and DHW thermostats behind the control gate. Single HEAT mode; current temperature from a read value (room/return for HC1, DHW temp for DHW), target temperature reuses the writable setpoint WriteSpec (encoding, range and read-back shared with the number platform). Platform.CLIMATE added. - dashboards: Control view gains thermostat cards (visibility-gated); Heat & Energy view gains optional apexcharts-card section (dual-axis power/COP + stacked thermal) with the built-in history-graph kept as fallback. - docs: ENTITY_IDS + README updated (climate now shipped). ruff clean, 16/16 pytest, dashboard YAML valid. Co-Authored-By: Claude Opus 4.8 --- README.md | 4 +- custom_components/dimplex_wpm/__init__.py | 1 + custom_components/dimplex_wpm/climate.py | 133 ++++++++++++++++++++++ dashboards/dimplex_wpm.yaml | 61 ++++++++++ spec/ENTITY_IDS.md | 4 + 5 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 custom_components/dimplex_wpm/climate.py diff --git a/README.md b/README.md index 16adc29..63684b2 100644 --- a/README.md +++ b/README.md @@ -81,8 +81,8 @@ not add them as grid consumption. - **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** (thermostat) entities for HC1/DHW are planned but not yet shipped; - setpoint control is via `number` entities for now. +- **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. diff --git a/custom_components/dimplex_wpm/__init__.py b/custom_components/dimplex_wpm/__init__.py index ee852bc..2ec4faa 100644 --- a/custom_components/dimplex_wpm/__init__.py +++ b/custom_components/dimplex_wpm/__init__.py @@ -41,6 +41,7 @@ Platform.BINARY_SENSOR, Platform.NUMBER, Platform.SELECT, + Platform.CLIMATE, ] diff --git a/custom_components/dimplex_wpm/climate.py b/custom_components/dimplex_wpm/climate.py new file mode 100644 index 0000000..ee8aa97 --- /dev/null +++ b/custom_components/dimplex_wpm/climate.py @@ -0,0 +1,133 @@ +"""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 + + +@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/dashboards/dimplex_wpm.yaml b/dashboards/dimplex_wpm.yaml index 96634a6..a0c962a 100644 --- a/dashboards/dimplex_wpm.yaml +++ b/dashboards/dimplex_wpm.yaml @@ -113,6 +113,51 @@ views: **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 @@ -242,6 +287,22 @@ views: 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 diff --git a/spec/ENTITY_IDS.md b/spec/ENTITY_IDS.md index 199bfb9..ab3493f 100644 --- a/spec/ENTITY_IDS.md +++ b/spec/ENTITY_IDS.md @@ -61,6 +61,8 @@ is standard Home Assistant behaviour with `has_entity_name`. `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` @@ -68,6 +70,8 @@ is standard Home Assistant behaviour with `has_entity_name`. `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` From 420c163aa3aadc9f7389d62f8029e2d5034b139b Mon Sep 17 00:00:00 2001 From: nb3rt Date: Sun, 21 Jun 2026 22:42:07 +0200 Subject: [PATCH 11/13] Localize entity names (translation_key + en/pl/de) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entities now use translation_key instead of _attr_name, so names localize while entity_ids stay English-derived and language-independent. - entity.py: _apply_common sets _attr_translation_key = key. SG Ready select uses translation_key too. - strings.json + translations/en.json: full entity{} section (129 names); English strings equal the former _attr_name, so entity_ids are unchanged... - ...except the 4 integration energy sensors whose names were dynamic ("(est.)"/"(meter)"): fixed to stable names → ids drop the _est suffix (analytics_electrical_energy / heat_energy / heat_energy_to_house / heat_energy_to_installation). Dashboard + ENTITY_IDS updated accordingly. - translations/pl.json + de.json: 129 localized entity names each (full coverage; key structure matches strings.json — hassfest-compatible). - ENTITY_IDS.md: header note updated (translation_key → English-derived ids). ruff clean, 16/16 pytest, all translation files key-match strings.json. Co-Authored-By: Claude Opus 4.8 --- custom_components/dimplex_wpm/entity.py | 6 +- custom_components/dimplex_wpm/select.py | 2 +- custom_components/dimplex_wpm/strings.json | 399 ++++++++++++++++++ .../dimplex_wpm/translations/de.json | 399 ++++++++++++++++++ .../dimplex_wpm/translations/en.json | 399 ++++++++++++++++++ .../dimplex_wpm/translations/pl.json | 399 ++++++++++++++++++ dashboards/dimplex_wpm.yaml | 12 +- spec/ENTITY_IDS.md | 17 +- 8 files changed, 1616 insertions(+), 17 deletions(-) diff --git a/custom_components/dimplex_wpm/entity.py b/custom_components/dimplex_wpm/entity.py index 0310581..81ead5d 100644 --- a/custom_components/dimplex_wpm/entity.py +++ b/custom_components/dimplex_wpm/entity.py @@ -31,8 +31,10 @@ def _apply_common( ) -> None: self._dimplex_key = key self._attr_unique_id = f"{entry.entry_id}_{module}_{key}" - if name is not None: - self._attr_name = name + # 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, diff --git a/custom_components/dimplex_wpm/select.py b/custom_components/dimplex_wpm/select.py index 7fc6182..7607831 100644 --- a/custom_components/dimplex_wpm/select.py +++ b/custom_components/dimplex_wpm/select.py @@ -57,7 +57,7 @@ class DimplexSGReadySelect(CoordinatorEntity, SelectEntity): """Representation of the SG Ready mode select.""" _attr_has_entity_name = True - _attr_name = "SG Ready mode" + _attr_translation_key = "sg_ready_mode" def __init__( self, diff --git a/custom_components/dimplex_wpm/strings.json b/custom_components/dimplex_wpm/strings.json index c8d1b97..013190f 100644 --- a/custom_components/dimplex_wpm/strings.json +++ b/custom_components/dimplex_wpm/strings.json @@ -53,5 +53,404 @@ } } } + }, + "entity": { + "sensor": { + "alpha_house": { + "name": "House heat fraction" + }, + "compressor_power_estimated": { + "name": "Compressor power (est.)" + }, + "controller_info": { + "name": "Controller info" + }, + "cooling_flow_temperature": { + "name": "Passive cooling flow temperature" + }, + "cooling_primary_return_temperature": { + "name": "Cooling primary return temperature" + }, + "cooling_return_temperature": { + "name": "Passive cooling return temperature" + }, + "cop_estimated": { + "name": "COP (est.)" + }, + "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" + }, + "electrical_energy_kwh": { + "name": "Electrical energy" + }, + "electrical_power": { + "name": "Electrical power" + }, + "electrical_power_best": { + "name": "Electrical power" + }, + "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" + }, + "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" + }, + "sensor_error_text": { + "name": "Sensor error" + }, + "sg_ready_code": { + "name": "SG Ready code" + }, + "sg_ready_text": { + "name": "SG Ready state" + }, + "solar_collector_temperature": { + "name": "Solar collector temperature" + }, + "solar_tank_temperature": { + "name": "Solar 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": "Ventilation exhaust air temperature" + }, + "vent_extract_air_temperature": { + "name": "Ventilation extract air temperature" + }, + "vent_extract_fan_speed": { + "name": "Ventilation extract fan speed" + }, + "vent_level": { + "name": "Ventilation level" + }, + "vent_outdoor_air_temperature": { + "name": "Ventilation outdoor air temperature" + }, + "vent_supply_air_temperature": { + "name": "Ventilation supply air temperature" + }, + "vent_supply_fan_speed": { + "name": "Ventilation 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 index eaf1d98..84d012f 100644 --- a/custom_components/dimplex_wpm/translations/de.json +++ b/custom_components/dimplex_wpm/translations/de.json @@ -53,5 +53,404 @@ } } } + }, + "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" + }, + "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": "Lüftung Fortlufttemperatur" + }, + "vent_extract_air_temperature": { + "name": "Lüftung Ablufttemperatur" + }, + "vent_extract_fan_speed": { + "name": "Lüftung Abluftventilatordrehzahl" + }, + "vent_level": { + "name": "Lüftungsstufe" + }, + "vent_outdoor_air_temperature": { + "name": "Lüftung Außenlufttemperatur" + }, + "vent_supply_air_temperature": { + "name": "Lüftung Zulufttemperatur" + }, + "vent_supply_fan_speed": { + "name": "Lüftung 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 c8d1b97..013190f 100644 --- a/custom_components/dimplex_wpm/translations/en.json +++ b/custom_components/dimplex_wpm/translations/en.json @@ -53,5 +53,404 @@ } } } + }, + "entity": { + "sensor": { + "alpha_house": { + "name": "House heat fraction" + }, + "compressor_power_estimated": { + "name": "Compressor power (est.)" + }, + "controller_info": { + "name": "Controller info" + }, + "cooling_flow_temperature": { + "name": "Passive cooling flow temperature" + }, + "cooling_primary_return_temperature": { + "name": "Cooling primary return temperature" + }, + "cooling_return_temperature": { + "name": "Passive cooling return temperature" + }, + "cop_estimated": { + "name": "COP (est.)" + }, + "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" + }, + "electrical_energy_kwh": { + "name": "Electrical energy" + }, + "electrical_power": { + "name": "Electrical power" + }, + "electrical_power_best": { + "name": "Electrical power" + }, + "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" + }, + "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" + }, + "sensor_error_text": { + "name": "Sensor error" + }, + "sg_ready_code": { + "name": "SG Ready code" + }, + "sg_ready_text": { + "name": "SG Ready state" + }, + "solar_collector_temperature": { + "name": "Solar collector temperature" + }, + "solar_tank_temperature": { + "name": "Solar 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": "Ventilation exhaust air temperature" + }, + "vent_extract_air_temperature": { + "name": "Ventilation extract air temperature" + }, + "vent_extract_fan_speed": { + "name": "Ventilation extract fan speed" + }, + "vent_level": { + "name": "Ventilation level" + }, + "vent_outdoor_air_temperature": { + "name": "Ventilation outdoor air temperature" + }, + "vent_supply_air_temperature": { + "name": "Ventilation supply air temperature" + }, + "vent_supply_fan_speed": { + "name": "Ventilation 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 index 2f46a00..37e2afc 100644 --- a/custom_components/dimplex_wpm/translations/pl.json +++ b/custom_components/dimplex_wpm/translations/pl.json @@ -53,5 +53,404 @@ } } } + }, + "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" + }, + "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 index a0c962a..a70924a 100644 --- a/dashboards/dimplex_wpm.yaml +++ b/dashboards/dimplex_wpm.yaml @@ -205,15 +205,15 @@ views: - change chart_type: bar entities: - - entity: sensor.analytics_electrical_energy_est - - entity: sensor.analytics_heat_energy_to_house_est + - entity: sensor.analytics_electrical_energy + - entity: sensor.analytics_heat_energy_to_house - type: entities title: Energy totals entities: - - entity: sensor.analytics_electrical_energy_est - - entity: sensor.analytics_heat_energy_est - - entity: sensor.analytics_heat_energy_to_house_est - - entity: sensor.analytics_heat_energy_to_installation_est + - 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 diff --git a/spec/ENTITY_IDS.md b/spec/ENTITY_IDS.md index ab3493f..b0d43b3 100644 --- a/spec/ENTITY_IDS.md +++ b/spec/ENTITY_IDS.md @@ -1,9 +1,10 @@ # Dimplex WPM — entity_id reference (derived scheme) -> How HA builds these (confirmed by the pre-M3 audit): entities use -> `has_entity_name = True` with `_attr_name`, **no** `translation_key` / -> `suggested_object_id`. So `entity_id = .slugify(" ")`. -> The dashboards in `dashboards/` reference exactly these ids. +> 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. @@ -85,9 +86,9 @@ is standard Home Assistant behaviour with `has_entity_name`. - `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_est`, - `sensor.analytics_heat_energy_est`, `sensor.analytics_heat_energy_to_house_est`, - `sensor.analytics_heat_energy_to_installation_est` +- 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`, @@ -96,7 +97,7 @@ is standard Home Assistant behaviour with `has_entity_name`. 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_est` is then suppressed (no double count). +the estimated `..._heat_energy` is then suppressed (no double count). With an electric meter: `analytics_electrical_power` source becomes `measured`. ## Optional modules (only when enabled) From f397feca73ed5d91476e8b2e09b3f746795a9949 Mon Sep 17 00:00:00 2001 From: nb3rt Date: Sun, 21 Jun 2026 22:46:48 +0200 Subject: [PATCH 12/13] Quality scale: parallel-updates, diagnostics, quality_scale.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toward Silver/Gold: - PARALLEL_UPDATES on every platform (0 for read, 1 to serialize writes). - diagnostics.py: config-entry diagnostics (config + coordinator data, host redacted) — Gold "diagnostics" rule. - quality_scale.yaml: honest done/todo/exempt across Bronze→Platinum. - README: Removal section (Bronze docs-removal) + quality-scale note. Bronze/Silver largely met; documented gaps: HA-runtime test coverage, entry.runtime_data, and Gold polish (icon/exception translations, reconfigure). Co-Authored-By: Claude Opus 4.8 --- README.md | 14 +++ .../dimplex_wpm/binary_sensor.py | 2 + custom_components/dimplex_wpm/climate.py | 2 + custom_components/dimplex_wpm/diagnostics.py | 42 ++++++++ custom_components/dimplex_wpm/number.py | 2 + .../dimplex_wpm/quality_scale.yaml | 99 +++++++++++++++++++ custom_components/dimplex_wpm/select.py | 2 + custom_components/dimplex_wpm/sensor.py | 2 + 8 files changed, 165 insertions(+) create mode 100644 custom_components/dimplex_wpm/diagnostics.py create mode 100644 custom_components/dimplex_wpm/quality_scale.yaml diff --git a/README.md b/README.md index 63684b2..0b5ccb0 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,12 @@ device probe; without a meter the value is estimated. 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 → @@ -92,3 +98,11 @@ 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/binary_sensor.py b/custom_components/dimplex_wpm/binary_sensor.py index 5c25f83..5aeabc7 100644 --- a/custom_components/dimplex_wpm/binary_sensor.py +++ b/custom_components/dimplex_wpm/binary_sensor.py @@ -16,6 +16,8 @@ from .entity import DimplexEntityMixin from .registers import COIL, RegisterSpec +PARALLEL_UPDATES = 0 # read-only, coordinator-driven + DEVICE_CLASS_MAP = { "running": BinarySensorDeviceClass.RUNNING, "problem": BinarySensorDeviceClass.PROBLEM, diff --git a/custom_components/dimplex_wpm/climate.py b/custom_components/dimplex_wpm/climate.py index ee8aa97..141d5d9 100644 --- a/custom_components/dimplex_wpm/climate.py +++ b/custom_components/dimplex_wpm/climate.py @@ -28,6 +28,8 @@ from .entity import DimplexEntityMixin from .registers import M_DHW, M_HC1 +PARALLEL_UPDATES = 1 # serialize writes to the controller + @dataclass(frozen=True) class ClimateDef: 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/number.py b/custom_components/dimplex_wpm/number.py index 1dbcc02..4dc9fbb 100644 --- a/custom_components/dimplex_wpm/number.py +++ b/custom_components/dimplex_wpm/number.py @@ -28,6 +28,8 @@ 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} 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/select.py b/custom_components/dimplex_wpm/select.py index 7607831..3745541 100644 --- a/custom_components/dimplex_wpm/select.py +++ b/custom_components/dimplex_wpm/select.py @@ -22,6 +22,8 @@ from .entity import DimplexEntityMixin from .registers import KIND_SELECT, WriteSpec +PARALLEL_UPDATES = 1 # serialize writes to the controller + async def async_setup_entry( hass: HomeAssistant, diff --git a/custom_components/dimplex_wpm/sensor.py b/custom_components/dimplex_wpm/sensor.py index 151da2b..c285efb 100644 --- a/custom_components/dimplex_wpm/sensor.py +++ b/custom_components/dimplex_wpm/sensor.py @@ -30,6 +30,8 @@ RegisterSpec, ) +PARALLEL_UPDATES = 0 # read-only, coordinator-driven + DEVICE_CLASS_MAP = { "temperature": SensorDeviceClass.TEMPERATURE, "power": SensorDeviceClass.POWER, From 0d32ab38bece8d0b784b26cfc4424c674cae0c79 Mon Sep 17 00:00:00 2001 From: nb3rt Date: Sun, 21 Jun 2026 22:58:57 +0200 Subject: [PATCH 13/13] Address pre-release review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the adversarial pre-release review (no blockers; these are the real ones): - modbus_client.write_register: map ConnectionError/OSError → ModbusException so control writes to an offline device surface a friendly HomeAssistantError instead of an unhandled traceback. - Fix entity_id collision: raw register sensor 5170 renamed "Electrical power" → "Electrical power (meter)" (id analytics_electrical_power_meter) so it no longer clashes with the canonical "Electrical power" (electrical_power_best). - controller_info: replace the always-zero "consecutive_failures" attribute with "last_update_success". - Naming/UX: drop the redundant device word from ventilation/passive-cooling/ solar entity names (was producing doubled display names + ids like ventilation_ventilation_*); regenerated en/strings, patched pl/de (incl. stripping the German "Lüftung " prefix). ENTITY_IDS.md updated. - select.py: read software_version from the coordinator (consistency). ruff clean, 16/16 pytest, en/pl/de key-parity with strings.json, dashboard valid. Co-Authored-By: Claude Opus 4.8 --- .../dimplex_wpm/modbus_client.py | 7 ++++- custom_components/dimplex_wpm/registers.py | 26 +++++++++---------- custom_components/dimplex_wpm/select.py | 2 +- custom_components/dimplex_wpm/sensor.py | 2 +- custom_components/dimplex_wpm/strings.json | 26 +++++++++---------- .../dimplex_wpm/translations/de.json | 14 +++++----- .../dimplex_wpm/translations/en.json | 26 +++++++++---------- .../dimplex_wpm/translations/pl.json | 2 +- spec/ENTITY_IDS.md | 11 +++++--- 9 files changed, 63 insertions(+), 53 deletions(-) diff --git a/custom_components/dimplex_wpm/modbus_client.py b/custom_components/dimplex_wpm/modbus_client.py index 9d0359b..70ddf29 100644 --- a/custom_components/dimplex_wpm/modbus_client.py +++ b/custom_components/dimplex_wpm/modbus_client.py @@ -97,8 +97,8 @@ 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) @@ -106,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/registers.py b/custom_components/dimplex_wpm/registers.py index 2a6361c..9aa9dfd 100644 --- a/custom_components/dimplex_wpm/registers.py +++ b/custom_components/dimplex_wpm/registers.py @@ -227,25 +227,25 @@ def _temp(key, address, module, name, *, category=None, flag=None, cap=None, re= _temp("source_outlet_temperature", 7, M_SOURCE, "Source outlet temperature"), # ===== Passive cooling (optional) ===== - _temp("cooling_flow_temperature", 19, M_COOLING, "Passive cooling flow temperature", flag=M_COOLING), - _temp("cooling_return_temperature", 20, M_COOLING, "Passive cooling return temperature", flag=M_COOLING), - _temp("cooling_primary_return_temperature", 21, M_COOLING, "Cooling primary return temperature", flag=M_COOLING), + _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, "Solar collector temperature", flag=M_SOLAR, re=False), - _temp("solar_tank_temperature", 23, M_SOLAR, "Solar tank temperature", flag=M_SOLAR), + _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, "Ventilation outdoor air temperature", flag=M_VENT), - _temp("vent_supply_air_temperature", 121, M_VENT, "Ventilation supply air temperature", flag=M_VENT), - _temp("vent_extract_air_temperature", 122, M_VENT, "Ventilation extract air temperature", flag=M_VENT), - _temp("vent_exhaust_air_temperature", 123, M_VENT, "Ventilation exhaust air temperature", flag=M_VENT), + _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="Ventilation supply fan speed", icon="mdi:fan"), + 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="Ventilation extract fan speed", icon="mdi:fan"), + 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="Ventilation level", icon="mdi:fan-chevron-up"), + 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"), @@ -258,7 +258,7 @@ def _temp(key, address, module, name, *, category=None, flag=None, cap=None, re= 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"), + 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, diff --git a/custom_components/dimplex_wpm/select.py b/custom_components/dimplex_wpm/select.py index 3745541..2c763e9 100644 --- a/custom_components/dimplex_wpm/select.py +++ b/custom_components/dimplex_wpm/select.py @@ -39,7 +39,7 @@ async def async_setup_entry( return host = data.get("host") - version = data.get("software_version") + version = coordinator.software_version model = data.get("model") entities: list[SelectEntity] = [ diff --git a/custom_components/dimplex_wpm/sensor.py b/custom_components/dimplex_wpm/sensor.py index c285efb..4cf264d 100644 --- a/custom_components/dimplex_wpm/sensor.py +++ b/custom_components/dimplex_wpm/sensor.py @@ -242,7 +242,7 @@ def extra_state_attributes(self) -> dict[str, Any]: "unit_id": meta.get("unit_id"), "profile": meta.get("profile"), "last_update": meta.get("last_update"), - "consecutive_failures": meta.get("consecutive_failures"), + "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), diff --git a/custom_components/dimplex_wpm/strings.json b/custom_components/dimplex_wpm/strings.json index 013190f..2f82777 100644 --- a/custom_components/dimplex_wpm/strings.json +++ b/custom_components/dimplex_wpm/strings.json @@ -66,13 +66,13 @@ "name": "Controller info" }, "cooling_flow_temperature": { - "name": "Passive cooling flow temperature" + "name": "Flow temperature" }, "cooling_primary_return_temperature": { - "name": "Cooling primary return temperature" + "name": "Primary return temperature" }, "cooling_return_temperature": { - "name": "Passive cooling return temperature" + "name": "Return temperature" }, "cop_estimated": { "name": "COP (est.)" @@ -96,7 +96,7 @@ "name": "Electrical energy" }, "electrical_power": { - "name": "Electrical power" + "name": "Electrical power (meter)" }, "electrical_power_best": { "name": "Electrical power" @@ -240,10 +240,10 @@ "name": "SG Ready state" }, "solar_collector_temperature": { - "name": "Solar collector temperature" + "name": "Collector temperature" }, "solar_tank_temperature": { - "name": "Solar tank temperature" + "name": "Tank temperature" }, "source_inlet_temperature": { "name": "Source inlet temperature" @@ -279,25 +279,25 @@ "name": "Total electrical power (est.)" }, "vent_exhaust_air_temperature": { - "name": "Ventilation exhaust air temperature" + "name": "Exhaust air temperature" }, "vent_extract_air_temperature": { - "name": "Ventilation extract air temperature" + "name": "Extract air temperature" }, "vent_extract_fan_speed": { - "name": "Ventilation extract fan speed" + "name": "Extract fan speed" }, "vent_level": { - "name": "Ventilation level" + "name": "Fan level" }, "vent_outdoor_air_temperature": { - "name": "Ventilation outdoor air temperature" + "name": "Outdoor air temperature" }, "vent_supply_air_temperature": { - "name": "Ventilation supply air temperature" + "name": "Supply air temperature" }, "vent_supply_fan_speed": { - "name": "Ventilation supply fan speed" + "name": "Supply fan speed" } }, "binary_sensor": { diff --git a/custom_components/dimplex_wpm/translations/de.json b/custom_components/dimplex_wpm/translations/de.json index 84d012f..bdfa1af 100644 --- a/custom_components/dimplex_wpm/translations/de.json +++ b/custom_components/dimplex_wpm/translations/de.json @@ -96,7 +96,7 @@ "name": "Elektrische Energie" }, "electrical_power": { - "name": "Elektrische Leistung" + "name": "Elektrische Leistung (Zähler)" }, "electrical_power_best": { "name": "Elektrische Leistung" @@ -279,25 +279,25 @@ "name": "Gesamte elektrische Leistung (geschätzt)" }, "vent_exhaust_air_temperature": { - "name": "Lüftung Fortlufttemperatur" + "name": "Fortlufttemperatur" }, "vent_extract_air_temperature": { - "name": "Lüftung Ablufttemperatur" + "name": "Ablufttemperatur" }, "vent_extract_fan_speed": { - "name": "Lüftung Abluftventilatordrehzahl" + "name": "Abluftventilatordrehzahl" }, "vent_level": { "name": "Lüftungsstufe" }, "vent_outdoor_air_temperature": { - "name": "Lüftung Außenlufttemperatur" + "name": "Außenlufttemperatur" }, "vent_supply_air_temperature": { - "name": "Lüftung Zulufttemperatur" + "name": "Zulufttemperatur" }, "vent_supply_fan_speed": { - "name": "Lüftung Zuluftventilatordrehzahl" + "name": "Zuluftventilatordrehzahl" } }, "binary_sensor": { diff --git a/custom_components/dimplex_wpm/translations/en.json b/custom_components/dimplex_wpm/translations/en.json index 013190f..2f82777 100644 --- a/custom_components/dimplex_wpm/translations/en.json +++ b/custom_components/dimplex_wpm/translations/en.json @@ -66,13 +66,13 @@ "name": "Controller info" }, "cooling_flow_temperature": { - "name": "Passive cooling flow temperature" + "name": "Flow temperature" }, "cooling_primary_return_temperature": { - "name": "Cooling primary return temperature" + "name": "Primary return temperature" }, "cooling_return_temperature": { - "name": "Passive cooling return temperature" + "name": "Return temperature" }, "cop_estimated": { "name": "COP (est.)" @@ -96,7 +96,7 @@ "name": "Electrical energy" }, "electrical_power": { - "name": "Electrical power" + "name": "Electrical power (meter)" }, "electrical_power_best": { "name": "Electrical power" @@ -240,10 +240,10 @@ "name": "SG Ready state" }, "solar_collector_temperature": { - "name": "Solar collector temperature" + "name": "Collector temperature" }, "solar_tank_temperature": { - "name": "Solar tank temperature" + "name": "Tank temperature" }, "source_inlet_temperature": { "name": "Source inlet temperature" @@ -279,25 +279,25 @@ "name": "Total electrical power (est.)" }, "vent_exhaust_air_temperature": { - "name": "Ventilation exhaust air temperature" + "name": "Exhaust air temperature" }, "vent_extract_air_temperature": { - "name": "Ventilation extract air temperature" + "name": "Extract air temperature" }, "vent_extract_fan_speed": { - "name": "Ventilation extract fan speed" + "name": "Extract fan speed" }, "vent_level": { - "name": "Ventilation level" + "name": "Fan level" }, "vent_outdoor_air_temperature": { - "name": "Ventilation outdoor air temperature" + "name": "Outdoor air temperature" }, "vent_supply_air_temperature": { - "name": "Ventilation supply air temperature" + "name": "Supply air temperature" }, "vent_supply_fan_speed": { - "name": "Ventilation supply fan speed" + "name": "Supply fan speed" } }, "binary_sensor": { diff --git a/custom_components/dimplex_wpm/translations/pl.json b/custom_components/dimplex_wpm/translations/pl.json index 37e2afc..6c80850 100644 --- a/custom_components/dimplex_wpm/translations/pl.json +++ b/custom_components/dimplex_wpm/translations/pl.json @@ -96,7 +96,7 @@ "name": "Energia elektryczna" }, "electrical_power": { - "name": "Moc elektryczna" + "name": "Moc elektryczna (licznik)" }, "electrical_power_best": { "name": "Moc elektryczna" diff --git a/spec/ENTITY_IDS.md b/spec/ENTITY_IDS.md index b0d43b3..2cbd873 100644 --- a/spec/ENTITY_IDS.md +++ b/spec/ENTITY_IDS.md @@ -98,11 +98,16 @@ is standard Home Assistant behaviour with `has_entity_name`. 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: `analytics_electrical_power` source becomes `measured`. +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_solar_collector_temperature`, `..._solar_tank_temperature` -- Passive cooling: `sensor.passive_cooling_*` +- Solar: `sensor.solar_collector_temperature`, `sensor.solar_tank_temperature` +- Passive cooling: `sensor.passive_cooling_flow_temperature`, + `..._return_temperature`, `..._primary_return_temperature`