fix(modbus): reg 15 re-asert kazdy tick + per-charger failsafe (BUG1)

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) <noreply@anthropic.com>
This commit is contained in:
Dusan Vojacek
2026-06-13 22:03:11 +02:00
parent 03b7396676
commit 54288ee2fd
6 changed files with 229 additions and 100 deletions

View File

@@ -18,33 +18,52 @@ logger = logging.getLogger(__name__)
TELTO_REG_AMPS_TO_USE = 15 # 0 = stop, 632 A TELTO_REG_AMPS_TO_USE = 15 # 0 = stop, 632 A
TELTO_REG_COMM_TIMEOUT_S = 19 # watchdog: bez komunikace → failsafe TELTO_REG_COMM_TIMEOUT_S = 19 # watchdog: bez komunikace → failsafe
TELTO_REG_FAILSAFE_CURRENT_A = 20 TELTO_REG_FAILSAFE_CURRENT_A = 20
#: Výpadek EMS: po 5 min bez zápisu wallbox přejde na failsafe proud — #: Výpadek EMS: po watchdog_comm_timeout_s bez komunikace wallbox přejde na
#: auto se přes noc nabije i bez EMS (pomalu), místo aby stálo na 0 A. #: 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_TIMEOUT_S = 300
TELTO_WATCHDOG_FAILSAFE_A = 8 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. """Registry pro jeden export tick: limit proudu + watchdog konfigurace.
Write-on-change: volající VŽDY filtruje přes drop-unchanged proti **Reg 15 (amps to use) NENÍ write-on-change** — viz `_assert_amps_register`.
fn_modbus_device_state_map (poslední written/verified per registr) — Je to volatilní řídicí registr: TeltoCharge ho po výpadku komunikace sám
watchdog 19/20 se reálně zapíše jen po startu / po výpadku zařízení, přepíše na failsafe (reg 20), aniž by o tom vznikl journal řádek. Kdyby se
amps (15) jen při změně plánu. Watchdog timer TeltoCharge sytí jakákoli reg 15 dropoval proti journalu (poslední „0 verified"), EMS by tichý drift
validní Modbus komunikace (i FC3 čtení telemetrie každých 60 s), takže 0 → 8 A NIKDY nezahlédlo (verify čte zpět jen `written` řádky) a nikdy ho
periodické zápisy k udržení spojení NEJSOU potřeba (oficiální protokol, neopravilo. Proto se reg 15 re-asertuje KAŽDÝ export tick (≤ 8×/hod) —
docs/04-modules/modbus-registers-teltocharge.md). 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) a = int(current_a)
if a < 6: if a < 6:
a = 0 a = 0
return [ return [
(TELTO_REG_AMPS_TO_USE, "telto_amps_to_use", min(a, 32)), (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_COMM_TIMEOUT_S, "telto_comm_timeout_s", int(comm_timeout_s)),
(TELTO_REG_FAILSAFE_CURRENT_A, "telto_failsafe_a", TELTO_WATCHDOG_FAILSAFE_A), (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: def _current_limit_for_charger(charger_code: str, sp: ControlSetpoints) -> int:
c = (charger_code or "").strip().lower() c = (charger_code or "").strip().lower()
if c == "ev-charger-1": if c == "ev-charger-1":
@@ -74,7 +93,8 @@ async def write_ev_setpoints(
rows = await db.fetch( 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 FROM ems.asset_ev_charger ec
JOIN ems.site_endpoint se ON se.id = ec.endpoint_id JOIN ems.site_endpoint se ON se.id = ec.endpoint_id
WHERE ec.site_id = $1 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) unit_id = int(row["unit_id"] if row["unit_id"] is not None else 1)
current_a = _current_limit_for_charger(code, setpoints) current_a = _current_limit_for_charger(code, setpoints)
registers = _telto_setpoint_registers(current_a) registers = _telto_setpoint_registers(
# Write-on-change: poslední written/verified stav (ne jen verified) — current_a,
# zápis se nesmí opakovat každý tick, když verify čtení zaostává. 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( device_state = await _fetch_device_state_registers(
site_id, asset_id, db, asset_type="ev_charger" site_id, asset_id, db, asset_type="ev_charger"
) )
registers, skipped = _drop_registers_matching_last_verified( watchdog_regs, skipped = _drop_registers_matching_last_verified(
registers, device_state 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) logger.debug("EV setpoint [%s]: beze změny (%s A)", code, current_a)
continue continue
@@ -119,7 +154,7 @@ async def write_ev_setpoints(
host, host,
port, port,
unit_id, unit_id,
registers, to_write,
db, db,
) )
ok = await execute_modbus_commands(cmd_ids, 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", "EV setpoint [%s]: %s A (regs %s%s) -> %s",
code, code,
current_a, current_a,
[r for r, _, _ in registers], [r for r, _, _ in to_write],
f", skip {skipped}" if skipped else "", f", skip {skipped}" if skipped else "",
"written" if ok else "FAILED", "written" if ok else "FAILED",
) )
@@ -155,7 +190,8 @@ async def write_ev_arrival_hold(
row = await db.fetchrow( 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 FROM ems.asset_ev_charger ec
JOIN ems.site_endpoint se ON se.id = ec.endpoint_id JOIN ems.site_endpoint se ON se.id = ec.endpoint_id
WHERE ec.site_id = $1 WHERE ec.site_id = $1
@@ -170,20 +206,29 @@ async def write_ev_arrival_hold(
if row is None: if row is None:
return False return False
asset_id = int(row["asset_id"]) 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( device_state = await _fetch_device_state_registers(
site_id, asset_id, db, asset_type="ev_charger" site_id, asset_id, db, asset_type="ev_charger"
) )
registers, skipped = _drop_registers_matching_last_verified( watchdog_regs, skipped = _drop_registers_matching_last_verified(
registers, device_state watchdog_regs, device_state
) )
if not registers: to_write = amps_regs + watchdog_regs
logger.info(
"EV arrival hold [%s]: 0 A už na zařízení (skip %s)",
charger_code,
skipped,
)
return True
cmd_ids = await create_modbus_commands( cmd_ids = await create_modbus_commands(
site_id, site_id,
None, None,
@@ -193,14 +238,14 @@ async def write_ev_arrival_hold(
str(row["host"]), str(row["host"]),
int(row["port"] or 502), int(row["port"] or 502),
int(row["unit_id"] if row["unit_id"] is not None else 1), int(row["unit_id"] if row["unit_id"] is not None else 1),
registers, to_write,
db, db,
) )
ok = await execute_modbus_commands(cmd_ids, db) ok = await execute_modbus_commands(cmd_ids, db)
logger.info( logger.info(
"EV arrival hold [%s]: 0 A (regs %s%s) %s", "EV arrival hold [%s]: 0 A (regs %s%s) %s",
charger_code, charger_code,
[r for r, _, _ in registers], [r for r, _, _ in to_write],
f", skip {skipped}" if skipped else "", f", skip {skipped}" if skipped else "",
"written" if ok else "FAILED", "written" if ok else "FAILED",
) )

View File

@@ -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ěží Export tick běží ~8x/hod (control_export :14,:29,:44,:59 + rolling replan
~8x/hod (control_export :14,:29,:44,:59 + rolling replan */15 s exportem), */15 s exportem). **Reg 15 (amps to use) se zapisuje VŽDY** — TeltoCharge ho
ale reg 15/19/20 se smí zapsat jen při změně proti poslednímu known stavu po výpadku komunikace sám přepíše na failsafe (reg 20) bez journal řádku, a
zařízení (fn_modbus_device_state_map: nejnovější written/verified řádek kdyby byl write-on-change, EMS by tichý drift 0 → 8 A nikdy nezahlédlo
journalu per registr). Watchdog (19/20) se zapíše jednou po startu / po (verify čte zpět jen `written`). **Reg 19/20 (watchdog config, EEPROM wear)
výpadku; sytí ho i FC3 čtení telemetrie (60 s), periodické zápisy netřeba. 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 import unittest
@@ -20,6 +22,7 @@ from services.control.outputs import (
TELTO_REG_FAILSAFE_CURRENT_A, TELTO_REG_FAILSAFE_CURRENT_A,
TELTO_WATCHDOG_FAILSAFE_A, TELTO_WATCHDOG_FAILSAFE_A,
TELTO_WATCHDOG_TIMEOUT_S, TELTO_WATCHDOG_TIMEOUT_S,
_split_amps_and_watchdog,
_telto_setpoint_registers, _telto_setpoint_registers,
write_ev_arrival_hold, write_ev_arrival_hold,
write_ev_setpoints, 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(6)[0][2], 6)
self.assertEqual(_telto_setpoint_registers(40)[0][2], 32) 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): 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( out, skipped = _drop_registers_matching_last_verified(
_telto_setpoint_registers(0), _STEADY_STATE_0A watchdog, _STEADY_STATE_0A
) )
self.assertEqual(out, []) 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]) self.assertEqual(skipped, [19, 20])
def test_empty_state_after_outage_writes_full_triple(self) -> None: def test_empty_state_after_outage_keeps_19_20(self) -> None:
out, skipped = _drop_registers_matching_last_verified( _, watchdog = _split_amps_and_watchdog(_telto_setpoint_registers(0))
_telto_setpoint_registers(0), {} out, skipped = _drop_registers_matching_last_verified(watchdog, {})
) self.assertEqual([r for r, _, _ in out], [19, 20])
self.assertEqual([r for r, _, _ in out], [15, 19, 20])
self.assertEqual(skipped, []) self.assertEqual(skipped, [])
class _FakeDB: class _FakeDB:
"""Jen řádky chargeru; journal funkce se patchují v modbus_journal.""" """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 = { self.row = {
"asset_id": 7, "asset_id": 7,
"code": "ev-charger-1", "code": "ev-charger-1",
"host": "172.16.1.16", "host": "172.16.1.16",
"port": 502, "port": 502,
"unit_id": 1, "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]: async def fetch(self, query: str, *args: object) -> list[dict]:
@@ -105,9 +116,9 @@ class _FakeDB:
raise AssertionError(f"unexpected fetchval: {query}") raise AssertionError(f"unexpected fetchval: {query}")
class WriteEvSetpointsOnChangeTests(unittest.IsolatedAsyncioTestCase): class WriteEvSetpointsTests(unittest.IsolatedAsyncioTestCase):
async def _run( 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]: ) -> tuple[AsyncMock, AsyncMock]:
create = AsyncMock(return_value=[1, 2, 3]) create = AsyncMock(return_value=[1, 2, 3])
execute = AsyncMock(return_value=True) execute = AsyncMock(return_value=True)
@@ -120,13 +131,17 @@ class WriteEvSetpointsOnChangeTests(unittest.IsolatedAsyncioTestCase):
patch.object(journal, "create_modbus_commands", create), patch.object(journal, "create_modbus_commands", create),
patch.object(journal, "execute_modbus_commands", execute), 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 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, execute = await self._run(_STEADY_STATE_0A, ev1_a=0)
create.assert_not_awaited() create.assert_awaited_once()
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_plan_change_writes_only_amps(self) -> None: async def test_plan_change_writes_only_amps(self) -> None:
create, execute = await self._run(_STEADY_STATE_0A, ev1_a=16) 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)]) self.assertEqual([(r, v) for r, _, v in registers], [(15, 16)])
execute.assert_awaited_once() 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) create, execute = await self._run({}, ev1_a=0)
registers = create.await_args.args[8] registers = create.await_args.args[8]
self.assertEqual([r for r, _, _ in registers], [15, 19, 20]) self.assertEqual([r for r, _, _ in registers], [15, 19, 20])
execute.assert_awaited_once() 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( async def _run(
self, device_state: dict[int, int] self, device_state: dict[int, int]
) -> tuple[bool, AsyncMock, AsyncMock]: ) -> 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] ok = await write_ev_arrival_hold(1, "ev-charger-1", _FakeDB()) # type: ignore[arg-type]
return ok, create, execute 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) ok, create, execute = await self._run(_STEADY_STATE_0A)
self.assertTrue(ok) self.assertTrue(ok)
create.assert_not_awaited() registers = create.await_args.args[8]
execute.assert_not_awaited() 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( ok, create, execute = await self._run(
{ {
TELTO_REG_AMPS_TO_USE: 16, TELTO_REG_AMPS_TO_USE: 16,
@@ -176,6 +203,7 @@ class WriteEvArrivalHoldOnChangeTests(unittest.IsolatedAsyncioTestCase):
) )
self.assertTrue(ok) self.assertTrue(ok)
registers = create.await_args.args[8] 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)]) self.assertEqual([(r, v) for r, _, v in registers], [(15, 0)])
execute.assert_awaited_once() execute.assert_awaited_once()

