From 54288ee2fd0c6041db93b2a15e2d69f6499f7633 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Sat, 13 Jun 2026 22:03:11 +0200 Subject: [PATCH] fix(modbus): reg 15 re-asert kazdy tick + per-charger failsafe (BUG1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zivy incident home-01 (TeltoCharge .16): od ~22:45 UTC 12.6. nevznikl zadny telto journal radek (ani failed), auto jelo failsafe 8 A misto planovanych 0 A. Root cause: reg 15 (amps) byl write-on-change proti journalu (fn_modbus_device_state_map). Jakmile mel reg 15 radek "0 verified" a plan dal chtel 0, NIKDY nevznikl novy prikaz -- a TeltoCharge si po vypadku komunikace sam prepsal reg 15 na failsafe (reg 20) BEZ journal radku. Verify cte zpet jen 'written' radky, takze tichy drift 0 -> 8 A nikdo nevidel ani neopravil. - reg 15 (amps to use) se zapisuje VZDY (re-asert) -- volatilni ridici registr, ne EEPROM; drzi verify jobu cerstvy written radek -> drift se zachyti a hned opravi. _split_amps_and_watchdog odděluje 15 od 19/20. - reg 19/20 (watchdog config, EEPROM) zustavaji write-on-change. - per-charger failsafe/timeout: asset_ev_charger.watchdog_failsafe_a / watchdog_comm_timeout_s (V106; default 8 A / 300 s). "Zakaz nabijeni" = reg 15 = 0 (protokol rev 0.5 nema samostatny enable registr). - testy test_ev_write_on_change.py; docs teltocharge + journal + data-model. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/services/control/outputs.py | 113 ++++++++++++------ backend/tests/test_ev_write_on_change.py | 96 +++++++++------ .../V106__ev_charger_failsafe_current.sql | 18 +++ docs/03-data-model.md | 4 + docs/04-modules/modbus-command-journal.md | 29 +++-- .../modbus-registers-teltocharge.md | 69 +++++++---- 6 files changed, 229 insertions(+), 100 deletions(-) create mode 100644 db/migration/V106__ev_charger_failsafe_current.sql diff --git a/backend/services/control/outputs.py b/backend/services/control/outputs.py index 5eb024a..73bdf2c 100644 --- a/backend/services/control/outputs.py +++ b/backend/services/control/outputs.py @@ -18,33 +18,52 @@ logger = logging.getLogger(__name__) TELTO_REG_AMPS_TO_USE = 15 # 0 = stop, 6–32 A TELTO_REG_COMM_TIMEOUT_S = 19 # watchdog: bez komunikace → failsafe TELTO_REG_FAILSAFE_CURRENT_A = 20 -#: Výpadek EMS: po 5 min bez zápisu wallbox přejde na failsafe proud — -#: auto se přes noc nabije i bez EMS (pomalu), místo aby stálo na 0 A. +#: Výpadek EMS: po watchdog_comm_timeout_s bez komunikace wallbox přejde na +#: failsafe proud — auto se přes noc nabije i bez EMS (pomalu), místo aby +#: stálo na 0 A. Defaulty (fallback, když řádek chargeru nemá vlastní hodnoty). TELTO_WATCHDOG_TIMEOUT_S = 300 TELTO_WATCHDOG_FAILSAFE_A = 8 -def _telto_setpoint_registers(current_a: int) -> list[tuple[int, str, int]]: +def _telto_setpoint_registers( + current_a: int, + *, + comm_timeout_s: int = TELTO_WATCHDOG_TIMEOUT_S, + failsafe_a: int = TELTO_WATCHDOG_FAILSAFE_A, +) -> list[tuple[int, str, int]]: """Registry pro jeden export tick: limit proudu + watchdog konfigurace. - Write-on-change: volající VŽDY filtruje přes drop-unchanged proti - fn_modbus_device_state_map (poslední written/verified per registr) — - watchdog 19/20 se reálně zapíše jen po startu / po výpadku zařízení, - amps (15) jen při změně plánu. Watchdog timer TeltoCharge sytí jakákoli - validní Modbus komunikace (i FC3 čtení telemetrie každých 60 s), takže - periodické zápisy k udržení spojení NEJSOU potřeba (oficiální protokol, - docs/04-modules/modbus-registers-teltocharge.md). + **Reg 15 (amps to use) NENÍ write-on-change** — viz `_assert_amps_register`. + Je to volatilní řídicí registr: TeltoCharge ho po výpadku komunikace sám + přepíše na failsafe (reg 20), aniž by o tom vznikl journal řádek. Kdyby se + reg 15 dropoval proti journalu (poslední „0 verified"), EMS by tichý drift + 0 → 8 A NIKDY nezahlédlo (verify čte zpět jen `written` řádky) a nikdy ho + neopravilo. Proto se reg 15 re-asertuje KAŽDÝ export tick (≤ 8×/hod) — + EEPROM wear se týká jen konfiguračních 19/20, které write-on-change zůstávají. + + Watchdog timer TeltoCharge sytí jakákoli validní Modbus komunikace (i FC3 + čtení telemetrie každých 60 s), takže periodické zápisy k udržení spojení + NEJSOU potřeba; failsafe/timeout (19/20) per charger z DB. """ a = int(current_a) if a < 6: a = 0 return [ (TELTO_REG_AMPS_TO_USE, "telto_amps_to_use", min(a, 32)), - (TELTO_REG_COMM_TIMEOUT_S, "telto_comm_timeout_s", TELTO_WATCHDOG_TIMEOUT_S), - (TELTO_REG_FAILSAFE_CURRENT_A, "telto_failsafe_a", TELTO_WATCHDOG_FAILSAFE_A), + (TELTO_REG_COMM_TIMEOUT_S, "telto_comm_timeout_s", int(comm_timeout_s)), + (TELTO_REG_FAILSAFE_CURRENT_A, "telto_failsafe_a", max(0, min(int(failsafe_a), 32))), ] +def _split_amps_and_watchdog( + registers: list[tuple[int, str, int]], +) -> tuple[list[tuple[int, str, int]], list[tuple[int, str, int]]]: + """Rozdělí registry na (reg 15 = vždy zapsat) a (19/20 = write-on-change).""" + amps = [r for r in registers if r[0] == TELTO_REG_AMPS_TO_USE] + watchdog = [r for r in registers if r[0] != TELTO_REG_AMPS_TO_USE] + return amps, watchdog + + def _current_limit_for_charger(charger_code: str, sp: ControlSetpoints) -> int: c = (charger_code or "").strip().lower() if c == "ev-charger-1": @@ -74,7 +93,8 @@ async def write_ev_setpoints( rows = await db.fetch( """ - SELECT ec.id AS asset_id, ec.code, se.host, se.port, se.unit_id + SELECT ec.id AS asset_id, ec.code, se.host, se.port, se.unit_id, + ec.watchdog_failsafe_a, ec.watchdog_comm_timeout_s FROM ems.asset_ev_charger ec JOIN ems.site_endpoint se ON se.id = ec.endpoint_id WHERE ec.site_id = $1 @@ -97,16 +117,31 @@ async def write_ev_setpoints( unit_id = int(row["unit_id"] if row["unit_id"] is not None else 1) current_a = _current_limit_for_charger(code, setpoints) - registers = _telto_setpoint_registers(current_a) - # Write-on-change: poslední written/verified stav (ne jen verified) — - # zápis se nesmí opakovat každý tick, když verify čtení zaostává. + registers = _telto_setpoint_registers( + current_a, + comm_timeout_s=int( + row["watchdog_comm_timeout_s"] + if row["watchdog_comm_timeout_s"] is not None + else TELTO_WATCHDOG_TIMEOUT_S + ), + failsafe_a=int( + row["watchdog_failsafe_a"] + if row["watchdog_failsafe_a"] is not None + else TELTO_WATCHDOG_FAILSAFE_A + ), + ) + amps_regs, watchdog_regs = _split_amps_and_watchdog(registers) + # Reg 15 = vždy (re-asert proti tichému watchdog failsafe driftu na + # zařízení, který nemá journal řádek). Reg 19/20 = write-on-change + # proti fn_modbus_device_state_map (poslední written/verified stav). device_state = await _fetch_device_state_registers( site_id, asset_id, db, asset_type="ev_charger" ) - registers, skipped = _drop_registers_matching_last_verified( - registers, device_state + watchdog_regs, skipped = _drop_registers_matching_last_verified( + watchdog_regs, device_state ) - if not registers: + to_write = amps_regs + watchdog_regs + if not to_write: logger.debug("EV setpoint [%s]: beze změny (%s A)", code, current_a) continue @@ -119,7 +154,7 @@ async def write_ev_setpoints( host, port, unit_id, - registers, + to_write, db, ) ok = await execute_modbus_commands(cmd_ids, db) @@ -128,7 +163,7 @@ async def write_ev_setpoints( "EV setpoint [%s]: %s A (regs %s%s) -> %s", code, current_a, - [r for r, _, _ in registers], + [r for r, _, _ in to_write], f", skip {skipped}" if skipped else "", "written" if ok else "FAILED", ) @@ -155,7 +190,8 @@ async def write_ev_arrival_hold( row = await db.fetchrow( """ - SELECT ec.id AS asset_id, ec.code, se.host, se.port, se.unit_id + SELECT ec.id AS asset_id, ec.code, se.host, se.port, se.unit_id, + ec.watchdog_failsafe_a, ec.watchdog_comm_timeout_s FROM ems.asset_ev_charger ec JOIN ems.site_endpoint se ON se.id = ec.endpoint_id WHERE ec.site_id = $1 @@ -170,20 +206,29 @@ async def write_ev_arrival_hold( if row is None: return False asset_id = int(row["asset_id"]) - registers = _telto_setpoint_registers(0) + registers = _telto_setpoint_registers( + 0, + comm_timeout_s=int( + row["watchdog_comm_timeout_s"] + if row["watchdog_comm_timeout_s"] is not None + else TELTO_WATCHDOG_TIMEOUT_S + ), + failsafe_a=int( + row["watchdog_failsafe_a"] + if row["watchdog_failsafe_a"] is not None + else TELTO_WATCHDOG_FAILSAFE_A + ), + ) + amps_regs, watchdog_regs = _split_amps_and_watchdog(registers) + # Reg 15 = 0 A se zapíše VŽDY (tvrdé zastavení po píchnutí kabelu; wallbox + # po připojení sám rozjíždí nabíjení defaultem). Reg 19/20 write-on-change. device_state = await _fetch_device_state_registers( site_id, asset_id, db, asset_type="ev_charger" ) - registers, skipped = _drop_registers_matching_last_verified( - registers, device_state + watchdog_regs, skipped = _drop_registers_matching_last_verified( + watchdog_regs, device_state ) - if not registers: - logger.info( - "EV arrival hold [%s]: 0 A už na zařízení (skip %s)", - charger_code, - skipped, - ) - return True + to_write = amps_regs + watchdog_regs cmd_ids = await create_modbus_commands( site_id, None, @@ -193,14 +238,14 @@ async def write_ev_arrival_hold( str(row["host"]), int(row["port"] or 502), int(row["unit_id"] if row["unit_id"] is not None else 1), - registers, + to_write, db, ) ok = await execute_modbus_commands(cmd_ids, db) logger.info( "EV arrival hold [%s]: 0 A (regs %s%s) %s", charger_code, - [r for r, _, _ in registers], + [r for r, _, _ in to_write], f", skip {skipped}" if skipped else "", "written" if ok else "FAILED", ) diff --git a/backend/tests/test_ev_write_on_change.py b/backend/tests/test_ev_write_on_change.py index c1efbd3..0b13958 100644 --- a/backend/tests/test_ev_write_on_change.py +++ b/backend/tests/test_ev_write_on_change.py @@ -1,11 +1,13 @@ -"""Write-on-change pro TeltoCharge: zápis JEN při skutečné změně hodnoty. +"""TeltoCharge zápis: reg 15 (amps) VŽDY, watchdog 19/20 write-on-change. -Regrese na opakované zápisy do wallboxu (EEPROM wear): export tick běží -~8x/hod (control_export :14,:29,:44,:59 + rolling replan */15 s exportem), -ale reg 15/19/20 se smí zapsat jen při změně proti poslednímu known stavu -zařízení (fn_modbus_device_state_map: nejnovější written/verified řádek -journalu per registr). Watchdog (19/20) se zapíše jednou po startu / po -výpadku; sytí ho i FC3 čtení telemetrie (60 s), periodické zápisy netřeba. +Export tick běží ~8x/hod (control_export :14,:29,:44,:59 + rolling replan +*/15 s exportem). **Reg 15 (amps to use) se zapisuje VŽDY** — TeltoCharge ho +po výpadku komunikace sám přepíše na failsafe (reg 20) bez journal řádku, a +kdyby byl write-on-change, EMS by tichý drift 0 → 8 A nikdy nezahlédlo +(verify čte zpět jen `written`). **Reg 19/20 (watchdog config, EEPROM wear) +zůstávají write-on-change** proti fn_modbus_device_state_map (nejnovější +written/verified řádek per registr): zapíší se jednou po startu / po výpadku; +sytí je i FC3 čtení telemetrie (60 s), periodické zápisy netřeba. """ import unittest @@ -20,6 +22,7 @@ from services.control.outputs import ( TELTO_REG_FAILSAFE_CURRENT_A, TELTO_WATCHDOG_FAILSAFE_A, TELTO_WATCHDOG_TIMEOUT_S, + _split_amps_and_watchdog, _telto_setpoint_registers, write_ev_arrival_hold, write_ev_setpoints, @@ -59,40 +62,48 @@ class TeltoSetpointRegistersTests(unittest.TestCase): self.assertEqual(_telto_setpoint_registers(6)[0][2], 6) self.assertEqual(_telto_setpoint_registers(40)[0][2], 32) + def test_per_charger_failsafe_and_timeout(self) -> None: + regs = _telto_setpoint_registers(0, comm_timeout_s=120, failsafe_a=6) + self.assertEqual([(r, v) for r, _, v in regs], [(15, 0), (19, 120), (20, 6)]) + + def test_failsafe_clamped_to_0_32(self) -> None: + self.assertEqual(_telto_setpoint_registers(0, failsafe_a=99)[2][2], 32) + self.assertEqual(_telto_setpoint_registers(0, failsafe_a=-5)[2][2], 0) + + def test_split_separates_amps_from_watchdog(self) -> None: + amps, watchdog = _split_amps_and_watchdog(_telto_setpoint_registers(0)) + self.assertEqual([r for r, _, _ in amps], [15]) + self.assertEqual([r for r, _, _ in watchdog], [19, 20]) + class DropAgainstDeviceStateTests(unittest.TestCase): - def test_steady_state_drops_everything(self) -> None: + def test_watchdog_steady_state_drops_19_20(self) -> None: + _, watchdog = _split_amps_and_watchdog(_telto_setpoint_registers(0)) out, skipped = _drop_registers_matching_last_verified( - _telto_setpoint_registers(0), _STEADY_STATE_0A + watchdog, _STEADY_STATE_0A ) self.assertEqual(out, []) - self.assertEqual(skipped, [15, 19, 20]) - - def test_amps_change_writes_only_reg_15(self) -> None: - out, skipped = _drop_registers_matching_last_verified( - _telto_setpoint_registers(16), _STEADY_STATE_0A - ) - self.assertEqual([(r, v) for r, _, v in out], [(15, 16)]) self.assertEqual(skipped, [19, 20]) - def test_empty_state_after_outage_writes_full_triple(self) -> None: - out, skipped = _drop_registers_matching_last_verified( - _telto_setpoint_registers(0), {} - ) - self.assertEqual([r for r, _, _ in out], [15, 19, 20]) + def test_empty_state_after_outage_keeps_19_20(self) -> None: + _, watchdog = _split_amps_and_watchdog(_telto_setpoint_registers(0)) + out, skipped = _drop_registers_matching_last_verified(watchdog, {}) + self.assertEqual([r for r, _, _ in out], [19, 20]) self.assertEqual(skipped, []) class _FakeDB: """Jen řádky chargeru; journal funkce se patchují v modbus_journal.""" - def __init__(self) -> None: + def __init__(self, failsafe_a: int = 8, comm_timeout_s: int = 300) -> None: self.row = { "asset_id": 7, "code": "ev-charger-1", "host": "172.16.1.16", "port": 502, "unit_id": 1, + "watchdog_failsafe_a": failsafe_a, + "watchdog_comm_timeout_s": comm_timeout_s, } async def fetch(self, query: str, *args: object) -> list[dict]: @@ -105,9 +116,9 @@ class _FakeDB: raise AssertionError(f"unexpected fetchval: {query}") -class WriteEvSetpointsOnChangeTests(unittest.IsolatedAsyncioTestCase): +class WriteEvSetpointsTests(unittest.IsolatedAsyncioTestCase): async def _run( - self, device_state: dict[int, int], ev1_a: int + self, device_state: dict[int, int], ev1_a: int, db: _FakeDB | None = None ) -> tuple[AsyncMock, AsyncMock]: create = AsyncMock(return_value=[1, 2, 3]) execute = AsyncMock(return_value=True) @@ -120,13 +131,17 @@ class WriteEvSetpointsOnChangeTests(unittest.IsolatedAsyncioTestCase): patch.object(journal, "create_modbus_commands", create), patch.object(journal, "execute_modbus_commands", execute), ): - await write_ev_setpoints(1, _setpoints(ev1_a), _FakeDB()) # type: ignore[arg-type] + await write_ev_setpoints(1, _setpoints(ev1_a), db or _FakeDB()) # type: ignore[arg-type] return create, execute - async def test_steady_state_no_write_at_all(self) -> None: + async def test_steady_state_still_reasserts_reg_15(self) -> None: + # Reg 15 se zapisuje VŽDY (re-asert proti tichému failsafe driftu), + # i když je device-state mapa shodná. Watchdog 19/20 se přeskočí. create, execute = await self._run(_STEADY_STATE_0A, ev1_a=0) - create.assert_not_awaited() - execute.assert_not_awaited() + create.assert_awaited_once() + registers = create.await_args.args[8] + self.assertEqual([(r, v) for r, _, v in registers], [(15, 0)]) + execute.assert_awaited_once() async def test_plan_change_writes_only_amps(self) -> None: create, execute = await self._run(_STEADY_STATE_0A, ev1_a=16) @@ -135,14 +150,24 @@ class WriteEvSetpointsOnChangeTests(unittest.IsolatedAsyncioTestCase): self.assertEqual([(r, v) for r, _, v in registers], [(15, 16)]) execute.assert_awaited_once() - async def test_after_outage_writes_full_triple(self) -> None: + async def test_after_outage_writes_amps_then_watchdog(self) -> None: create, execute = await self._run({}, ev1_a=0) registers = create.await_args.args[8] self.assertEqual([r for r, _, _ in registers], [15, 19, 20]) execute.assert_awaited_once() + async def test_per_charger_failsafe_from_db(self) -> None: + # Failsafe 6 A z DB → po výpadku se zapíše reg 20 = 6 (prázdná mapa). + create, _ = await self._run( + {}, ev1_a=0, db=_FakeDB(failsafe_a=6, comm_timeout_s=120) + ) + registers = create.await_args.args[8] + self.assertEqual( + [(r, v) for r, _, v in registers], [(15, 0), (19, 120), (20, 6)] + ) -class WriteEvArrivalHoldOnChangeTests(unittest.IsolatedAsyncioTestCase): + +class WriteEvArrivalHoldTests(unittest.IsolatedAsyncioTestCase): async def _run( self, device_state: dict[int, int] ) -> tuple[bool, AsyncMock, AsyncMock]: @@ -160,13 +185,15 @@ class WriteEvArrivalHoldOnChangeTests(unittest.IsolatedAsyncioTestCase): ok = await write_ev_arrival_hold(1, "ev-charger-1", _FakeDB()) # type: ignore[arg-type] return ok, create, execute - async def test_hold_skips_when_device_already_at_zero(self) -> None: + async def test_hold_always_writes_reg_15_even_if_device_at_zero(self) -> None: + # Tvrdé zastavení po píchnutí kabelu — reg 15 = 0 se zapíše VŽDY. ok, create, execute = await self._run(_STEADY_STATE_0A) self.assertTrue(ok) - create.assert_not_awaited() - execute.assert_not_awaited() + registers = create.await_args.args[8] + self.assertEqual([(r, v) for r, _, v in registers], [(15, 0)]) + execute.assert_awaited_once() - async def test_hold_writes_only_amps_when_watchdog_already_set(self) -> None: + async def test_hold_writes_amps_and_watchdog_when_device_drifted(self) -> None: ok, create, execute = await self._run( { TELTO_REG_AMPS_TO_USE: 16, @@ -176,6 +203,7 @@ class WriteEvArrivalHoldOnChangeTests(unittest.IsolatedAsyncioTestCase): ) self.assertTrue(ok) registers = create.await_args.args[8] + # Reg 15 = 0 zapsán (i když device hlásí 16); 19/20 shodné → skip. self.assertEqual([(r, v) for r, _, v in registers], [(15, 0)]) execute.assert_awaited_once() diff --git a/db/migration/V106__ev_charger_failsafe_current.sql b/db/migration/V106__ev_charger_failsafe_current.sql new file mode 100644 index 0000000..2cbd7f3 --- /dev/null +++ b/db/migration/V106__ev_charger_failsafe_current.sql @@ -0,0 +1,18 @@ +-- Per-charger watchdog failsafe proud (reg 20 TeltoCharge) + comm timeout (reg 19). +-- Failsafe = proud, na který wallbox spadne po výpadku komunikace EMS delším než +-- comm timeout. Default 8 A historicky (auto se přes noc dobije pomalu i bez EMS), +-- ale po ZAPOJENÍ má jet řízeně z plánu (0 A drží arrival-hold + watchdog sycení +-- čtením telemetrie), ne failsafe. Konfigurovatelné per charger, ať lze failsafe +-- snížit (např. 6 A) nebo zvednout dle dotačních / komfortních požadavků. +-- +-- Sémantika: hodnota PŘI výpadku EMS, ne při běžném provozu. Proto se obvykle +-- drží min 6 A (IEC 61851 minimum); 0 = po výpadku vědomě nenabíjet. + +alter table ems.asset_ev_charger + add column if not exists watchdog_failsafe_a int not null default 8, + add column if not exists watchdog_comm_timeout_s int not null default 300; + +comment on column ems.asset_ev_charger.watchdog_failsafe_a is + 'TeltoCharge reg 20: proud (A) při výpadku komunikace EMS déle než watchdog_comm_timeout_s. Default 8 A (pomalé dobití bez EMS). 0 = po výpadku nenabíjet. Běžný provoz řídí reg 15 z plánu, ne failsafe.'; +comment on column ems.asset_ev_charger.watchdog_comm_timeout_s is + 'TeltoCharge reg 19: timeout (s) bez validní Modbus komunikace, po kterém wallbox přejde na watchdog_failsafe_a. Sytí ho i FC3 čtení telemetrie (60 s). Default 300 s.'; diff --git a/docs/03-data-model.md b/docs/03-data-model.md index 31bfe33..47300c6 100644 --- a/docs/03-data-model.md +++ b/docs/03-data-model.md @@ -175,6 +175,10 @@ CREATE TABLE asset_ev_charger ( phases INT DEFAULT 3, connector_count INT DEFAULT 1, schedulable BOOLEAN DEFAULT true, + -- TeltoCharge watchdog (V106): reg 19/20. Failsafe = proud po výpadku + -- komunikace EMS; běžný provoz řídí reg 15 z plánu, ne failsafe. + watchdog_failsafe_a INT NOT NULL DEFAULT 8, -- reg 20: 0–32 A (0 = po výpadku nenabíjet) + watchdog_comm_timeout_s INT NOT NULL DEFAULT 300, -- reg 19: s bez komunikace → failsafe notes TEXT ); ``` diff --git a/docs/04-modules/modbus-command-journal.md b/docs/04-modules/modbus-command-journal.md index 873d5c9..fb2d350 100644 --- a/docs/04-modules/modbus-command-journal.md +++ b/docs/04-modules/modbus-command-journal.md @@ -48,20 +48,29 @@ Implementace: `services/control_exporter.py` — `verify_modbus_commands`, `_ver ## EV wallbox (TeltoCharge) `write_ev_setpoints` (každý export tick) a `write_ev_arrival_hold` (po detekci -příjezdu EV) zapisují registry **15** (amps to use), **19** (comm timeout 300 s) -a **20** (failsafe 8 A) — vždy přes journal (`asset_type = 'ev_charger'`). +příjezdu EV) zapisují registry **15** (amps to use), **19** (comm timeout) a +**20** (failsafe) — vždy přes journal (`asset_type = 'ev_charger'`). Timeout +a failsafe jsou per charger (`asset_ev_charger.watchdog_comm_timeout_s` / +`watchdog_failsafe_a`, V106; default 300 s / 8 A). - **Verify job ověřuje všechny asset typy** — `fn_modbus_written_command_ids` nefiltruje podle `asset_type` a registry 15/19/20 jsou dle protokolu R/W (čtou se zpět standardní FC 3 větví). -- **Write-on-change:** před zápisem se registry filtrují proti - **`ems.fn_modbus_device_state_map`** (nejnovější řádek journalu per registr; - hodnota jen pro stav `written`/`verified`). Shodná hodnota ⇒ zápis se - přeskočí. Na rozdíl od `fn_modbus_last_verified_map` (Deye drop-unchanged) - nečeká na verify — `written` stačí, takže pomalý/neúspěšný verify read - nevede k opakovaným zápisům každý tick (EEPROM wear). Nejnovější řádek - `failed`/`mismatch` ⇒ registr v mapě chybí ⇒ po výpadku zařízení se - konfigurace (vč. watchdog 19/20) obnoví jedním zápisem. +- **Reg 15 (amps) se zapisuje KAŽDÝ tick** (re-asert), **NE write-on-change.** + Incident 2026-06-13: TeltoCharge si po výpadku komunikace sám přepíše reg 15 + na failsafe (reg 20) bez journal řádku; write-on-change proti journalu + (poslední „0 verified") by tichý drift **0 → 8 A** nikdy nezahlédlo (verify + čte zpět jen `written`) a nikdy neopravilo. Re-asert každý tick drift opraví + a drží verify jobu čerstvý `written` reg-15 řádek. Reg 15 je volatilní řídicí + registr (ne EEPROM). +- **Reg 19/20 (watchdog config) zůstávají write-on-change:** před zápisem se + filtrují proti **`ems.fn_modbus_device_state_map`** (nejnovější řádek journalu + per registr; hodnota jen pro stav `written`/`verified`). Shodná hodnota ⇒ + zápis se přeskočí. Na rozdíl od `fn_modbus_last_verified_map` (Deye + drop-unchanged) nečeká na verify — `written` stačí, takže pomalý/neúspěšný + verify read nevede k opakovaným zápisům každý tick (EEPROM wear). Nejnovější + řádek `failed`/`mismatch` ⇒ registr v mapě chybí ⇒ po výpadku zařízení se + watchdog 19/20 obnoví jedním zápisem. - **Mismatch po 3 pokusech NEpřepíná SELF_SUSTAIN** — fallback režim je Deye politika (`asset_type = 'inverter'`); u wallboxu zůstane řádek `mismatch` + Discord (`notify_modbus_mismatch`). diff --git a/docs/04-modules/modbus-registers-teltocharge.md b/docs/04-modules/modbus-registers-teltocharge.md index 8c350a9..8295002 100644 --- a/docs/04-modules/modbus-registers-teltocharge.md +++ b/docs/04-modules/modbus-registers-teltocharge.md @@ -32,42 +32,67 @@ ukončil session a EV výkon 0 by špinil bazál (pravidlo 15). | Reg | R/W | Význam | Hodnoty | EMS zapisuje | |-----|-----|--------|---------|--------------| -| 15 | R/W | **Amps to use** (limit proudu) | 0 = stop, 6–32 A | hodnota z plánu (`ev{1,2}_current_a`); příjezd EV → hold 0 A | -| 16 | R/W | Start/Stop session | 0 nic · 1 stop · 2 start | ne | -| 19 | R/W | Communication timeout (watchdog) | 0–600 s (0 = vypnuto), default 30 | `TELTO_WATCHDOG_TIMEOUT_S` = **300** | -| 20 | R/W | Failsafe current | 0, 6–32 A, default 6 | `TELTO_WATCHDOG_FAILSAFE_A` = **8** | +| 15 | R/W | **Amps to use** (limit proudu) | 0 = stop, 6–32 A | hodnota z plánu (`ev{1,2}_current_a`); příjezd EV → hold 0 A. **Zapisuje se KAŽDÝ tick** (re-asert, ne write-on-change — viz níže) | +| 16 | R/W | Start/Stop session | 0 nic · 1 stop · 2 start | ne (tvrdé zastavení řešíme reg 15 = 0) | +| 19 | R/W | Communication timeout (watchdog) | 0–600 s (0 = vypnuto), default 30 | per charger `asset_ev_charger.watchdog_comm_timeout_s` (default **300**) | +| 20 | R/W | Failsafe current | 0, 6–32 A, default 6 | per charger `asset_ev_charger.watchdog_failsafe_a` (default **8**) | Všechny čtyři registry jsou dle oficiálního protokolu (wiki *External control RS485* / protokol rev 0.5) **R/W** — verify job je čte zpět standardní FC 3 větví (žádný write-only registr v této sadě). -### Write-on-change — POVINNÉ (EEPROM wear) +**„Zákaz nabíjení" = reg 15 = 0.** Protokol rev 0.5 v této sadě **nemá** +samostatný boolean „charging enable/disable" registr — řízení je proudovým +limitem (reg 15: 0 = stop) plus volitelně reg 16 (1 = stop session). EMS +používá **reg 15 = 0** jako řízené zastavení (arrival-hold i běžný plán); +reg 16 se nezapisuje. Failsafe (reg 20) je hodnota PŘI výpadku komunikace, +ne při běžném provozu — běžně auto stojí na 0 A, dokud plán neřekne jinak. + +### Reg 15 (amps) — VŽDY re-asert; reg 19/20 — write-on-change (EEPROM) Export tick běží ~8×/hod (control_export `:14,:29,:44,:59` + rolling replan -`*/15` s exportem). Zápis do wallboxu se proto provádí **jen při skutečné -změně hodnoty**: `write_ev_setpoints` i `write_ev_arrival_hold` filtrují -registry přes `_drop_registers_matching_last_verified` proti -**`ems.fn_modbus_device_state_map`** (nejnovější řádek journalu per registr -se stavem `written` **nebo** `verified`). Důsledky: +`*/15` s exportem). -- **reg 15** se zapíše jen při změně plánovaného proudu (0 ↔ 6–32 A) — to je - legitimní zápis; -- **reg 19/20** se zapíší jednou po nasazení / po výpadku zařízení (nejnovější - řádek `failed`/`mismatch` ⇒ registr v mapě chybí ⇒ znovu se zapíše) a pak - už nikdy, dokud se hodnota nezmění; -- čekání na verify **neblokuje** skip — `written` (TCP ack) stačí, mismatch - z verify stav mapy zneplatní a vynutí nový zápis. +- **reg 15 (amps to use) se zapisuje při KAŽDÉM ticku** (`write_ev_setpoints` + i `write_ev_arrival_hold`). **Důvod (incident 2026-06-13):** TeltoCharge si + po výpadku komunikace sám přepíše reg 15 na failsafe (reg 20) — bez journal + řádku. Kdyby byl reg 15 write-on-change proti journalu (poslední + „0 verified"), EMS by tichý drift **0 → 8 A** na zařízení **NIKDY + nezahlédlo** (verify čte zpět jen `written` řádky) a nikdy ho neopravilo: + auto po každém krátkém výpadku spojení tiše jelo 8 A místo plánovaných 0 A. + Reg 15 je volatilní řídicí registr (ne EEPROM), opakovaný zápis je v pořádku; + re-asert každý tick zároveň drží verify jobu čerstvý `written` reg-15 řádek + → případný drift se zachytí a hned opraví. +- **reg 19/20 (watchdog config) zůstávají write-on-change** přes + `_drop_registers_matching_last_verified` proti **`ems.fn_modbus_device_state_map`** + (nejnovější řádek journalu per registr, stav `written` **nebo** `verified`): + zapíší se jednou po nasazení / po výpadku zařízení (nejnovější řádek + `failed`/`mismatch` ⇒ registr v mapě chybí ⇒ znovu se zapíše) a pak už ne, + dokud se hodnota nezmění — šetří EEPROM. Čekání na verify skip neblokuje, + `written` (TCP ack) stačí. -### Watchdog — sytí ho i čtení +Implementace: `_telto_setpoint_registers` (per-charger failsafe/timeout), +`_split_amps_and_watchdog` (reg 15 vs 19/20) v `services/control/outputs.py`. + +### Watchdog — sytí ho i čtení; failsafe konfigurovatelný Protokol definuje timeout jako *„if no **valid communication** is present after a configurable time interval…"* — timer resetuje **jakákoli** validní Modbus komunikace s unit ID wallboxu, **včetně FC 3 čtení**. Telemetrie čte blok 0–40 každých **60 s**, takže watchdog 300 s je trvale sycen čtením a -**periodické zápisy k udržení spojení nejsou potřeba**. Failsafe (omezení na -8 A, reg 20 „max allowed current on comm timeout") nastane až po 5 min bez -jakéhokoli pollingu = skutečný výpadek EMS; auto se pak přes noc dobije -pomalu místo stání na 0 A. +**periodické zápisy k udržení spojení nejsou potřeba**. Failsafe (reg 20 +„max allowed current on comm timeout") nastane až po `watchdog_comm_timeout_s` +bez jakéhokoli pollingu = skutečný výpadek EMS. + +**Failsafe je per charger** (`asset_ev_charger.watchdog_failsafe_a`, default +8 A; `watchdog_comm_timeout_s`, default 300 s; migrace V106): +- default **8 A** = po skutečném výpadku EMS se auto přes noc pomalu dobije + místo stání na 0 A; +- snížit lze na **6 A** (IEC 61851 minimum) nebo **0** (po výpadku nenabíjet), + dle dotačních / komfortních požadavků; +- **běžný provoz po zapojení řídí reg 15 z plánu** (0 A drží arrival-hold + + sycení watchdogu čtením telemetrie), failsafe se uplatní jen při výpadku — + rozpor „chci řízený default 0 A, ale po výpadku malý proud" je tím vyřešen. ## WB2 mimo EMS (V105, 2026-06-13)