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:
@@ -18,33 +18,52 @@ logger = logging.getLogger(__name__)
|
|||||||
TELTO_REG_AMPS_TO_USE = 15 # 0 = stop, 6–32 A
|
TELTO_REG_AMPS_TO_USE = 15 # 0 = stop, 6–32 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",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
18
db/migration/V106__ev_charger_failsafe_current.sql
Normal file
18
db/migration/V106__ev_charger_failsafe_current.sql
Normal 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.';
|
||||||
@@ -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: 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
|
notes TEXT
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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`).
|
||||||
|
|||||||
@@ -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, 6–32 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, 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 |
|
| 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 | `TELTO_WATCHDOG_TIMEOUT_S` = **300** |
|
| 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 | `TELTO_WATCHDOG_FAILSAFE_A` = **8** |
|
| 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
|
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 ↔ 6–32 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 0–40 každých **60 s**, takže watchdog 300 s je trvale sycen čtením a
|
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
|
**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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user