View File

@@ -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.';

View File

@@ -175,6 +175,10 @@ CREATE TABLE asset_ev_charger (
phases INT DEFAULT 3, phases INT DEFAULT 3,
connector_count INT DEFAULT 1, connector_count INT DEFAULT 1,
schedulable BOOLEAN DEFAULT true, 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: 032 A (0 = po výpadku nenabíjet)
watchdog_comm_timeout_s INT NOT NULL DEFAULT 300, -- reg 19: s bez komunikace → failsafe
notes TEXT notes TEXT
); );
``` ```

View File

@@ -48,20 +48,29 @@ Implementace: `services/control_exporter.py` — `verify_modbus_commands`, `_ver
## EV wallbox (TeltoCharge) ## EV wallbox (TeltoCharge)
`write_ev_setpoints` (každý export tick) a `write_ev_arrival_hold` (po detekci `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) příjezdu EV) zapisují registry **15** (amps to use), **19** (comm timeout) a
a **20** (failsafe 8 A) — vždy přes journal (`asset_type = 'ev_charger'`). **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` - **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 nefiltruje podle `asset_type` a registry 15/19/20 jsou dle protokolu R/W
(čtou se zpět standardní FC 3 větví). (čtou se zpět standardní FC 3 větví).
- **Write-on-change:** před zápisem se registry filtrují proti - **Reg 15 (amps) se zapisuje KAŽDÝ tick** (re-asert), **NE write-on-change.**
**`ems.fn_modbus_device_state_map`** (nejnovější řádek journalu per registr; Incident 2026-06-13: TeltoCharge si po výpadku komunikace sám přepíše reg 15
hodnota jen pro stav `written`/`verified`). Shodná hodnota ⇒ zápis se na failsafe (reg 20) bez journal řádku; write-on-change proti journalu
přeskočí. Na rozdíl od `fn_modbus_last_verified_map` (Deye drop-unchanged) (poslední „0 verified") by tichý drift **0 → 8 A** nikdy nezahlédlo (verify
nečeká na verify — `written` stačí, takže pomalý/neúspěšný verify read čte zpět jen `written`) a nikdy neopravilo. Re-asert každý tick drift opraví
nevede k opakovaným zápisům každý tick (EEPROM wear). Nejnovější řádek a drží verify jobu čerstvý `written` reg-15 řádek. Reg 15 je volatilní řídicí
`failed`/`mismatch` ⇒ registr v mapě chybí ⇒ po výpadku zařízení se registr (ne EEPROM).
konfigurace (vč. watchdog 19/20) obnoví jedním zápisem. - **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 - **Mismatch po 3 pokusech NEpřepíná SELF_SUSTAIN** — fallback režim je Deye
politika (`asset_type = 'inverter'`); u wallboxu zůstane řádek `mismatch` politika (`asset_type = 'inverter'`); u wallboxu zůstane řádek `mismatch`
+ Discord (`notify_modbus_mismatch`). + Discord (`notify_modbus_mismatch`).

View File

@@ -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 | | Reg | R/W | Význam | Hodnoty | EMS zapisuje |
|-----|-----|--------|---------|--------------| |-----|-----|--------|---------|--------------|
| 15 | R/W | **Amps to use** (limit proudu) | 0 = stop, 632 A | hodnota z plánu (`ev{1,2}_current_a`); příjezd EV → hold 0 A | | 15 | R/W | **Amps to use** (limit proudu) | 0 = stop, 632 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 | | 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) | 0600 s (0 = vypnuto), default 30 | `TELTO_WATCHDOG_TIMEOUT_S` = **300** | | 19 | R/W | Communication timeout (watchdog) | 0600 s (0 = vypnuto), default 30 | per charger `asset_ev_charger.watchdog_comm_timeout_s` (default **300**) |
| 20 | R/W | Failsafe current | 0, 632 A, default 6 | `TELTO_WATCHDOG_FAILSAFE_A` = **8** | | 20 | R/W | Failsafe current | 0, 632 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 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 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ě). 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 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é `*/15` s exportem).
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:
- **reg 15** se zapíše jen při změně plánovaného proudu (0 ↔ 632 A) — to je - **reg 15 (amps to use) se zapisuje při KAŽDÉM ticku** (`write_ev_setpoints`
legitimní zápis; i `write_ev_arrival_hold`). **Důvod (incident 2026-06-13):** TeltoCharge si
- **reg 19/20** se zapíší jednou po nasazení / po výpadku zařízení (nejnovější po výpadku komunikace sám přepíše reg 15 na failsafe (reg 20) — bez journal
řádek `failed`/`mismatch` ⇒ registr v mapě chybí ⇒ znovu se zapíše) a pak řádku. Kdyby byl reg 15 write-on-change proti journalu (poslední
už nikdy, dokud se hodnota nezmění; „0 verified"), EMS by tichý drift **0 → 8 A** na zařízení **NIKDY
- čekání na verify **neblokuje** skip — `written` (TCP ack) stačí, mismatch nezahlédlo** (verify čte zpět jen `written` řádky) a nikdy ho neopravilo:
z verify stav mapy zneplatní a vynutí nový zápis. 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 Protokol definuje timeout jako *„if no **valid communication** is present
after a configurable time interval…"* — timer resetuje **jakákoli** validní after a configurable time interval…"* — timer resetuje **jakákoli** validní
Modbus komunikace s unit ID wallboxu, **včetně FC 3 čtení**. Telemetrie čte Modbus komunikace s unit ID wallboxu, **včetně FC 3 čtení**. Telemetrie čte
blok 040 každých **60 s**, takže watchdog 300 s je trvale sycen čtením a blok 040 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 **periodické zápisy k udržení spojení nejsou potřeba**. Failsafe (reg 20
8 A, reg 20 „max allowed current on comm timeout") nastane až po 5 min bez „max allowed current on comm timeout") nastane až po `watchdog_comm_timeout_s`
jakéhokoli pollingu = skutečný výpadek EMS; auto se pak přes noc dobije bez jakéhokoli pollingu = skutečný výpadek EMS.
pomalu místo stání na 0 A.
**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) ## WB2 mimo EMS (V105, 2026-06-13)