TeltoCharge write-on-change: zápis jen při změně hodnoty (EEPROM wear)

Wallbox dostával zápisy 15/19/20 každý export tick (~8x/hod: control_export
:14,:29,:44,:59 + rolling replan */15 s exportem), protože drop-unchanged
stál na fn_modbus_last_verified_map — dokud verify čtení nedoběhlo/selhalo,
mapa byla prázdná a celá trojice se psala pořád dokola. write_ev_arrival_hold
navíc psal trojici nepodmíněně při každém píchnutí kabelu (docstring lhal).

- nová ems.fn_modbus_device_state_map (R__100): nejnovější řádek journalu
  per registr, hodnota jen pro written/verified; failed/mismatch => registr
  chybí => po výpadku se konfigurace obnoví jedním zápisem
- write_ev_setpoints + write_ev_arrival_hold filtrují přes tuto mapu:
  reg 15 jen při změně plánu, watchdog 19/20 jednou po startu/po výpadku
- verify job EV chargery ověřuje už dnes (fn_modbus_written_command_ids bez
  filtru asset_type); registry 15/19/20 jsou dle oficiálního protokolu R/W
- watchdog Telto sytí jakákoli validní komunikace vč. FC3 čtení telemetrie
  (60 s << 300 s) — periodické zápisy k udržení spojení nejsou potřeba,
  failsafe 8 A nastane jen při skutečném výpadku EMS
- testy: tests/test_ev_write_on_change.py (drop, setpoints, arrival hold)
- docs: modbus-registers-teltocharge.md (sekce Zápis už není "NEimplementováno",
  R/W tabulka, watchdog sémantika), modbus-command-journal.md (sekce EV
  wallbox), CLAUDE.md (fn_modbus_device_state_map)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dusan Vojacek
2026-06-12 22:21:59 +02:00
parent a889950eba
commit 7decfebdbd
7 changed files with 362 additions and 25 deletions

View File

