diff --git a/backend/services/control/exporter_monolith.py b/backend/services/control/exporter_monolith.py index 528f20a..51a9298 100644 --- a/backend/services/control/exporter_monolith.py +++ b/backend/services/control/exporter_monolith.py @@ -36,7 +36,8 @@ BATT_VOLTAGE_V = 51.2 # Reg 143 ve SELL: min(|grid_setpoint_w|, …) nesmí klesnout pod tuto podlahu (W) — kvůli chování firmware, ne mapování režimu. REG143_SELL_CAP_MIN_W = 200 -# Reg 178 – pevné hodnoty (bit4–5); bez read-modify-write (kolize s Loxone / transaction ID) +# Reg 178 – bitové pole: používáme bity 4–5 (peak shaving switch) a bity 0–1 (MI export cutoff). +# Ostatní bity zachovat → read-modify-write. REG178_SELL = 0b00100000 # 32, grid peak shaving disable REG178_PASSIVE = 0b00110000 # 48, grid peak shaving enable (PASSIVE i CHARGE) # TOU reg 166+ ve PASSIVE při prioritě baterie: signál střídači „využij celý dostupný rozsah“, @@ -44,14 +45,11 @@ REG178_PASSIVE = 0b00110000 # 48, grid peak shaving enable (PASSIVE i CHARGE) DEYE_TOU_SOC_PASSIVE_BATTERY_PRIORITY_PCT = 100 # Verify: jen bity 4–5 (horní byte layout v dokumentaci); ostatní bity mohou mít firmware / Loxone REG178_VERIFY_MASK = 0x0030 - -# Reg 179 – Control board special 1: bits 0–1 ovládají MI export cutoff (AC coupling / GEN). -REG179_MI_EXPORT_MASK = 0x0003 -REG179_MI_EXPORT_DISABLE = 0b10 -REG179_MI_EXPORT_ENABLE = 0b11 - -def _deye_reg179_verify_match(expected_i: int, actual_i: int) -> bool: - return (int(expected_i) & REG179_MI_EXPORT_MASK) == (int(actual_i) & REG179_MI_EXPORT_MASK) +# Reg 178 bits 0–1: MI export cutoff (AC coupling / GEN). +REG178_MI_EXPORT_MASK = 0x0003 +REG178_MI_EXPORT_DISABLE = 0b10 +REG178_MI_EXPORT_ENABLE = 0b11 +REG178_VERIFY_MASK_COMBINED = REG178_VERIFY_MASK | REG178_MI_EXPORT_MASK # Po 3 neúspěšných verify pokusech → SELF_SUSTAIN jen u těchto registrech (bezpečnost / export). # 62–64 řeší toleranční bundle (nemění režim). 178 a TOU power W jsou „soft“ — jen log + Discord. @@ -63,7 +61,9 @@ DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A = 350 def _deye_reg178_verify_match(expected_i: int, actual_i: int) -> bool: - return (int(expected_i) & REG178_VERIFY_MASK) == (int(actual_i) & REG178_VERIFY_MASK) + return (int(expected_i) & REG178_VERIFY_MASK_COMBINED) == ( + int(actual_i) & REG178_VERIFY_MASK_COMBINED + ) def deye_reg_triggers_self_sustain_after_verify_exhaust(reg: int) -> bool: @@ -121,8 +121,7 @@ DEYE_REGISTER_NAMES: dict[int, str] = { 142: "limit_control (0=selling first, 1=zero export to load, 2=zero export to CT)", 143: "export_limit_w (max export do sítě)", 145: "solar_sell (0=disabled, 1=enabled)", - 178: "grid_peak_shaving_switch (SELL=32 bit4-5=10, PASSIVE/CHARGE=48 bit4-5=11)", - 179: "control_board_special_1 (bits0-1: MI export cutoff disable=2 enable=3)", + 178: "control_board_special_1 (bits0-1: MI export cutoff disable=2 enable=3; bits4-5 peak shaving 32/48)", 148: "time_point_1_time", 149: "time_point_2_time", 154: "time_point_1_power_w", @@ -338,16 +337,6 @@ def _drop_registers_matching_last_verified( if int(reg) == 178 and _deye_reg178_verify_match(int(val), int(lv)): skipped.append(int(reg)) continue - # reg179: porovnáváme jen bits0–1 maskou 0x0003 (masked RMW zachovává ostatní bity). - if int(reg) == 179 and _deye_reg179_verify_match(int(val), int(lv)): - # GEN cutoff (BA81): chceme na zařízení dostat "clean" hodnotu 2/3. - # Pokud minulý verified stav obsahuje jiné bity (např. 0xFFFE/0xFFFF), - # maska sice sedí, ale firmware/UI nemusí cutoff aplikovat správně. - # Proto reg179 skipneme jen tehdy, když je poslední verified hodnota už - # skutečně 2 nebo 3 (tj. clean value), ne jen maskově ekvivalentní. - if int(lv) in (REG179_MI_EXPORT_DISABLE, REG179_MI_EXPORT_ENABLE): - skipped.append(int(reg)) - continue if int(lv) == int(val): skipped.append(int(reg)) continue @@ -370,7 +359,7 @@ class ControlSetpoints: deye_physical_mode: str | None = None #: True = zákaz exportu (BLOCK_EXPORT) pro daný slot: např. při efektivní vykupní ceně < 0. export_ban: bool = False - #: True = odpojit GEN port (MI export cutoff) v tomto slotu dle plánu (reg 179 bits0-1). + #: True = odpojit GEN port (MI export cutoff) v tomto slotu dle plánu (reg 178 bits0-1, 0-based). #: None/False = neodpojovat. deye_gen_cutoff_enabled: bool = False #: Efektivní vykupní cena slotu (Kč/kWh z plánu); pro TOU řízení priorit baterie vs. přetok @@ -833,8 +822,6 @@ async def verify_modbus_commands( first_178, second_178, ) - if reg == 179: - matches = _deye_reg179_verify_match(expected_i, actual_i) if not matches and reg in DEYE_TOU_POWER_REGS and inv_cfg is not None: matches = _deye_tou_power_verify_match(expected_i, actual_i, inv_cfg) @@ -861,7 +848,7 @@ async def verify_modbus_commands( ( " (reg178 mask 0x%04X)" % REG178_VERIFY_MASK if reg == 178 - else (" (reg179 mask 0x%04X)" % REG179_MI_EXPORT_MASK if reg == 179 else "") + else "" ), ) row_ac = await db.fetchrow( @@ -1643,42 +1630,42 @@ async def write_inverter_setpoints( (142, "limit_control", selling_mode), (143, "", export_limit), (145, "solar_sell", solar_sell), - (178, "grid_peak_shaving_switch", reg178_val), ] ) - if inv.deye_gen_microinverter_cutoff_enabled: - # Deye UI semantics: "MI export cutoff ENABLE" means export to grid is blocked (GEN effectively cut off). - # Therefore: want_cutoff=True -> ENABLE (3), want_cutoff=False -> DISABLE (2). - want_cutoff = bool(setpoints_now.deye_gen_cutoff_enabled) and deye_mode != "SELL" - target_bits = ( - REG179_MI_EXPORT_ENABLE if want_cutoff else REG179_MI_EXPORT_DISABLE + # Reg 178: bitové pole. Nastavujeme bits4–5 (peak shaving) vždy; bits0–1 (MI export cutoff) jen pokud feature. + # Ostatní bity musí zůstat zachované → read-modify-write. + try: + mb178 = await get_modbus_client(inv.host, inv.port) + r178 = await mb178.read_holding_registers(178, 1, unit_id) + if not r178 or len(r178) < 1: + raise OSError(f"reg178 read returned {len(r178) if r178 is not None else None} values") + current_178 = int(r178[0]) + peak_bits = int(reg178_val) & int(REG178_VERIFY_MASK) + if inv.deye_gen_microinverter_cutoff_enabled: + want_cutoff = bool(setpoints_now.deye_gen_cutoff_enabled) and deye_mode != "SELL" + mi_bits = ( + REG178_MI_EXPORT_ENABLE if want_cutoff else REG178_MI_EXPORT_DISABLE + ) + else: + mi_bits = int(current_178) & int(REG178_MI_EXPORT_MASK) + + new_178 = ( + (int(current_178) & ~int(REG178_VERIFY_MASK_COMBINED)) + | int(peak_bits) + | int(mi_bits) ) - try: - mb179 = await get_modbus_client(inv.host, inv.port) - r179 = await mb179.read_holding_registers(179, 1, unit_id) - if r179 and len(r179) >= 1: - current_179 = int(r179[0]) - # Deye firmware/UI u některých instalací neinterpretuje jen bits0–1 maskou, - # ale očekává přímo hodnotu 2/3. Proto zapisujeme "clean" 2/3 (bez RMW), - # aby se cutoff skutečně projevil i v UI. - registers.append((179, "control_board_special_1", int(target_bits))) - logger.info( - "[control] %s: reg179 MI cutoff %s (old=%s new=%s mask=0x%04X)", - inv.code, - "ON" if want_cutoff else "OFF", - current_179, - int(target_bits), - REG179_MI_EXPORT_MASK, - ) - else: - logger.warning( - "[control] %s: reg179 read returned %s values, skip cutoff write", - inv.code, - len(r179) if r179 is not None else None, - ) - except Exception as e: - logger.warning("[control] %s: reg179 cutoff RMW failed: %s", inv.code, e) + registers.append((178, "control_board_special_1", int(new_178))) + logger.info( + "[control] %s: reg178 (control_board_special_1) old=%s new=%s peak_bits=0x%04X mi_bits=%s", + inv.code, + current_178, + new_178, + int(peak_bits), + int(mi_bits), + ) + except Exception as e: + logger.warning("[control] %s: reg178 RMW failed (skip reg178 write): %s", inv.code, e) logger.info( "[control] %s: deye_mode=%s charge=%sA discharge=%sA " @@ -1803,13 +1790,11 @@ 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) @@ -1823,9 +1808,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_ENABLE), + "reg178_control_board_special_1": int(r178), + "reg178_mi_export_cutoff_bits": int(r178) & int(REG178_MI_EXPORT_MASK), + "reg178_mi_export_cutoff_is_on": (int(r178) & int(REG178_MI_EXPORT_MASK)) == int(REG178_MI_EXPORT_ENABLE), "reg191_peak_shaving_w": int(r191), "read_at": read_at.isoformat(), } diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 19e42d8..5d164d3 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -322,7 +322,7 @@ class DispatchResult: #: Explicitní fyzický režim Deye pro control exporter (PASSIVE / SELL / CHARGE). #: Cíl: odstranit heuristiky z exporteru a nést záměr přímo v plánu. deye_physical_mode: str - #: True = v daném slotu odpojit GEN port (MI export cutoff) přes reg 179 bits0–1. + #: True = v daném slotu odpojit GEN port (MI export cutoff) přes reg 178 bits0–1 (0-based; v UI často jako "register 179"). #: None = lokalita tuto funkci nemá / nepoužívá. deye_gen_cutoff_enabled: bool | None ev1_setpoint_w: Optional[int] @@ -689,7 +689,7 @@ def solve_dispatch( prob += bd[t] <= pulp.lpSum(ev_via_bat[e][t] for e in range(EV)) # BA81 (GEN port microinverters): pokud máme k dispozici GEN cut-off, držíme skutečný # BLOCK_EXPORT jako hard constraint: export do sítě v okně se záporným prodejem je zakázaný. - # Přebytek pak řeší curtail PV A / nabíjení / případně GEN cut-off (reg 179). + # Přebytek pak řeší curtail PV A / nabíjení / případně GEN cut-off (reg 178 bits0–1). if z_gen_cutoff is not None: prob += ge[t] == 0 diff --git a/backend/services/telemetry_collector.py b/backend/services/telemetry_collector.py index e0e662e..1edd41f 100644 --- a/backend/services/telemetry_collector.py +++ b/backend/services/telemetry_collector.py @@ -28,9 +28,11 @@ DEYE_REG_GRID_EXPORT_TOTAL_LO = 524 DEYE_REG_GRID_EXPORT_TOTAL_HI = 525 DEYE_REG_PV1_POWER = 672 DEYE_REG_PV2_POWER = 673 -# Solar sell (0 = přebytek řiditelné FVE nesmí do sítě) a GEN/MI cut-off (bits0–1 == 3 → cut-off ON); viz modbus-registers.md +# Solar sell (0 = přebytek řiditelné FVE nesmí do sítě) a GEN/MI cut-off (reg178 bits0–1 == 3 → cut-off ON). +# Pozn.: v některých manuálech/UI se uvádí "register 179" (1-based), ale Modbus adresa je 178 (0-based). +# Viz modbus-registers.md. DEYE_REG_SOLAR_SELL = 145 -DEYE_REG_CONTROL_BOARD_SPECIAL1 = 179 +DEYE_REG_CONTROL_BOARD_SPECIAL1 = 178 def aggregate_pv_production_w(pv1_w: int, pv2_w: int, gen_port_w: int) -> int: diff --git a/backend/tests/test_control_exporter_tou.py b/backend/tests/test_control_exporter_tou.py index ef34b3f..e8ddefe 100644 --- a/backend/tests/test_control_exporter_tou.py +++ b/backend/tests/test_control_exporter_tou.py @@ -9,7 +9,6 @@ from services.control.exporter_monolith import ( ControlSetpoints, InverterConfig, _deye_reg178_verify_with_double_read, - _deye_reg179_verify_match, _deye_tou_params, _deye_tou_power_verify_match, _deye_zero_export_amps_for_passive, @@ -55,11 +54,6 @@ class ModbusVerifyPolicyTests(unittest.TestCase): self.assertTrue(ok) self.assertEqual(v, 48) - def test_reg179_verify_match_only_bits_0_1(self) -> None: - # expected=3 (enable), actual can have other bits set but bits0-1 must match - self.assertTrue(_deye_reg179_verify_match(3, 0xFFFB)) - self.assertFalse(_deye_reg179_verify_match(3, 0xFFFA)) # bits0-1=2 - def test_reg178_not_critical_for_self_sustain(self) -> None: self.assertFalse(deye_reg_triggers_self_sustain_after_verify_exhaust(178)) diff --git a/backend/tests/test_drop_registers_matching_last_verified.py b/backend/tests/test_drop_registers_matching_last_verified.py index 1e868fd..0957b88 100644 --- a/backend/tests/test_drop_registers_matching_last_verified.py +++ b/backend/tests/test_drop_registers_matching_last_verified.py @@ -20,27 +20,3 @@ def test_drop_registers_keeps_reg178_when_mask_differs(): assert out == registers assert skipped == [] - -def test_drop_registers_keeps_reg179_when_mask_matches_but_not_clean(): - registers = [(179, "control_board_special_1", 2)] # want cutoff ON (clean value) - last_verified = {179: 0x1236} # bits0–1 still == 2, but not a clean 2/3 value - out, skipped = _drop_registers_matching_last_verified(registers, last_verified) - assert out == registers - assert skipped == [] - - -def test_drop_registers_skips_reg179_when_clean_value_matches(): - registers = [(179, "control_board_special_1", 2)] # want cutoff ON (clean value) - last_verified = {179: 2} # already clean cutoff ON - out, skipped = _drop_registers_matching_last_verified(registers, last_verified) - assert out == [] - assert skipped == [179] - - -def test_drop_registers_keeps_reg179_when_mask_differs(): - registers = [(179, "control_board_special_1", 2)] # want cutoff ON - last_verified = {179: 0x1237} # ...0111b => bits0–1 == 3 (cutoff OFF) - out, skipped = _drop_registers_matching_last_verified(registers, last_verified) - assert out == registers - assert skipped == [] - diff --git a/docs/04-modules/control.md b/docs/04-modules/control.md index e76972d..f5f2ff9 100644 --- a/docs/04-modules/control.md +++ b/docs/04-modules/control.md @@ -111,18 +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 +### BA81: GEN port cut-off (mikroinvertory na GEN) přes reg 178 (0-based) 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**: +`asset_inverter.deye_gen_microinverter_cutoff_enabled = true`, exporter provede **read-modify-write** +registru **178** (v některých manuálech/UI uváděno jako “register 179” – 1-based): -- `deye_gen_cutoff_enabled = true` → reg **179** bits **0–1** = **3** (`11b`, enable = cut-off **ON** / export blokován) -- `deye_gen_cutoff_enabled = false` → reg **179** bits **0–1** = **2** (`10b`, disable = cut-off **OFF** / export povolen) +- `deye_gen_cutoff_enabled = true` → reg **178** bits **0–1** = **3** (`11b`, enable = cut-off **ON** / export blokován) +- `deye_gen_cutoff_enabled = false` → reg **178** bits **0–1** = **2** (`10b`, disable = cut-off **OFF** / export povolen) 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). +bits 0–1). Detail registrů: [`modbus-registers.md`](modbus-registers.md) (reg 178). ### Fyzický režim (`get_deye_mode` v `exporter_monolith.py`) diff --git a/docs/04-modules/forecast.md b/docs/04-modules/forecast.md index 0a15f0e..b17275e 100644 --- a/docs/04-modules/forecast.md +++ b/docs/04-modules/forecast.md @@ -100,7 +100,7 @@ def calculate_pv_power( - Endpoint `GET /api/v1/sites/{site_id}/forecast/pv?date=YYYY-MM-DD` vrací vždy poslední `ok` run per `(interval_start, pv_array_id)` (`DISTINCT ON`), takže UI nevidí duplikáty z historických běhů. - **Kalibrace delty:** `GET /api/v1/sites/{site_id}/forecast/pv-delta-profile?from=…&to=…` vrací JSON z `ems.fn_pv_forecast_delta_profile` (`deltas`, `deltas_by_array`, `delta_learn_min_ts` z `ems.site_pv_forecast_calibration`). Volitelné query parametry: `half_life_days`, `threshold_w`, `top_n_days`, `non_top_day_factor`, `day_weight_gamma` (NULL u numerických přepsání = hodnota z kalibrační tabulky / default funkce). - **Úprava kalibrace z API:** `PATCH /api/v1/sites/{site_id}/configuration/pv-forecast-calibration` s JSON tělem (částečný update); odpověď je aktuální řádek kalibrace. Souhrn konfigurace v `GET …/configuration` obsahuje klíč `pv_forecast_calibration`. -- **Telemetrie pro učení delty:** `telemetry_collector` při Modbus poll přidá čtení reg. **145** a **179**; `fn_telemetry_inverter_sample` ukládá `is_export_limited` / `pv_derating_flags` (bity 1 = solar sell off, 2 = GEN/MI cut-off aktivní dle masky `(reg179 & 3) == 2`). `fn_fill_forecast_accuracy` sloty s těmito signály označí `telemetry_derating`. +- **Telemetrie pro učení delty:** `telemetry_collector` při Modbus poll čte reg. **145** a **178**; `fn_telemetry_inverter_sample` ukládá `is_export_limited` / `pv_derating_flags` (bity 1 = solar sell off, 2 = GEN/MI cut-off aktivní dle masky `(reg178 & 3) == 3`). `fn_fill_forecast_accuracy` sloty s těmito signály označí `telemetry_derating`. --- diff --git a/docs/04-modules/modbus-command-journal.md b/docs/04-modules/modbus-command-journal.md index a09b353..fac9aeb 100644 --- a/docs/04-modules/modbus-command-journal.md +++ b/docs/04-modules/modbus-command-journal.md @@ -25,8 +25,9 @@ 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 hodnotu 2/3 (clean write). - Při ověření se za shodu považuje jen maska **bits 0–1** (`0x0003`) vůči očekávání (**3 = cutoff ON**, **2 = cutoff OFF**). +4. **Reg 178** (control board special 1, BA81 GEN cut-off): exporter nastavuje bits **0–1** (2/3) pomocí + **read-modify-write**, protože reg 178 je bitové pole i pro další volby (např. peak shaving bits 4–5). + Při ověření se za shodu považuje maska **bits 0–1 a 4–5** (`0x0033`) vůči očekávání. 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í: @@ -48,8 +49,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). +Pokud je zapnutý feature `asset_inverter.deye_gen_microinverter_cutoff_enabled = true`, exporter nastavuje +**MI export cutoff** přes **reg 178 bits0–1** (BA81 GEN port cut-off) — stále jako jeden záznam `modbus_command` +pro **reg 178** (spolu s peak shaving bity 4–5). **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`. @@ -72,7 +74,7 @@ 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`. +Poznámka: **GEN port cut-off na BA81** se aktuálně provádí přímo přes Deye **reg 178 (bits0–1)** 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 diff --git a/docs/04-modules/modbus-registers.md b/docs/04-modules/modbus-registers.md index 203922a..547d651 100644 --- a/docs/04-modules/modbus-registers.md +++ b/docs/04-modules/modbus-registers.md @@ -20,8 +20,7 @@ EMS zapisuje řídící hodnoty přes journal (`modbus_command`) a **`write_regi | 142 | Limit control (System work mode) | 0/1/2 | — | **0** = selling first, **1** = zero export to load, **2** = zero export to CT. Hodnota v non-SELL režimech pochází z `asset_inverter.deye_zero_export_mode` (závisí na instalaci – viz tabulka níže). V režimu SELL vždy **0**. | | 145 | Solar sell | 0/1 | — | **0** = disabled (přebytek FVE na **straně měniče** se nesmí vést do sítě — curtailment vůči síti), **1** = enabled. Platí jen pro **FVE pod kontrolou Deye** (`controllable = true`); druhá pole (např. **pv-b** u home-01) EMS tímto registerem neřídí. EMS dnes **vždy zapisuje 1**; při 108 = 0 a 145 = 1 přebytky z řiditelného stringu typicky tečou do sítě (viz pass-through níže). | | 143 | Export limit W | závisí na typu (SUN-20K až ~13 500) | 1 W | Max export do sítě; hodnota z `site_grid_connection.max_export_power_w` | -| 178 | Grid peak shaving switch | bitmask | — | EMS zapisuje **pevnou** hodnotu (bez read-modify-write kvůli kolizím s paralelním čtením z Loxone): **32** (`0b00100000`, bit4–5 = **10**) v režimu **SELL**; **48** (`0b00110000`, bit4–5 = **11**) v **PASSIVE** a **CHARGE**. | -| 179 | Control board special 1 | bitmask | — | **BA81:** bits **0–1** ovládají „MI export to Grid cutoff“ (AC coupling / GEN): **2** (`10b`) = disable (cutoff ON), **3** (`11b`) = enable. EMS zapisuje **masked RMW** (zachová ostatní bity) jen pokud `asset_inverter.deye_gen_microinverter_cutoff_enabled = true`. | +| 178 | Control board special 1 | bitmask | — | Bitové pole pro více funkcí. **EMS používá:** (a) bits **4–5** pro peak shaving switch: **32** (`0b00100000`, bit4–5 = **10**) v režimu **SELL**; **48** (`0b00110000`, bit4–5 = **11**) v **PASSIVE/CHARGE**. (b) **BA81:** bits **0–1** pro „MI export to Grid cutoff“ (AC coupling / GEN): **2** = disable (cutoff OFF), **3** = enable (cutoff ON). EMS zapisuje jako **read-modify-write** (zachová ostatní bity). V některých manuálech/UI je to označené jako „register 179“ (1-based). | | 190 | GEN peak shaving | 0–16000 | 1 W | Peak shaving na GEN portu | | 191 | Grid peak shaving power | 0–16000 | 1 W | **EMS NEZAPISUJE** – nastavit **manuálně v SolarmanApp**. Hodnota určuje výkon peak shavingu v **W**. | @@ -37,9 +36,9 @@ EMS zapisuje řídící hodnoty přes journal (`modbus_command`) a **`write_regi - **SELL:** **32** – bit4–5 = **10**, grid peak shaving **disable** (export do sítě). - **PASSIVE** a **CHARGE:** **48** – bit4–5 = **11**, grid peak shaving **enable**. -EMS **nezapisuje** read-modify-write (paralelní čtení jinými klienty může způsobit nesoulad). +EMS zapisuje **read-modify-write** a zachovává ostatní bity (reg 178 obsahuje více funkcí). -**Ověření v journalu (`modbus_command`):** u zápisu **178** se při verify porovnávají jen **bity 4–5** maskou **`0x0030`** s očekávanou hodnotou (32/48); `value_verified` zůstává plný readback. Při nesouladu masky následuje **druhé FC3 čtení** reg. 178 (mitigace RS485 glitchů). U **TOU výkonu W (154–159)** verify akceptuje i readback **`max_charge_a × 51.2`** nebo **`max_discharge_a × 51.2`**, pokud firmware hodnotu přepíše na interní maximum (skutečný výkon je stejně omezen reg. 108/109). Detail: `modbus-command-journal.md`. +**Ověření v journalu (`modbus_command`):** u zápisu **178** se při verify porovnává maska **bits 0–1 a 4–5** (`0x0033`) s očekávanou hodnotou; `value_verified` zůstává plný readback. Při nesouladu masky následuje **druhé FC3 čtení** reg. 178 (mitigace RS485 glitchů). U **TOU výkonu W (154–159)** verify akceptuje i readback **`max_charge_a × 51.2`** nebo **`max_discharge_a × 51.2`**, pokud firmware hodnotu přepíše na interní maximum (skutečný výkon je stejně omezen reg. 108/109). Detail: `modbus-command-journal.md`. **Idempotence (proti spamu zápisů):** pokud poslední `verified` hodnota už má správně nastavené bity 4–5 (maska `0x0030`), EMS zápis reg. 178 v dalším běhu přeskočí (i když `value_verified` obsahuje jiné bity). @@ -61,20 +60,15 @@ Režim **CHARGE_CHEAP** nastaví oba setpointy na stejný kladný výkon (min. 1 **PASSIVE (ZERO):** reg. **108/109** podle `_deye_zero_export_amps_for_passive` — při exportu v plánu bez vybíjení je **108 = 0** (přetok FVE); při importu bez nabíjení je **109 = 0** (držet baterii). Jinak oba max (AUTO). Detail: `operating-modes.md`. -### BA81: GEN port cut-off (reg 179) z plánu +### BA81: GEN port cut-off (reg 178 bits0–1) z plánu Pro instalace s AC coupling na GEN portu (mikroinvertory) může solver uložit do `planning_interval` flag **`deye_gen_cutoff_enabled`**.\n -- `true` → exporter nastaví reg **179** bits0–1 na **3** (`11b`, enable = cut-off ON / export blokován) +- `true` → exporter nastaví reg **178** bits0–1 na **3** (`11b`, enable = cut-off ON / export blokován) - `false` → exporter nastaví bits0–1 na **2** (`10b`, disable = cut-off OFF / export povolen) -Zápis do reg. 179 se v praxi provádí jako **„clean write“** hodnoty **2** nebo **3** (bez read-modify-write), -protože některé firmware/UI varianty nevyhodnocují jen bity 0–1 maskou, ale očekávají přímo hodnotu 2/3. -Ověření v journalu (`verify_modbus_commands`) přesto porovnává jen bits0–1 maskou `0x0003` (odolnost vůči -paralelním změnám jiných bitů / verzím FW). +Zápis se provádí jako **read-modify-write** nad **reg 178** (zachová ostatní bity registru). -**Idempotence:** EMS zápis reg. 179 přeskočí jen tehdy, když poslední `verified` hodnota je už **clean 2/3**. -Masková shoda s hodnotami typu `0xfffe` / `0xffff` se záměrně **nepovažuje** za “už zapsáno”, aby se zařízení -dostalo do stabilního stavu, který odpovídá UI i chování firmware. +**Idempotence:** pokud poslední `verified` hodnota už má správně nastavené relevantní bity (maska `0x0033`), EMS zápis reg. 178 v dalším běhu přeskočí. **Pozn.:** Flag se v solveru vůbec nevytváří ani neukládá tam, kde není povolen feature `asset_inverter.deye_gen_microinverter_cutoff_enabled` – takové lokality ho nemají ani v UI. ### Provozní režim EMS SELF_SUSTAIN diff --git a/docs/04-modules/operating-modes.md b/docs/04-modules/operating-modes.md index de92ea2..784f675 100644 --- a/docs/04-modules/operating-modes.md +++ b/docs/04-modules/operating-modes.md @@ -67,7 +67,7 @@ Nabíjení ze sítě s vysokým cílovým SoC v TOU řeší větev **CHARGE** (g **Implementace (BLOCK_EXPORT):** při `effective_sell_price < 0` (slot z plánu) EMS drží fyzicky stále **PASSIVE**, ale zapne **zákaz exportu přebytků** pro řiditelnou FVE: - **reg 145 = 0** (solar sell disabled) mimo SELL -- **BA81:** navíc přes **reg 179** (bits0–1) aktivuje „MI export to Grid cutoff“ pro mikroinvertory na GEN portu (jen pokud je `asset_inverter.deye_gen_microinverter_cutoff_enabled = true`). +- **BA81:** navíc přes **reg 178** (bits0–1; v některých UI jako “register 179”) aktivuje „MI export to Grid cutoff“ pro mikroinvertory na GEN portu (jen pokud je `asset_inverter.deye_gen_microinverter_cutoff_enabled = true`). Týká se jen výroby, kterou Deye umí ovlivnit; **pv-b / ongrid GEN** u home-01 tímto neomezíš. #### PV1/PV2 vs. GEN port (důležité pro BLOCK_EXPORT) diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 4da0d65..671f7d0 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -251,7 +251,7 @@ Při `sell_price < 0` tedy nastává problém: - pokud výroba na GEN portu převýší okamžitou spotřebu + možný charge do baterie, zbytek fyzicky teče do sítě (nechtěný export za zápornou cenu). Řešení na hardware úrovni: -- **Deye reg 179 bits0–1** („MI export to Grid cutoff“) umožní GEN port **tvrdě odpojit**. +- **Deye reg 178 bits0–1** („MI export to Grid cutoff“, často uváděno jako “register 179” v 1-based značení) umožní GEN port **tvrdě odpojit**. #### Správné rozhodovací pravidlo (záměr) @@ -278,7 +278,7 @@ kde: - (případně) explicitní `no_export` politika, pokud je v kontextu dostupná Mimo tyto případy je `z_gen_cutoff[t]` vynucené na `0`. - Cut-off je v účelové funkci **penalizované** (za „zahozenou“ GEN výrobu), aby se zapínalo jen jako poslední možnost. - - Výstup se ukládá do `planning_interval.deye_gen_cutoff_enabled` (nullable) a exporter pak jen provede reg 179. + - Výstup se ukládá do `planning_interval.deye_gen_cutoff_enabled` (nullable) a exporter pak nastaví bity reg 178. **Scope / bezpečnost:** proměnná i flag existují jen na lokalitách, kde je zapnutý `asset_inverter.deye_gen_microinverter_cutoff_enabled` (tj. kde je GEN port s mikroinvertory reálně zapojen). Jinde se nic neřeší ani nezobrazuje. diff --git a/frontend/src/pages/Planning.tsx b/frontend/src/pages/Planning.tsx index f2f8106..9c70066 100644 --- a/frontend/src/pages/Planning.tsx +++ b/frontend/src/pages/Planning.tsx @@ -405,7 +405,7 @@ function genCutoffBadge(i: PlanningIntervalDto): { show: boolean; label: string; show: true, label: 'GEN CUT', klass: 'bg-red-500/15 text-red-200 ring-1 ring-red-500/35', - title: 'GEN port cut-off (BA81): reg179 bits0-1=2 (MI export cutoff ON)', + title: 'GEN port cut-off (BA81): reg178 bits0-1=3 (MI export cutoff ON)', } } return {