From afee62ba4ec960be218ca9ef11d803d2b418dce1 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Wed, 29 Apr 2026 12:51:53 +0200 Subject: [PATCH] fix cutoff gen port --- .cursor/rules/documentation-update-discipline.mdc | 15 +++++++++++++++ backend/services/control/exporter_monolith.py | 8 +++++++- docs/04-modules/control.md | 13 +++++++++++++ docs/04-modules/modbus-command-journal.md | 8 ++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 .cursor/rules/documentation-update-discipline.mdc diff --git a/.cursor/rules/documentation-update-discipline.mdc b/.cursor/rules/documentation-update-discipline.mdc new file mode 100644 index 0000000..f665b63 --- /dev/null +++ b/.cursor/rules/documentation-update-discipline.mdc @@ -0,0 +1,15 @@ +--- +description: When changing implementation, update relevant docs +alwaysApply: true +--- + +# Documentation update discipline + +- When you make an **implementation change** (Python/SQL/frontend), you must also update the **relevant documentation** + in `docs/` (and/or `CLAUDE.md` if it’s normative guidance) in the same change set. +- The docs update must cover: + - what behavior changed (externally visible / operational impact), + - where it is implemented (file/function names), + - how to verify it (DB table/view, API endpoint, or operational check). + +If there is no existing relevant document, add a short section to the closest module doc under `docs/04-modules/`. diff --git a/backend/services/control/exporter_monolith.py b/backend/services/control/exporter_monolith.py index 6134c22..b28d77a 100644 --- a/backend/services/control/exporter_monolith.py +++ b/backend/services/control/exporter_monolith.py @@ -1515,6 +1515,7 @@ async def write_inverter_setpoints( if inv is None: return "FAIL inverter: no controllable Modbus endpoint" + unit_id = int(inv.unit_id if inv.unit_id is not None else 1) raw_bat = setpoints_now.battery_w grid_w = int(setpoints_now.grid_setpoint_w or 0) no_export = inv.no_export @@ -1647,7 +1648,7 @@ async def write_inverter_setpoints( ) try: mb179 = await get_modbus_client(inv.host, inv.port) - r179 = await mb179.read_holding_registers(179, 1, unit) + r179 = await mb179.read_holding_registers(179, 1, unit_id) if r179 and len(r179) >= 1: current_179 = int(r179[0]) new_179 = (current_179 & ~REG179_MI_EXPORT_MASK) | int(target_bits) @@ -1792,11 +1793,13 @@ async def read_deye_registers_live(site_id: int, db: asyncpg.Connection) -> dict b108 = await mb.read_holding_registers(108, 2) b141 = await mb.read_holding_registers(141, 5) r178 = await mb.read_holding_registers(178, 1) + r179 = await mb.read_holding_registers(179, 1) r191 = await mb.read_holding_registers(191, 1) r108, r109 = b108[0], b108[1] r141, r142, r143 = b141[0], b141[1], b141[2] r145 = b141[4] r178 = r178[0] + r179 = r179[0] r191 = r191[0] except Exception: logger.exception("read_deye_registers_live site=%s failed", site_id) @@ -1810,6 +1813,9 @@ async def read_deye_registers_live(site_id: int, db: asyncpg.Connection) -> dict "reg143_export_limit_w": int(r143), "reg145_solar_sell": int(r145), "reg178_peak_shaving_switch": int(r178), + "reg179_control_board_special_1": int(r179), + "reg179_mi_export_cutoff_bits": int(r179) & int(REG179_MI_EXPORT_MASK), + "reg179_mi_export_cutoff_is_on": (int(r179) & int(REG179_MI_EXPORT_MASK)) == int(REG179_MI_EXPORT_DISABLE), "reg191_peak_shaving_w": int(r191), "read_at": read_at.isoformat(), } diff --git a/docs/04-modules/control.md b/docs/04-modules/control.md index 323d312..65b5a0b 100644 --- a/docs/04-modules/control.md +++ b/docs/04-modules/control.md @@ -111,6 +111,19 @@ def apply_overrides(plan, overrides) -> Setpoints: **Princip:** držet mapování plán → Deye **jednoduché**; detail a zdůvodnění v [`operating-modes.md`](operating-modes.md) (sekce *Keep it simple*). +### BA81: GEN port cut-off (mikroinvertory na GEN) přes reg 179 + +U instalací typu **BA81** (AC coupling / mikroinvertory na GEN portu) může solver uložit do plánu flag +`planning_interval.deye_gen_cutoff_enabled` (true/false). Pokud je na střídači zapnutý feature flag +`asset_inverter.deye_gen_microinverter_cutoff_enabled = true`, exporter provede **masked read-modify-write** +registru **179**: + +- `deye_gen_cutoff_enabled = true` → reg **179** bits **0–1** = **2** (`10b`, disable = cut-off **ON**) +- `deye_gen_cutoff_enabled = false` → reg **179** bits **0–1** = **3** (`11b`, enable = cut-off **OFF**) + +Zápisy se ukládají do `ems.modbus_command` a ověřují v `verify_modbus_commands` (porovnává se pouze maska +bits 0–1). Detail registrů: [`modbus-registers.md`](modbus-registers.md) (reg 179). + ### Fyzický režim (`get_deye_mode` v `exporter_monolith.py`) | Fyzický režim | Podmínka z `ControlSetpoints` | diff --git a/docs/04-modules/modbus-command-journal.md b/docs/04-modules/modbus-command-journal.md index 35cf42b..dcd4a3b 100644 --- a/docs/04-modules/modbus-command-journal.md +++ b/docs/04-modules/modbus-command-journal.md @@ -25,6 +25,8 @@ Indexy: podle `(site_id, status, created_at)` a částečný index pro `pending` 1. Po `mismatch` se odešle **Discord** alert (`notify_modbus_mismatch`), pokud je nastaven `DISCORD_WEBHOOK_URL`. 2. **Retry** zápisu max. **3×** (počítáno přes `attempt_count` po zápisech). 3. **Reg 178** (grid peak shaving switch): journal ukládá **celé 16bit** `value_to_write` (32 nebo 48). Při ověření se za **shodu** považuje shoda **bitů 4–5** maskou **`0x0030`** s očekáváním; `value_verified` = přečtená surová hodnota. Při nesouladu masky se **jednou** znovu přečte reg. 178 (druhé FC3) kvůli glitchům na RS485 — pokud druhé čtení maskou sedí, stav je **`verified`**. +4. **Reg 179** (control board special 1, BA81 GEN cut-off): exporter zapisuje masked RMW (zachová ostatní bity). + Při ověření se za shodu považuje jen maska **bits 0–1** (`0x0003`) vůči očekávání (2 = cutoff ON, 3 = cutoff OFF). 4. **TOU výkon W (154–159):** firmware často vrátí **max. výkon z reg. 108/109 × 51.2 V** místo přesně zapsaného W; verify to akceptuje jako **shodu** (skutečný výkon je stejně omezen proudy 108/109). 5. **Pojistka 62–64**: pokud by se řádek registru **62, 63 nebo 64** omylem dostal do striktní větve po jednom registru, verify to zachytí a zpracuje **jako toleranční celek 62–64** (stejně jako primární clock větev) — bez přepnutí do SELF_SUSTAIN jen kvůli tomu. 6. Po třech neúspěšných cyklech ověření: @@ -46,6 +48,9 @@ Implementace: `services/control_exporter.py` — `verify_modbus_commands`, `_ver `write_inverter_setpoints` přidá do journalu podle potřeby **62–64** (čas — po čtení z invertoru jen při driftu / 24h intervalu; viz `modbus-registers.md`) a **time pointy 148–177** (bloky 3–6 typicky jednou denně; viz `modbus-registers.md`), dále **108**, **109**, **141**, **142**, **178**, **143**. Každý řádek daného exportního běhu má **`deye_physical_mode`** (**PASSIVE** / **SELL** / **CHARGE**). **Reg 191** EMS nezapisuje (SolarmanApp). Převod výkonu: `battery_watts_to_amps` v `modbus-registers.md`. +Pokud je zapnutý feature `asset_inverter.deye_gen_microinverter_cutoff_enabled = true`, exporter navíc zapisuje +**reg 179** (masked RMW) podle `planning_interval.deye_gen_cutoff_enabled` (BA81 GEN port cut-off). + **Dávky:** `execute_modbus_commands` slučuje souvislé adresy do jednoho **`write_registers`** (FC **0x10**). `verify_modbus_commands` čte zpět po souvislých blocích (`read_holding_registers`, FC 0x03). Detail režimů: `modbus-registers.md`. ## APScheduler @@ -67,6 +72,9 @@ Vrátí počty `checked` / `verified` / `mismatch` a seznam dotčených příkaz Tabulka pro budoucí logování **cut-off** přepínačů (mikroinvertory / GEN při záporné prodejní ceně). Záznam při **změně** stavu: `asset_code`, `new_state`, `previous_state`, `reason`, `sell_price_czk`, `triggered_by`. Zatím jen schéma; logika napojení v `control_exporter` je v TODO. +Poznámka: **GEN port cut-off na BA81** se aktuálně provádí přímo přes Deye **reg 179** a loguje se v `ems.modbus_command`. +`cutoff_switch_log` je oddělená tabulka pro budoucí obecnější “cut-off” akce (nezávisle na konkrétním Modbus registru). + ## Konfigurace - `.env`: `DISCORD_WEBHOOK_URL` — prázdné = notifikace vypnuté (jen log).