@@ -69,6 +69,35 @@ async def _fetch_last_verified_registers(
return {int(k): int(v) for k, v in data.items()}
async def _fetch_device_state_registers(
site_id: int,
asset_id: int,
db: asyncpg.Connection,
*,
asset_type: str,
) -> dict[int, int]:
"""
Poslední známá hodnota na zařízení podle journalu — NEJNOVĚJŠÍ řádek per
registr, hodnota jen pro status 'verified' nebo 'written' (zápis prošel,
verify ještě nemusel doběhnout). Novější failed/mismatch => registr chybí
=> volající zapíše znovu (obnova konfigurace po výpadku zařízení).
Pro write-on-change u EV wallboxů (EEPROM wear): na rozdíl od
_fetch_last_verified_registers nevyžaduje úspěšný verify, takže se zápis
neopakuje každý export tick, když verify čtení zaostává nebo selhává.
"""
raw = await db.fetchval(
"""
select ems.fn_modbus_device_state_map($1::int, $2::int, $3::text)
""",
site_id,
asset_id,
asset_type,
)
data = raw if isinstance(raw, dict) else json.loads(raw)
return {int(k): int(v) for k, v in data.items()}
async def _fetch_last_verified_inverter_registers(
site_id: int, inverter_asset_id: int, db: asyncpg.Connection
) -> dict[int, int]:

View File

@@ -27,8 +27,13 @@ TELTO_WATCHDOG_FAILSAFE_A = 8
def _telto_setpoint_registers(current_a: int) -> list[tuple[int, str, int]]:
"""Registry pro jeden export tick: limit proudu + watchdog konfigurace.
Watchdog (19/20) se posílá s každým tickem, ale journal drop-unchanged ho
po prvním verified zápisu přeskakuje — reálně se zapíše jednou.
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).
"""
a = int(current_a)
if a < 6:
@@ -62,7 +67,7 @@ async def write_ev_setpoints(
) -> str:
from services.control.modbus_journal import (
_drop_registers_matching_last_verified,
_fetch_last_verified_registers,
_fetch_device_state_registers,
create_modbus_commands,
execute_modbus_commands,
)
@@ -93,11 +98,13 @@ async def write_ev_setpoints(
current_a = _current_limit_for_charger(code, setpoints)
registers = _telto_setpoint_registers(current_a)
last_verified = await _fetch_last_verified_registers(
# Write-on-change: poslední written/verified stav (ne jen verified) —
# zápis se nesmí opakovat každý tick, když verify čtení zaostává.
device_state = await _fetch_device_state_registers(
site_id, asset_id, db, asset_type="ev_charger"
)
registers, skipped = _drop_registers_matching_last_verified(
registers, last_verified
registers, device_state
)
if not registers:
logger.debug("EV setpoint [%s]: beze změny (%s A)", code, current_a)
@@ -135,10 +142,13 @@ async def write_ev_arrival_hold(
TeltoCharge po připojení kabelu sám rozjede nabíjení svým defaultem —
nabíjet smí až PLÁN (replan + export běží hned poté v _on_ev_arrival,
takže držení trvá sekundy až ~1 min). Watchdog registry se zapíší spolu
s 0 A (drop-unchanged je po prvním verified stejně přeskočí).
takže držení trvá sekundy až ~1 min). Write-on-change: registry shodné
s posledním written/verified stavem (typicky watchdog 19/20, často
i 15=0) se přeskočí — žádný zbytečný zápis při každém píchnutí kabelu.
"""
from services.control.modbus_journal import (
_drop_registers_matching_last_verified,
_fetch_device_state_registers,
create_modbus_commands,
execute_modbus_commands,
)
@@ -159,21 +169,40 @@ async def write_ev_arrival_hold(
)
if row is None:
return False
asset_id = int(row["asset_id"])
registers = _telto_setpoint_registers(0)
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
)
if not registers:
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(
site_id,
None,
"ev_charger",
int(row["asset_id"]),
asset_id,
str(row["code"]),
str(row["host"]),
int(row["port"] or 502),
int(row["unit_id"] if row["unit_id"] is not None else 1),
_telto_setpoint_registers(0),
registers,
db,
)
ok = await execute_modbus_commands(cmd_ids, db)
logger.info(
"EV arrival hold [%s]: 0 A %s", charger_code, "written" if ok else "FAILED"
"EV arrival hold [%s]: 0 A (regs %s%s) %s",
charger_code,
[r for r, _, _ in registers],
f", skip {skipped}" if skipped else "",
"written" if ok else "FAILED",
)
return bool(ok)

View File

@@ -0,0 +1,184 @@
"""Write-on-change pro TeltoCharge: zápis JEN při skutečné změně hodnoty.
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.
"""
import unittest
from unittest.mock import AsyncMock, patch
import services.control.modbus_journal as journal
from services.control.modbus_journal import _drop_registers_matching_last_verified
from services.control.models import ControlSetpoints
from services.control.outputs import (
TELTO_REG_AMPS_TO_USE,
TELTO_REG_COMM_TIMEOUT_S,
TELTO_REG_FAILSAFE_CURRENT_A,
TELTO_WATCHDOG_FAILSAFE_A,
TELTO_WATCHDOG_TIMEOUT_S,
_telto_setpoint_registers,
write_ev_arrival_hold,
write_ev_setpoints,
)
#: Stav zařízení po prvním úspěšném exportu s 0 A (klid, auto nepřipojené).
_STEADY_STATE_0A = {
TELTO_REG_AMPS_TO_USE: 0,
TELTO_REG_COMM_TIMEOUT_S: TELTO_WATCHDOG_TIMEOUT_S,
TELTO_REG_FAILSAFE_CURRENT_A: TELTO_WATCHDOG_FAILSAFE_A,
}
def _setpoints(ev1_a: int = 0) -> ControlSetpoints:
return ControlSetpoints(
battery_w=None,
grid_export_limit=0,
ev1_current_a=ev1_a,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=0,
ev1_power_w=0,
ev2_power_w=0,
)
class TeltoSetpointRegistersTests(unittest.TestCase):
def test_triple_for_zero_amps(self) -> None:
regs = _telto_setpoint_registers(0)
self.assertEqual(
[(r, v) for r, _, v in regs],
[(15, 0), (19, 300), (20, 8)],
)
def test_amps_below_six_coerced_to_zero_and_clamped_to_32(self) -> None:
self.assertEqual(_telto_setpoint_registers(5)[0][2], 0)
self.assertEqual(_telto_setpoint_registers(6)[0][2], 6)
self.assertEqual(_telto_setpoint_registers(40)[0][2], 32)
class DropAgainstDeviceStateTests(unittest.TestCase):
def test_steady_state_drops_everything(self) -> None:
out, skipped = _drop_registers_matching_last_verified(
_telto_setpoint_registers(0), _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])
self.assertEqual(skipped, [])
class _FakeDB:
"""Jen řádky chargeru; journal funkce se patchují v modbus_journal."""
def __init__(self) -> None:
self.row = {
"asset_id": 7,
"code": "ev-charger-1",
"host": "172.16.1.16",
"port": 502,
"unit_id": 1,
}
async def fetch(self, query: str, *args: object) -> list[dict]:
return [self.row]
async def fetchrow(self, query: str, *args: object) -> dict:
return self.row
async def fetchval(self, query: str, *args: object) -> None:
raise AssertionError(f"unexpected fetchval: {query}")
class WriteEvSetpointsOnChangeTests(unittest.IsolatedAsyncioTestCase):
async def _run(
self, device_state: dict[int, int], ev1_a: int
) -> tuple[AsyncMock, AsyncMock]:
create = AsyncMock(return_value=[1, 2, 3])
execute = AsyncMock(return_value=True)
with (
patch.object(
journal,
"_fetch_device_state_registers",
AsyncMock(return_value=device_state),
),
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]
return create, execute
async def test_steady_state_no_write_at_all(self) -> None:
create, execute = await self._run(_STEADY_STATE_0A, ev1_a=0)
create.assert_not_awaited()
execute.assert_not_awaited()
async def test_plan_change_writes_only_amps(self) -> None:
create, execute = await self._run(_STEADY_STATE_0A, ev1_a=16)
create.assert_awaited_once()
registers = create.await_args.args[8]
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:
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()
class WriteEvArrivalHoldOnChangeTests(unittest.IsolatedAsyncioTestCase):
async def _run(
self, device_state: dict[int, int]
) -> tuple[bool, AsyncMock, AsyncMock]:
create = AsyncMock(return_value=[1])
execute = AsyncMock(return_value=True)
with (
patch.object(
journal,
"_fetch_device_state_registers",
AsyncMock(return_value=device_state),
),
patch.object(journal, "create_modbus_commands", create),
patch.object(journal, "execute_modbus_commands", execute),
):
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:
ok, create, execute = await self._run(_STEADY_STATE_0A)
self.assertTrue(ok)
create.assert_not_awaited()
execute.assert_not_awaited()
async def test_hold_writes_only_amps_when_watchdog_already_set(self) -> None:
ok, create, execute = await self._run(
{
TELTO_REG_AMPS_TO_USE: 16,
TELTO_REG_COMM_TIMEOUT_S: TELTO_WATCHDOG_TIMEOUT_S,
TELTO_REG_FAILSAFE_CURRENT_A: TELTO_WATCHDOG_FAILSAFE_A,
}
)
self.assertTrue(ok)
registers = create.await_args.args[8]
self.assertEqual([(r, v) for r, _, v in registers], [(15, 0)])
execute.assert_awaited_once()
if __name__ == "__main__":
unittest.main()