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:
@@ -163,7 +163,7 @@ Projekt je **SQL-first**: doménová logika, agregace, joiny mezi tabulkami a st
|
|||||||
| `signal_state` | Poslední požadovaná / odeslaná / ověřená hodnota na cíli (idempotence). |
|
| `signal_state` | Poslední požadovaná / odeslaná / ověřená hodnota na cíli (idempotence). |
|
||||||
| `cutoff_switch_log` | Log přepnutí cut-off přepínačů (mikroinvertory); edge trigger, důvod a cena. |
|
| `cutoff_switch_log` | Log přepnutí cut-off přepínačů (mikroinvertory); edge trigger, důvod a cena. |
|
||||||
|
|
||||||
**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_site_directory`, `vw_modbus_last_verified`, `vw_asset_inverter_modbus_poll`, `vw_asset_ev_charger_modbus_poll`, `vw_asset_heat_pump_modbus_poll`, `vw_latest_telemetry`, `vw_telemetry_hourly_7d`, `vw_telemetry_15m_7d` (15min agregát pro dashboard sloty; repeatable `R__071_vw_telemetry_15m_7d.sql`), `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_delete_forecast_pv_prague_calendar_day`, `fn_pv_forecast_sync_reference_days`, `fn_set_mode`, `fn_expire_modes` (vrací řádky přepnutí pro Discord), `fn_restore_previous_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_rebuild_consumption_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`, dále read-modely: `fn_site_configuration`, `fn_site_full_status`, `fn_site_notifications_context`, `fn_plan_current_bundle`, `fn_planning_run_horizon`, `fn_planning_future_price_days`, `fn_economics_daily_month`, `fn_economics_monthly_chart`, `fn_economics_lock_day`, `fn_economics_unlock_day`, `fn_energy_flows_daily_month`, `fn_energy_flows_intervals_day`, `fn_forecast_pv_split`, `fn_ev_sessions_active`, `fn_ev_session_apply_patch`, `fn_ev_arrival_prediction_bundle`, `fn_ev_session_transition`, `fn_ev_session_defaults` (kaskáda ev_weekly_requirement → forecast → defaulty), `fn_negative_price_predictions`, `fn_latest_ote_day_stats`, `fn_ote_day_slot_stats_prague`, `fn_ote_list_missing_days`, `fn_site_effective_prices_day_prague`, `fn_modbus_journal_list`, `fn_modbus_written_command_ids`, `fn_modbus_commands_by_ids`, `fn_inverter_modbus_caps_patch`, `fn_set_mode_with_context`, `fn_fill_audit_for_site_window`, plánování: `fn_load_planning_slots_full`, `fn_last_effective_ote`, `fn_planning_horizon_end`, `fn_planning_site_context`, `fn_pv_forecast_correction_factor`, `fn_planning_run_commit`, `fn_planning_slot_boundary_prague`, `fn_planning_interval_at_offset`, `fn_telemetry_inverter_sample`, `fn_telemetry_ev_charger_sample`, `fn_telemetry_heat_pump_sample`, `fn_battery_cycle_audit`, Deye helpery: `fn_deye_pack_system_time`, `fn_deye_clock_drift_sec`, `fn_deye_time_point_regs`, `fn_deye_tou_inactive_signature`, `fn_modbus_last_verified_map`, `fn_inverter_pv_a_max_w`, `fn_site_has_active_green_bonus_pv`.
|
**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_site_directory`, `vw_modbus_last_verified`, `vw_asset_inverter_modbus_poll`, `vw_asset_ev_charger_modbus_poll`, `vw_asset_heat_pump_modbus_poll`, `vw_latest_telemetry`, `vw_telemetry_hourly_7d`, `vw_telemetry_15m_7d` (15min agregát pro dashboard sloty; repeatable `R__071_vw_telemetry_15m_7d.sql`), `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_delete_forecast_pv_prague_calendar_day`, `fn_pv_forecast_sync_reference_days`, `fn_set_mode`, `fn_expire_modes` (vrací řádky přepnutí pro Discord), `fn_restore_previous_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_rebuild_consumption_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`, dále read-modely: `fn_site_configuration`, `fn_site_full_status`, `fn_site_notifications_context`, `fn_plan_current_bundle`, `fn_planning_run_horizon`, `fn_planning_future_price_days`, `fn_economics_daily_month`, `fn_economics_monthly_chart`, `fn_economics_lock_day`, `fn_economics_unlock_day`, `fn_energy_flows_daily_month`, `fn_energy_flows_intervals_day`, `fn_forecast_pv_split`, `fn_ev_sessions_active`, `fn_ev_session_apply_patch`, `fn_ev_arrival_prediction_bundle`, `fn_ev_session_transition`, `fn_ev_session_defaults` (kaskáda ev_weekly_requirement → forecast → defaulty), `fn_negative_price_predictions`, `fn_latest_ote_day_stats`, `fn_ote_day_slot_stats_prague`, `fn_ote_list_missing_days`, `fn_site_effective_prices_day_prague`, `fn_modbus_journal_list`, `fn_modbus_written_command_ids`, `fn_modbus_commands_by_ids`, `fn_inverter_modbus_caps_patch`, `fn_set_mode_with_context`, `fn_fill_audit_for_site_window`, plánování: `fn_load_planning_slots_full`, `fn_last_effective_ote`, `fn_planning_horizon_end`, `fn_planning_site_context`, `fn_pv_forecast_correction_factor`, `fn_planning_run_commit`, `fn_planning_slot_boundary_prague`, `fn_planning_interval_at_offset`, `fn_telemetry_inverter_sample`, `fn_telemetry_ev_charger_sample`, `fn_telemetry_heat_pump_sample`, `fn_battery_cycle_audit`, Deye helpery: `fn_deye_pack_system_time`, `fn_deye_clock_drift_sec`, `fn_deye_time_point_regs`, `fn_deye_tou_inactive_signature`, `fn_modbus_last_verified_map`, `fn_modbus_device_state_map`, `fn_inverter_pv_a_max_w`, `fn_site_has_active_green_bonus_pv`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,35 @@ async def _fetch_last_verified_registers(
|
|||||||
return {int(k): int(v) for k, v in data.items()}
|
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(
|
async def _fetch_last_verified_inverter_registers(
|
||||||
site_id: int, inverter_asset_id: int, db: asyncpg.Connection
|
site_id: int, inverter_asset_id: int, db: asyncpg.Connection
|
||||||
) -> dict[int, int]:
|
) -> dict[int, int]:
|
||||||
|
|||||||
@@ -27,8 +27,13 @@ TELTO_WATCHDOG_FAILSAFE_A = 8
|
|||||||
def _telto_setpoint_registers(current_a: int) -> list[tuple[int, str, int]]:
|
def _telto_setpoint_registers(current_a: int) -> list[tuple[int, str, int]]:
|
||||||
"""Registry pro jeden export tick: limit proudu + watchdog konfigurace.
|
"""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
|
Write-on-change: volající VŽDY filtruje přes drop-unchanged proti
|
||||||
po prvním verified zápisu přeskakuje — reálně se zapíše jednou.
|
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)
|
a = int(current_a)
|
||||||
if a < 6:
|
if a < 6:
|
||||||
@@ -62,7 +67,7 @@ async def write_ev_setpoints(
|
|||||||
) -> str:
|
) -> str:
|
||||||
from services.control.modbus_journal import (
|
from services.control.modbus_journal import (
|
||||||
_drop_registers_matching_last_verified,
|
_drop_registers_matching_last_verified,
|
||||||
_fetch_last_verified_registers,
|
_fetch_device_state_registers,
|
||||||
create_modbus_commands,
|
create_modbus_commands,
|
||||||
execute_modbus_commands,
|
execute_modbus_commands,
|
||||||
)
|
)
|
||||||
@@ -93,11 +98,13 @@ async def write_ev_setpoints(
|
|||||||
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(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"
|
site_id, asset_id, db, asset_type="ev_charger"
|
||||||
)
|
)
|
||||||
registers, skipped = _drop_registers_matching_last_verified(
|
registers, skipped = _drop_registers_matching_last_verified(
|
||||||
registers, last_verified
|
registers, device_state
|
||||||
)
|
)
|
||||||
if not registers:
|
if not registers:
|
||||||
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)
|
||||||
@@ -135,10 +142,13 @@ async def write_ev_arrival_hold(
|
|||||||
|
|
||||||
TeltoCharge po připojení kabelu sám rozjede nabíjení svým defaultem —
|
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,
|
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
|
takže držení trvá sekundy až ~1 min). Write-on-change: registry shodné
|
||||||
s 0 A (drop-unchanged je po prvním verified stejně přeskočí).
|
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 (
|
from services.control.modbus_journal import (
|
||||||
|
_drop_registers_matching_last_verified,
|
||||||
|
_fetch_device_state_registers,
|
||||||
create_modbus_commands,
|
create_modbus_commands,
|
||||||
execute_modbus_commands,
|
execute_modbus_commands,
|
||||||
)
|
)
|
||||||
@@ -159,21 +169,40 @@ async def write_ev_arrival_hold(
|
|||||||
)
|
)
|
||||||
if row is None:
|
if row is None:
|
||||||
return False
|
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(
|
cmd_ids = await create_modbus_commands(
|
||||||
site_id,
|
site_id,
|
||||||
None,
|
None,
|
||||||
"ev_charger",
|
"ev_charger",
|
||||||
int(row["asset_id"]),
|
asset_id,
|
||||||
str(row["code"]),
|
str(row["code"]),
|
||||||
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),
|
||||||
_telto_setpoint_registers(0),
|
registers,
|
||||||
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 %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)
|
return bool(ok)
|
||||||
|
|
||||||
|
|||||||
184
backend/tests/test_ev_write_on_change.py
Normal file
184
backend/tests/test_ev_write_on_change.py
Normal 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()
|
||||||
43
db/routines/R__100_fn_modbus_device_state_map.sql
Normal file
43
db/routines/R__100_fn_modbus_device_state_map.sql
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
-- map register -> poslední známá hodnota na zařízení podle journalu.
|
||||||
|
-- Na rozdíl od fn_modbus_last_verified_map bere NEJNOVĚJŠÍ řádek per registr
|
||||||
|
-- a hodnotu vrací jen pokud je 'verified' NEBO 'written' (zápis potvrzený
|
||||||
|
-- TCP ackem, verify ještě nedoběhl / čtení zpět selhalo). Novější řádek
|
||||||
|
-- failed/mismatch/pending => registr v mapě chybí => exporter zapíše znovu
|
||||||
|
-- (po výpadku zařízení se konfigurace obnoví jedním zápisem).
|
||||||
|
-- Motivace: write-on-change pro EV wallboxy (EEPROM wear) — zápis se nesmí
|
||||||
|
-- opakovat každý export tick jen proto, že verify ještě neproběhl.
|
||||||
|
|
||||||
|
drop function if exists ems.fn_modbus_device_state_map;
|
||||||
|
|
||||||
|
create or replace function ems.fn_modbus_device_state_map(
|
||||||
|
p_site_id int,
|
||||||
|
p_asset_id int,
|
||||||
|
p_asset_type text
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
select coalesce(
|
||||||
|
jsonb_object_agg(t.register::text, to_jsonb(t.val)),
|
||||||
|
'{}'::jsonb
|
||||||
|
)
|
||||||
|
from (
|
||||||
|
select distinct on (mc.register)
|
||||||
|
mc.register,
|
||||||
|
case
|
||||||
|
when mc.status = 'verified' then mc.value_verified
|
||||||
|
when mc.status = 'written' then coalesce(mc.value_written, mc.value_to_write)
|
||||||
|
else null
|
||||||
|
end as val
|
||||||
|
from ems.modbus_command mc
|
||||||
|
where mc.site_id = p_site_id
|
||||||
|
and mc.asset_type = p_asset_type
|
||||||
|
and mc.asset_id = p_asset_id
|
||||||
|
order by mc.register, mc.id desc
|
||||||
|
) t
|
||||||
|
where t.val is not null;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_modbus_device_state_map is
|
||||||
|
'Mapa register -> poslední známá hodnota na zařízení (nejnovější řádek modbus_command per registr; jen status written/verified, jinak registr chybí). Pro write-on-change drop v control exporteru (EV wallboxy) — šetří EEPROM, po výpadku (failed) se zapíše znovu.';
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Účel
|
## Účel
|
||||||
|
|
||||||
Každý zápis na Modbus TCP (Deye a později další aktiva) se ukládá do tabulky `ems.modbus_command` jako samostatný řádek: cílový registr, hodnota, endpoint, vazba na `site_id` a volitelně na `planning_run_id`. Po zápisu má řádek stav `written`; samostatný **verifikační job** (každé 2 minuty) nebo ruční **GET** `/api/v1/sites/{site_id}/control/verify` přečte registr zpět a nastaví `value_verified` a stav `verified` nebo `mismatch`. **Výjimka:** Deye **62–64** (systémový čas) se vždy ověřují **jako celek** jedním čtením 62–64 a **tolerančně** podle dekódovaného data/času — řádky 62–64 se **neprohánějí** striktní větví po jednom registru (jinak by zejména **64** způsoboval falešné `mismatch` a SELF_SUSTAIN). Podmnožina `written` řádků (např. jen 64) se sloučí s dotazem na všechny `written` 62–64 pro daný invertor; viz `modbus-registers.md`.
|
Každý zápis na Modbus TCP (Deye `inverter` i TeltoCharge `ev_charger`) se ukládá do tabulky `ems.modbus_command` jako samostatný řádek: cílový registr, hodnota, endpoint, vazba na `site_id` a volitelně na `planning_run_id`. Po zápisu má řádek stav `written`; samostatný **verifikační job** (každé 2 minuty) nebo ruční **GET** `/api/v1/sites/{site_id}/control/verify` přečte registr zpět a nastaví `value_verified` a stav `verified` nebo `mismatch`. **Výjimka:** Deye **62–64** (systémový čas) se vždy ověřují **jako celek** jedním čtením 62–64 a **tolerančně** podle dekódovaného data/času — řádky 62–64 se **neprohánějí** striktní větví po jednom registru (jinak by zejména **64** způsoboval falešné `mismatch` a SELF_SUSTAIN). Podmnožina `written` řádků (např. jen 64) se sloučí s dotazem na všechny `written` 62–64 pro daný invertor; viz `modbus-registers.md`.
|
||||||
|
|
||||||
## Schéma `ems.modbus_command`
|
## Schéma `ems.modbus_command`
|
||||||
|
|
||||||
@@ -45,6 +45,30 @@ Pro diagnostiku času Deye po opravě clock logiky používej u `modbus_command`
|
|||||||
|
|
||||||
Implementace: `services/control_exporter.py` — `verify_modbus_commands`, `_verify_deye_clock_written_bundle`, `_fetch_written_deye_clock_commands`, `_switch_to_self_sustain`, `DEYE_CRITICAL_REGS_SELF_SUSTAIN`, `_deye_tou_power_verify_match`; `services/notification_service.py` — `run_fn_set_mode_with_discord`, `_auto_rolling_replan_after_self_sustain_exit`, `notify_operating_mode_changed`.
|
Implementace: `services/control_exporter.py` — `verify_modbus_commands`, `_verify_deye_clock_written_bundle`, `_fetch_written_deye_clock_commands`, `_switch_to_self_sustain`, `DEYE_CRITICAL_REGS_SELF_SUSTAIN`, `_deye_tou_power_verify_match`; `services/notification_service.py` — `run_fn_set_mode_with_discord`, `_auto_rolling_replan_after_self_sustain_exit`, `notify_operating_mode_changed`.
|
||||||
|
|
||||||
|
## 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'`).
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- **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`).
|
||||||
|
- Watchdog wallboxu sytí i FC 3 čtení telemetrie (60 s) — periodické zápisy
|
||||||
|
k udržení spojení nejsou potřeba; detail
|
||||||
|
[`modbus-registers-teltocharge.md`](modbus-registers-teltocharge.md).
|
||||||
|
|
||||||
## Střídač (Deye)
|
## Střídač (Deye)
|
||||||
|
|
||||||
`write_inverter_setpoints` přidá do journalu podle potřeby **62–64** (čas — po čtení z invertoru jen při driftu / 24h intervalu; viz `modbus-registers.md`) a **time pointy 148–177** (bloky 3–6 typicky jednou denně; viz `modbus-registers.md`), dále **108**, **109**, **141**, **142**, **178**, **143**. Každý řádek daného exportního běhu má **`deye_physical_mode`** (**PASSIVE** / **SELL** / **CHARGE**). **Reg 191** EMS nezapisuje (SolarmanApp). Převod výkonu: `battery_watts_to_amps` v `modbus-registers.md`.
|
`write_inverter_setpoints` přidá do journalu podle potřeby **62–64** (čas — po čtení z invertoru jen při driftu / 24h intervalu; viz `modbus-registers.md`) a **time pointy 148–177** (bloky 3–6 typicky jednou denně; viz `modbus-registers.md`), dále **108**, **109**, **141**, **142**, **178**, **143**. Každý řádek daného exportního běhu má **`deye_physical_mode`** (**PASSIVE** / **SELL** / **CHARGE**). **Reg 191** EMS nezapisuje (SolarmanApp). Převod výkonu: `battery_watts_to_amps` v `modbus-registers.md`.
|
||||||
@@ -83,6 +107,6 @@ Poznámka: **GEN port cut-off na BA81** se aktuálně provádí přímo přes De
|
|||||||
|
|
||||||
## Související soubory
|
## Související soubory
|
||||||
|
|
||||||
- Migrace: `db/migration/V023__modbus_command_journal.sql`, `V025__deye_physical_mode.sql`, `V030__deye_clock_sync_at.sql`, `V044__deye_register_max_current_a.sql`; repeatables `db/routines/R__044_fn_set_mode.sql` (`fn_expire_modes` vrací detail přepnutí pro notifikace)
|
- Migrace: `db/migration/V023__modbus_command_journal.sql`, `V025__deye_physical_mode.sql`, `V030__deye_clock_sync_at.sql`, `V044__deye_register_max_current_a.sql`; repeatables `db/routines/R__044_fn_set_mode.sql` (`fn_expire_modes` vrací detail přepnutí pro notifikace), `db/routines/R__002_fn_modbus_last_verified_map.sql`, `db/routines/R__100_fn_modbus_device_state_map.sql` (write-on-change pro EV)
|
||||||
- Backend: `backend/services/control_exporter.py`, `backend/services/modbus_client.py`, `backend/services/notification_service.py`, `backend/app/main.py`
|
- Backend: `backend/services/control_exporter.py`, `backend/services/control/outputs.py` (EV write-on-change), `backend/services/modbus_client.py`, `backend/services/notification_service.py`, `backend/app/main.py`
|
||||||
- Registry Deye: `docs/04-modules/modbus-registers.md`
|
- Registry Deye: `docs/04-modules/modbus-registers.md`; TeltoCharge: `docs/04-modules/modbus-registers-teltocharge.md`
|
||||||
|
|||||||
@@ -28,15 +28,43 @@ Mapování stavů v EMS (`TELTO_STATUS_MAP` v `telemetry_collector.py`):
|
|||||||
**Při selhání čtení se vzorek NEzapisuje** — fabrikovaný `available` by falešně
|
**Při selhání čtení se vzorek NEzapisuje** — fabrikovaný `available` by falešně
|
||||||
ukončil session a EV výkon 0 by špinil bazál (pravidlo 15).
|
ukončil session a EV výkon 0 by špinil bazál (pravidlo 15).
|
||||||
|
|
||||||
## Zápis (budoucí řízení — zatím NEimplementováno)
|
## Zápis (control exporter — `write_ev_setpoints`, `write_ev_arrival_hold`)
|
||||||
|
|
||||||
| Reg | Význam | Hodnoty |
|
| Reg | R/W | Význam | Hodnoty | EMS zapisuje |
|
||||||
|-----|--------|---------|
|
|-----|-----|--------|---------|--------------|
|
||||||
| 15 | **Amps to use** (limit proudu) | 0 = stop, 6–32 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 |
|
||||||
| 16 | Start/Stop session | 0 nic · 1 stop · 2 start |
|
| 16 | R/W | Start/Stop session | 0 nic · 1 stop · 2 start | ne |
|
||||||
| 19 | Communication timeout (watchdog) | 0–600 s (0 = vypnuto) |
|
| 19 | R/W | Communication timeout (watchdog) | 0–600 s (0 = vypnuto), default 30 | `TELTO_WATCHDOG_TIMEOUT_S` = **300** |
|
||||||
| 20 | Failsafe current | 0, 6–32 A |
|
| 20 | R/W | Failsafe current | 0, 6–32 A, default 6 | `TELTO_WATCHDOG_FAILSAFE_A` = **8** |
|
||||||
|
|
||||||
Až se zapne řízení: zapisovat reg 15 přes journal `modbus_command`
|
Všechny čtyři registry jsou dle oficiálního protokolu (wiki *External control
|
||||||
(pravidlo 17) a nastavit watchdog (reg 19/20) — při výpadku EMS wallbox
|
RS485* / protokol rev 0.5) **R/W** — verify job je čte zpět standardní FC 3
|
||||||
spadne na failsafe proud.
|
větví (žádný write-only registr v této sadě).
|
||||||
|
|
||||||
|
### Write-on-change — POVINNÉ (EEPROM wear)
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
### Watchdog — sytí ho i čtení
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user