fix rs485 s eror self_sustain
This commit is contained in:
@@ -40,10 +40,50 @@ 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
|
# Verify: jen bity 4–5 (horní byte layout v dokumentaci); ostatní bity mohou mít firmware / Loxone
|
||||||
REG178_VERIFY_MASK = 0x0030
|
REG178_VERIFY_MASK = 0x0030
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
DEYE_CRITICAL_REGS_SELF_SUSTAIN = frozenset({108, 109, 142, 143, 145})
|
||||||
|
# Výkonové řádky TOU (154 + slot_index 0…5) — firmware často přepíše na max W z max_charge/max_discharge A.
|
||||||
|
DEYE_TOU_POWER_REGS = frozenset(range(154, 160))
|
||||||
|
# Deye LV: firmware často odmítne 351 A a drží 350 — horní strop pro zápis z DB.
|
||||||
|
DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A = 350
|
||||||
|
|
||||||
|
|
||||||
def _deye_reg178_verify_match(expected_i: int, actual_i: int) -> bool:
|
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) == (int(actual_i) & REG178_VERIFY_MASK)
|
||||||
|
|
||||||
|
|
||||||
|
def deye_reg_triggers_self_sustain_after_verify_exhaust(reg: int) -> bool:
|
||||||
|
"""True = po 3× mismatch přepnout lokalitu do SELF_SUSTAIN (kritický registr)."""
|
||||||
|
return int(reg) in DEYE_CRITICAL_REGS_SELF_SUSTAIN
|
||||||
|
|
||||||
|
|
||||||
|
def _deye_tou_power_verify_match(
|
||||||
|
expected_i: int, actual_i: int, inv: InverterConfig
|
||||||
|
) -> bool:
|
||||||
|
"""Firmware často clampne TOU power W na max z reg. 108/109 × 51.2 V — akceptovat jako OK."""
|
||||||
|
if int(actual_i) == int(expected_i):
|
||||||
|
return True
|
||||||
|
# 51.2 V — nesmí int(BATT_VOLTAGE_V)==51 (off-by-one vs. firmware 17920 W @ 350 A)
|
||||||
|
max_w_charge = int(inv.max_charge_a * BATT_VOLTAGE_V)
|
||||||
|
max_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V)
|
||||||
|
a = int(actual_i)
|
||||||
|
return a == max_w_charge or a == max_w_discharge
|
||||||
|
|
||||||
|
|
||||||
|
def _deye_reg178_verify_with_double_read(
|
||||||
|
expected_i: int, actual_first: int, actual_second: int | None
|
||||||
|
) -> tuple[bool, int]:
|
||||||
|
"""
|
||||||
|
Vrátí (shoda, hodnota_pro_journal).
|
||||||
|
Druhé čtení použít jen když první neprojde maskou (RS485 / glitch).
|
||||||
|
"""
|
||||||
|
if _deye_reg178_verify_match(expected_i, actual_first):
|
||||||
|
return True, actual_first
|
||||||
|
if actual_second is not None and _deye_reg178_verify_match(expected_i, actual_second):
|
||||||
|
return True, int(actual_second)
|
||||||
|
return False, actual_first
|
||||||
|
|
||||||
# Neaktivní TOU bloky (3–6): „konec dne“ — Deye často 23:59 (2359) neuloží a vrátí např. 2355,
|
# Neaktivní TOU bloky (3–6): „konec dne“ — Deye často 23:59 (2359) neuloží a vrátí např. 2355,
|
||||||
# verify pak hlásí mismatch. 23:55 je na zařízeních stabilní (viz HHMM jako desítkové číslo).
|
# verify pak hlásí mismatch. 23:55 je na zařízeních stabilní (viz HHMM jako desítkové číslo).
|
||||||
DEYE_TOU_INACTIVE_HHMM = 2355
|
DEYE_TOU_INACTIVE_HHMM = 2355
|
||||||
@@ -679,10 +719,13 @@ async def verify_modbus_commands(
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Přečte registry zpět (FC 3 po souvislých blocích) a porovná s value_to_write.
|
Přečte registry zpět (FC 3 po souvislých blocích) a porovná s value_to_write.
|
||||||
Při mismatch: retry → SELF_SUSTAIN + Discord.
|
Při mismatch: retry (až 3×). Po vyčerpání pokusů u kritických registrů (108, 109, 142, 143, 145)
|
||||||
|
→ SELF_SUSTAIN + Discord; u „soft“ (178, TOU power W) jen log + Discord, režim se nemění.
|
||||||
"""
|
"""
|
||||||
from services.notification_service import notify_modbus_mismatch
|
from services.notification_service import notify_modbus_mismatch
|
||||||
|
|
||||||
|
inv_cfg = await _load_inverter_config(site_id, db)
|
||||||
|
|
||||||
async def _apply_verify_result(
|
async def _apply_verify_result(
|
||||||
cmd: asyncpg.Record,
|
cmd: asyncpg.Record,
|
||||||
actual_i: int,
|
actual_i: int,
|
||||||
@@ -732,7 +775,33 @@ async def verify_modbus_commands(
|
|||||||
expected_i = int(cmd["value_to_write"])
|
expected_i = int(cmd["value_to_write"])
|
||||||
matches = actual_i == expected_i
|
matches = actual_i == expected_i
|
||||||
if reg == 178:
|
if reg == 178:
|
||||||
matches = _deye_reg178_verify_match(expected_i, actual_i)
|
first_178 = int(actual_i)
|
||||||
|
second_178: int | None = None
|
||||||
|
if not _deye_reg178_verify_match(expected_i, first_178):
|
||||||
|
try:
|
||||||
|
r178 = await client.read_holding_registers(178, 1, unit)
|
||||||
|
if r178 and len(r178) >= 1:
|
||||||
|
second_178 = int(r178[0])
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"[cmd %s] reg178 double-read failed: %s", cmd_id, e
|
||||||
|
)
|
||||||
|
matches, actual_i = _deye_reg178_verify_with_double_read(
|
||||||
|
expected_i, first_178, second_178
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
matches
|
||||||
|
and second_178 is not None
|
||||||
|
and not _deye_reg178_verify_match(expected_i, first_178)
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
"[cmd %s] reg178 double-read recovered: first=%s second=%s",
|
||||||
|
cmd_id,
|
||||||
|
first_178,
|
||||||
|
second_178,
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
@@ -777,6 +846,7 @@ async def verify_modbus_commands(
|
|||||||
await execute_modbus_commands([cmd_id], db)
|
await execute_modbus_commands([cmd_id], db)
|
||||||
await verify_modbus_commands([cmd_id], db, site_id)
|
await verify_modbus_commands([cmd_id], db, site_id)
|
||||||
else:
|
else:
|
||||||
|
if deye_reg_triggers_self_sustain_after_verify_exhaust(reg):
|
||||||
logger.critical(
|
logger.critical(
|
||||||
"[cmd %s] 3 failed attempts, switching to SELF_SUSTAIN",
|
"[cmd %s] 3 failed attempts, switching to SELF_SUSTAIN",
|
||||||
cmd_id,
|
cmd_id,
|
||||||
@@ -789,6 +859,14 @@ async def verify_modbus_commands(
|
|||||||
f"reg 0x{reg:04X}"
|
f"reg 0x{reg:04X}"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"[cmd %s] 3 failed verify attempts on non-critical reg 0x%04X "
|
||||||
|
"(no mode change): %s",
|
||||||
|
cmd_id,
|
||||||
|
reg,
|
||||||
|
cmd["asset_code"],
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if reg == 178 and actual_i != expected_i:
|
if reg == 178 and actual_i != expected_i:
|
||||||
@@ -1009,6 +1087,9 @@ async def _load_inverter_config(
|
|||||||
md = row["max_discharge_a"]
|
md = row["max_discharge_a"]
|
||||||
max_charge_a = int(mc) if mc is not None else 0
|
max_charge_a = int(mc) if mc is not None else 0
|
||||||
max_discharge_a = int(md) if md is not None else 0
|
max_discharge_a = int(md) if md is not None else 0
|
||||||
|
# Firmware Deye často drží max 350 A — vyšší hodnota z DB → mismatch 351 vs 350.
|
||||||
|
max_charge_a = min(max_charge_a, DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A)
|
||||||
|
max_discharge_a = min(max_discharge_a, DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A)
|
||||||
port = int(row["port"] or 502)
|
port = int(row["port"] or 502)
|
||||||
uid = int(row["unit_id"] if row["unit_id"] is not None else 1)
|
uid = int(row["unit_id"] if row["unit_id"] is not None else 1)
|
||||||
return InverterConfig(
|
return InverterConfig(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -40,6 +41,32 @@ async def notify_operating_mode_changed(
|
|||||||
await send_discord(msg, level=lvl)
|
await send_discord(msg, level=lvl)
|
||||||
|
|
||||||
|
|
||||||
|
async def _auto_rolling_replan_after_self_sustain_exit(site_id: int) -> None:
|
||||||
|
"""Po návratu z SELF_SUSTAIN do AUTO přepočítat rolling plán (nové DB spojení)."""
|
||||||
|
try:
|
||||||
|
from app.deps import get_pg_pool
|
||||||
|
from services.planning_engine import run_plan_api
|
||||||
|
|
||||||
|
pool = await get_pg_pool()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Auto replan after SELF_SUSTAIN→AUTO: pool unavailable: %s", e)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as replan_conn:
|
||||||
|
await run_plan_api(
|
||||||
|
site_id,
|
||||||
|
"rolling",
|
||||||
|
replan_conn,
|
||||||
|
triggered_by="mode:self_sustain_exit",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Auto rolling replan after SELF_SUSTAIN→AUTO failed: %s",
|
||||||
|
e,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def run_fn_set_mode_with_discord(
|
async def run_fn_set_mode_with_discord(
|
||||||
conn: asyncpg.Connection,
|
conn: asyncpg.Connection,
|
||||||
site_id: int,
|
site_id: int,
|
||||||
@@ -84,6 +111,15 @@ async def run_fn_set_mode_with_discord(
|
|||||||
notes,
|
notes,
|
||||||
level=notify_level,
|
level=notify_level,
|
||||||
)
|
)
|
||||||
|
prev_u = str(prev).upper()
|
||||||
|
new_u = str(new).upper()
|
||||||
|
if prev_u == "SELF_SUSTAIN" and new_u == "AUTO":
|
||||||
|
try:
|
||||||
|
asyncio.get_running_loop().create_task(
|
||||||
|
_auto_rolling_replan_after_self_sustain_exit(site_id)
|
||||||
|
)
|
||||||
|
except RuntimeError:
|
||||||
|
logger.debug("No event loop; skip auto rolling replan")
|
||||||
return str(new)
|
return str(new)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
from services.control_exporter import (
|
from services.control_exporter import (
|
||||||
ControlSetpoints,
|
ControlSetpoints,
|
||||||
InverterConfig,
|
InverterConfig,
|
||||||
|
_deye_reg178_verify_with_double_read,
|
||||||
_deye_tou_params,
|
_deye_tou_params,
|
||||||
|
_deye_tou_power_verify_match,
|
||||||
|
deye_reg_triggers_self_sustain_after_verify_exhaust,
|
||||||
get_deye_mode,
|
get_deye_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,6 +37,29 @@ def _inv(*, min_soc: int | None = 12, reserve_soc: int | None = 20) -> InverterC
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _inv_350a() -> InverterConfig:
|
||||||
|
"""350 A × 51.2 V = 17920 W — typický firmware clamp pro TOU power."""
|
||||||
|
return replace(_inv(), max_charge_a=350, max_discharge_a=350)
|
||||||
|
|
||||||
|
|
||||||
|
class ModbusVerifyPolicyTests(unittest.TestCase):
|
||||||
|
def test_tou_power_accepts_firmware_max_w_clamp(self) -> None:
|
||||||
|
inv = _inv_350a()
|
||||||
|
self.assertTrue(_deye_tou_power_verify_match(7752, 17920, inv))
|
||||||
|
self.assertTrue(_deye_tou_power_verify_match(16728, 17920, inv))
|
||||||
|
|
||||||
|
def test_reg178_double_read_recovers_from_glitch(self) -> None:
|
||||||
|
ok, v = _deye_reg178_verify_with_double_read(48, 12014, 48)
|
||||||
|
self.assertTrue(ok)
|
||||||
|
self.assertEqual(v, 48)
|
||||||
|
|
||||||
|
def test_reg178_not_critical_for_self_sustain(self) -> None:
|
||||||
|
self.assertFalse(deye_reg_triggers_self_sustain_after_verify_exhaust(178))
|
||||||
|
|
||||||
|
def test_reg108_critical_for_self_sustain(self) -> None:
|
||||||
|
self.assertTrue(deye_reg_triggers_self_sustain_after_verify_exhaust(108))
|
||||||
|
|
||||||
|
|
||||||
class DeyeTouParamsTests(unittest.TestCase):
|
class DeyeTouParamsTests(unittest.TestCase):
|
||||||
def test_sell_uses_reserve_soc(self) -> None:
|
def test_sell_uses_reserve_soc(self) -> None:
|
||||||
sp = ControlSetpoints(
|
sp = ControlSetpoints(
|
||||||
|
|||||||
@@ -139,6 +139,15 @@ Hodnota `deye_zero_export_mode` (1 = zero export to load, 2 = zero export to CT)
|
|||||||
|
|
||||||
**TOU (time points, reg. 166+):** SOC závisí na fyzickém režimu z `get_deye_mode` — **SELL** zapisuje ekonomickou rezervu (`reserve_soc_percent`), **PASSIVE** a neaktivní řádky **3–6** provozní minimum (`min_soc_percent`). Viz [`modbus-registers.md`](modbus-registers.md).
|
**TOU (time points, reg. 166+):** SOC závisí na fyzickém režimu z `get_deye_mode` — **SELL** zapisuje ekonomickou rezervu (`reserve_soc_percent`), **PASSIVE** a neaktivní řádky **3–6** provozní minimum (`min_soc_percent`). Viz [`modbus-registers.md`](modbus-registers.md).
|
||||||
|
|
||||||
|
### Verifikace zápisů (journal) a SELF_SUSTAIN
|
||||||
|
|
||||||
|
Po zápisu na Modbus se hodnoty ověřují v `verify_modbus_commands` (`control_exporter.py`). Po **3 neúspěšných** cyklech zápis+verify:
|
||||||
|
|
||||||
|
- **Kritické registry** (**108, 109, 142, 143, 145**) → přepnutí lokality do **SELF_SUSTAIN** (`system:mismatch`).
|
||||||
|
- **Ostatní** (včetně **178** a **TOU power W 154–159** po vyčerpání soft pravidel) → zůstane **AUTO** (nebo aktuální režim), řádek journalu **`mismatch`**, Discord upozornění.
|
||||||
|
|
||||||
|
Při přechodu **SELF_SUSTAIN → AUTO** (`run_fn_set_mode_with_discord`) se na pozadí spustí **rolling replan**, aby aktivní plán odpovídal plné optimalizaci. Viz [`modbus-command-journal.md`](modbus-command-journal.md).
|
||||||
|
|
||||||
```python
|
```python
|
||||||
async def write_inverter_setpoints(site_id: int, setpoints: Setpoints, db):
|
async def write_inverter_setpoints(site_id: int, setpoints: Setpoints, db):
|
||||||
inverters = await db.fetch(
|
inverters = await db.fetch(
|
||||||
|
|||||||
@@ -24,19 +24,23 @@ 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`.
|
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).
|
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`** — readback může mít jiné ostatní bity (firmware / paralelní čtení). `value_verified` = přečtená surová hodnota; stav **`verified`**, pokud maska sedí s očekáváním.
|
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. **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.
|
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. Po třech neúspěšných cyklech ověření:
|
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.
|
||||||
- **Obyčejné registry** (mimo souvislý blok Deye **62–64**): přepnutí lokality na **SELF_SUSTAIN** přes `run_fn_set_mode_with_discord` → `ems.fn_set_mode` (`activated_by` = `system:mismatch`, poznámka = důvod). Při skutečné změně `mode_code` jde na Discord **kritická** zpráva (stejný formát jako u ostatních přepnutí režimu).
|
6. Po třech neúspěšných cyklech ověření:
|
||||||
|
- **Kritické Deye registry** (**108, 109, 142, 143, 145**): přepnutí lokality na **SELF_SUSTAIN** přes `run_fn_set_mode_with_discord` → `ems.fn_set_mode` (`activated_by` = `system:mismatch`, poznámka = důvod). Při skutečné změně `mode_code` jde na Discord **kritická** zpráva (stejný formát jako u ostatních přepnutí režimu).
|
||||||
|
- **Nekritické / soft registry** (např. **178** po vyčerpání druhého čtení, **154–159** bez akceptovaného clampu, ostatní mimo výše uvedené kritické): po 3 pokusech zůstane řádek v **`mismatch`**, jde **Discord** (`notify_modbus_mismatch`), **režim se nemění**.
|
||||||
- **Výjimka — systémový čas 62–64:** přepnutí režimu **se neprovádí**. Po 3 neúspěšných ověřeních jde **kritický** Discord (`notify_modbus_clock_verify_exhausted`); střídač a EMS režim zůstávají v aktuálním stavu (čas na sběrnici může vyžadovat ruční kontrolu / firmware).
|
- **Výjimka — systémový čas 62–64:** přepnutí režimu **se neprovádí**. Po 3 neúspěšných ověřeních jde **kritický** Discord (`notify_modbus_clock_verify_exhausted`); střídač a EMS režim zůstávají v aktuálním stavu (čas na sběrnici může vyžadovat ruční kontrolu / firmware).
|
||||||
|
|
||||||
|
**Po návratu SELF_SUSTAIN → AUTO** (přes `fn_set_mode`): `notification_service` naplánuje na pozadí **rolling replan** (`run_plan_api`, `triggered_by=mode:self_sustain_exit`), aby aktivní plán odpovídal znovu plné optimalizaci v AUTO.
|
||||||
|
|
||||||
**Baseline po deployi (operativa):** např. počet přepnutí na SELF_SUSTAIN z verify za poslední 2 dny:
|
**Baseline po deployi (operativa):** např. počet přepnutí na SELF_SUSTAIN z verify za poslední 2 dny:
|
||||||
`SELECT count(*) FROM ems.site_operating_mode_log WHERE mode_code = 'SELF_SUSTAIN' AND activated_by = 'system:mismatch' AND activated_at >= now() - interval '2 days';`
|
`SELECT count(*) FROM ems.site_operating_mode_log WHERE mode_code = 'SELF_SUSTAIN' AND activated_by = 'system:mismatch' AND activated_at >= now() - interval '2 days';`
|
||||||
Pro diagnostiku času Deye po opravě clock logiky používej u `modbus_command` krátké okno (např. `verified_at >= now() - interval '2 days'`).
|
Pro diagnostiku času Deye po opravě clock logiky používej u `modbus_command` krátké okno (např. `verified_at >= now() - interval '2 days'`).
|
||||||
|
|
||||||
**Discord při jakékoli změně režimu** (nejen Modbus): `notification_service.run_fn_set_mode_with_discord` volá `ems.fn_set_mode` a při změně `mode_code` oproti stavu před voláním pošle zprávu (`notify_operating_mode_changed`). Úroveň: `user:api` → info, obecné `system:*` → warning, `system:mismatch` → critical. Použití: HTTP `POST /api/v1/sites/{site_id}/mode`, `_switch_to_self_sustain` v `control_exporter`. Vypršení `valid_until`: `ems.fn_expire_modes()` vrací řádky `(site_id, site_code, old_mode, new_mode)` pro každé provedené přepnutí; scheduler v `main.py` (a lazy expire v `_fetch_operating_mode`) z nich pošle Discord.
|
**Discord při jakékoli změně režimu** (nejen Modbus): `notification_service.run_fn_set_mode_with_discord` volá `ems.fn_set_mode` a při změně `mode_code` oproti stavu před voláním pošle zprávu (`notify_operating_mode_changed`). Úroveň: `user:api` → info, obecné `system:*` → warning, `system:mismatch` → critical. Použití: HTTP `POST /api/v1/sites/{site_id}/mode`, `_switch_to_self_sustain` v `control_exporter`. Vypršení `valid_until`: `ems.fn_expire_modes()` vrací řádky `(site_id, site_code, old_mode, new_mode)` pro každé provedené přepnutí; scheduler v `main.py` (a lazy expire v `_fetch_operating_mode`) z nich pošle Discord.
|
||||||
|
|
||||||
Implementace: `services/control_exporter.py` — `verify_modbus_commands`, `_verify_deye_clock_written_bundle`, `_fetch_written_deye_clock_commands`, `_switch_to_self_sustain`; `services/notification_service.py` — `run_fn_set_mode_with_discord`, `notify_operating_mode_changed`.
|
Implementace: `services/control_exporter.py` — `verify_modbus_commands`, `_verify_deye_clock_written_bundle`, `_fetch_written_deye_clock_commands`, `_switch_to_self_sustain`, `DEYE_CRITICAL_REGS_SELF_SUSTAIN`, `_deye_tou_power_verify_match`; `services/notification_service.py` — `run_fn_set_mode_with_discord`, `_auto_rolling_replan_after_self_sustain_exit`, `notify_operating_mode_changed`.
|
||||||
|
|
||||||
## Střídač (Deye)
|
## Střídač (Deye)
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ EMS zapisuje řídící hodnoty přes journal (`modbus_command`) a **`write_regi
|
|||||||
|
|
||||||
| Reg | Název | Rozsah | Jednotka | Použití v EMS |
|
| Reg | Název | Rozsah | Jednotka | Použití v EMS |
|
||||||
|-----|-------|--------|----------|---------------|
|
|-----|-------|--------|----------|---------------|
|
||||||
| 108 | Max charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | EMS počítá proud v **SQL**: `COALESCE(deye_register_max_charge_a, FLOOR(LEAST(W)/51.2))` — sloupec stropu v **A** je volitelný (NULL = jen odvod z kW); při vyplnění např. 350 při W→351 A se použije 350. |
|
| 108 | Max charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | EMS počítá proud v **SQL**: `COALESCE(deye_register_max_charge_a, FLOOR(LEAST(W)/51.2))` — sloupec stropu v **A** je volitelný (NULL = jen odvod z kW); při vyplnění např. 350 při W→351 A se použije 350. V Pythonu se navíc **clampuje horní strop 350 A** (`DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A`), aby firmware nevracel 350 při zápisu 351. |
|
||||||
| 109 | Max discharge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Stejně: `COALESCE(deye_register_max_discharge_a, FLOOR(LEAST(W)/51.2))`. |
|
| 109 | Max discharge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Stejně: `COALESCE(deye_register_max_discharge_a, FLOOR(LEAST(W)/51.2))` + **clamp 350 A** jako u 108. |
|
||||||
| 128 | Grid charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Nabíjení ze sítě |
|
| 128 | Grid charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Nabíjení ze sítě |
|
||||||
| 130 | Grid charge enable | 0/1 | — | 1 = povolit nabíjení ze sítě |
|
| 130 | Grid charge enable | 0/1 | — | 1 = povolit nabíjení ze sítě |
|
||||||
| 141 | Energy mgmt mode | bitmask | — | EMS vždy **0** (neměnit jinak) |
|
| 141 | Energy mgmt mode | bitmask | — | EMS vždy **0** (neměnit jinak) |
|
||||||
@@ -38,7 +38,7 @@ EMS zapisuje řídící hodnoty přes journal (`modbus_command`) a **`write_regi
|
|||||||
|
|
||||||
EMS **nezapisuje** read-modify-write (paralelní čtení jinými klienty může způsobit nesoulad).
|
EMS **nezapisuje** read-modify-write (paralelní čtení jinými klienty může způsobit nesoulad).
|
||||||
|
|
||||||
**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. Detail: `modbus-command-journal.md`.
|
**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`.
|
||||||
|
|
||||||
## Klíčové registry podle fyzického režimu Deye
|
## Klíčové registry podle fyzického režimu Deye
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user