fix zapisovani casu
This commit is contained in:
@@ -83,7 +83,7 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
|
||||
|
||||
17. **Modbus zápis = journal.** Každý zápis do zařízení přes control exporter se loguje do `ems.modbus_command`. **Verifikační job** běží každé **2 minuty** a ověřuje nedávno zápis (`written` → čtení registru). Při **mismatch** po max. **3** pokusech o zápis → u běžných registrů přepnutí na **SELF_SUSTAIN** (`run_fn_set_mode_with_discord` → `fn_set_mode`, `activated_by` = `system:mismatch`) + **Discord** při skutečné změně režimu. **Výjimka:** souvislý blok Deye **62–64** (čas) → po 3 neúspěšných ověřeních **bez** změny režimu, kritický **Discord** (`notify_modbus_clock_verify_exhausted`). **Obecně:** při jakékoli změně `mode_code` z Pythonu (`POST /api/v1/sites/{id}/mode`, mismatch → SELF_SUSTAIN, `fn_expire_modes`) lze Discord zapnout přes `DISCORD_WEBHOOK_URL`. Detail: `docs/04-modules/modbus-command-journal.md`.
|
||||
|
||||
18. **Deye zápis registrů 60–499:** pouze **FC 0x10** (`write_registers`), **nikdy** FC 0x06 pro tento rozsah; **`execute_modbus_commands`** slučuje souvislé adresy do jednoho FC 0x10. **Fyzické režimy střídače jsou tři:** **PASSIVE**, **SELL**, **CHARGE** (mapování z plánu / politik EMS v `control_exporter.get_deye_mode`). V **PASSIVE** a **SELL** jsou reg **108** / **109** obvykle na **maximum z DB** (**výjimka PRESERVE:** `lock_battery=True` → **0 / 0**). Omezování pod maximum jinak brání Deye reagovat na nepředvídatelnou spotřebu a přebytky FVE. **Řízení:** time points – blok **1** = začátek **aktuálního** 15min slotu + plán pro tento slot, blok **2** = začátek **následujícího** slotu + plán pro něj (`current_slot_hhmm` / `next_slot_hhmm`); bloky **3–6** neaktivní **2355** (ne 23:59 kvůli firmware), zápis **nejednou častěji než 1× denně** (Europe/Prague) + při změně podpisu (`deye_tou_inactive_signature`: `HHMM|min_soc|reserve_soc|tp_discharge_w`, V028 meta + V029 komentář); **reg 166+** u TP: **SELL** = `reserve_soc_percent`, **PASSIVE** / řádky **3–6** = `min_soc_percent`. **108** / **109** / **141** (0) / **142** (0 = selling first jen ve **SELL**, jinak 1) / **178** (pevně **32** ve **SELL**, **48** v **PASSIVE** a **CHARGE** – bez read-modify-write) / **143** (export limit W z DB) z **aktuálního** setpointu. **Reg 191** EMS **nezapisuje**. **Čas 62–64:** před zařazením do fronty **čtení** 62–64; zápis jen při driftu **> 60 s**, nebo **NULL** `deye_last_system_time_sync_at`, nebo uplynulých **24 h** od posledního **ověřeného** syncu (`deye_last_system_time_sync_at` / `deye_last_system_time_sync_minute` se doplňují až po **úspěšné toleranční verifikaci** v `_verify_deye_clock_command_run`, ne po samotném zápisu); při chybě čtení se čas zapisuje; reg **64** se zapisuje s **sekundami 0**; verifikace journalu pro souvislý blok 62–64 je **toleranční** (odchylka dekódovaného času až **120 s**); po 3 neúspěšných ověřeních **bez** přepnutí do SELF_SUSTAIN (jen Discord). **SELL:** `grid_setpoint_w` < −200. **CHARGE:** `battery_w` > 500 a `grid_setpoint_w` > 200. **PASSIVE:** ostatní (včetně `battery_w=None` u SELF_SUSTAIN → plné limity 108/109). Detail: `docs/04-modules/modbus-registers.md`, režimy: `docs/04-modules/operating-modes.md`.
|
||||
18. **Deye zápis registrů 60–499:** pouze **FC 0x10** (`write_registers`), **nikdy** FC 0x06 pro tento rozsah; **`execute_modbus_commands`** slučuje souvislé adresy do jednoho FC 0x10. **Fyzické režimy střídače jsou tři:** **PASSIVE**, **SELL**, **CHARGE** (mapování z plánu / politik EMS v `control_exporter.get_deye_mode`). V **PASSIVE** a **SELL** jsou reg **108** / **109** obvykle na **maximum z DB** (**výjimka PRESERVE:** `lock_battery=True` → **0 / 0**). Omezování pod maximum jinak brání Deye reagovat na nepředvídatelnou spotřebu a přebytky FVE. **Řízení:** time points – blok **1** = začátek **aktuálního** 15min slotu + plán pro tento slot, blok **2** = začátek **následujícího** slotu + plán pro něj (`current_slot_hhmm` / `next_slot_hhmm`); bloky **3–6** neaktivní **2355** (ne 23:59 kvůli firmware), zápis **nejednou častěji než 1× denně** (Europe/Prague) + při změně podpisu (`deye_tou_inactive_signature`: `HHMM|min_soc|reserve_soc|tp_discharge_w`, V028 meta + V029 komentář); **reg 166+** u TP: **SELL** = `reserve_soc_percent`, **PASSIVE** / řádky **3–6** = `min_soc_percent`. **108** / **109** / **141** (0) / **142** (0 = selling first jen ve **SELL**, jinak 1) / **178** (pevně **32** ve **SELL**, **48** v **PASSIVE** a **CHARGE** – bez read-modify-write) / **143** (export limit W z DB) z **aktuálního** setpointu. **Reg 191** EMS **nezapisuje**. **Čas 62–64:** před zařazením do fronty **čtení** 62–64; zápis jen při driftu **> 60 s**, nebo **NULL** `deye_last_system_time_sync_at`, nebo uplynulých **24 h** od posledního syncu; `deye_last_system_time_sync_at` / `deye_last_system_time_sync_minute` po **úspěšném zápisu** 62–64 a znovu po **úspěšné toleranční verifikaci**; při chybě čtení se čas zapisuje; reg **64** se zapisuje s **sekundami 0**; verify **vždy** čte 62–64 najednou — **reg 64 nesmí** do striktní větve; toleranční odchylka až **120 s**; po 3 neúspěších u hodin **bez** SELF_SUSTAIN (jen Discord). **SELL:** `grid_setpoint_w` < −200. **CHARGE:** `battery_w` > 500 a `grid_setpoint_w` > 200. **PASSIVE:** ostatní (včetně `battery_w=None` u SELF_SUSTAIN → plné limity 108/109). Detail: `docs/04-modules/modbus-registers.md`, režimy: `docs/04-modules/operating-modes.md`.
|
||||
|
||||
19. **Baterie – export v LP:** V `solve_dispatch` binárka `z_export[t]`: pokud `grid_export` v daném slotu **≥ 1** W, platí koncové `soc[t] ≥ arb_base_wh` (ekonomická rezerva z DB, ne časová řada `arb_floor_series`). Bez exportu může plán jít k `min_soc_percent` (provozní podlaha; u paralelních packů často 11–12 %, migrace V029 + komentář sloupce).
|
||||
|
||||
@@ -179,6 +179,7 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan
|
||||
| Self-hosted deploy (Gitea, Caddy, `/opt/ems-deploy`) | `docs/deployment-self-hosted.md`, `deploy/deploy.sh` |
|
||||
| Reset DB / restore z dumpu (Docker volume, Timescale) | `docs/database-reset-and-restore.md`, `scripts/import_ems_db.sh` |
|
||||
| Nespecifikované chování | `docs/06-open-questions.md` (přidat otázku, neimpl. naslepo) |
|
||||
| **MCP read-only SQL na EMS DB** | Cursor MCP server **`postgres-ems`**, nástroj **`query`**. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -49,6 +49,9 @@ _DEYE_INACTIVE_TOU_REGISTERS: frozenset[int] = frozenset(
|
||||
]
|
||||
)
|
||||
|
||||
# Systémový čas Deye — vždy toleranční verify jako celek 62–64 (reg 64 sám nesmí do striktní větve).
|
||||
DEYE_CLOCK_REGS: frozenset[int] = frozenset({62, 63, 64})
|
||||
|
||||
DEYE_REGISTER_NAMES: dict[int, str] = {
|
||||
108: "max_charge_a (max nabíjecí proud baterie)",
|
||||
109: "max_discharge_a (max vybíjecí proud baterie)",
|
||||
@@ -178,9 +181,9 @@ def _deye_should_skip_time_sync_after_read(
|
||||
r64: int,
|
||||
) -> bool:
|
||||
"""
|
||||
True = nezařazovat zápis 62–64: drift je malý a od posledního úspěšného ověření času
|
||||
(status verified v journalu 62–64) neuplynul 24h — sloupec deye_last_system_time_sync_at
|
||||
se doplňuje jen po tolerančním ověření v _verify_deye_clock_command_run.
|
||||
True = nezařazovat zápis 62–64: drift je malý a od posledního úspěšného zápisu (FC 0x10 ACK)
|
||||
nebo tolerančního ověření neuplynulo 24h — sloupec deye_last_system_time_sync_at doplňuje
|
||||
write_inverter_setpoints po úspěšném zápisu batche obsahujícího 62–64 a znovu po úspěšném verify.
|
||||
"""
|
||||
dev = _deye_registers_to_prague_datetime(r62, r63, r64)
|
||||
if dev is None:
|
||||
@@ -202,11 +205,35 @@ def _deye_should_skip_time_sync_after_read(
|
||||
return True
|
||||
|
||||
|
||||
def _is_deye_contiguous_clock_run(run: list[asyncpg.Record]) -> bool:
|
||||
if len(run) != 3:
|
||||
return False
|
||||
regs = sorted(int(c["register"]) for c in run)
|
||||
return regs == [62, 63, 64]
|
||||
async def _fetch_written_deye_clock_commands(
|
||||
site_id: int,
|
||||
asset_id: int,
|
||||
host: str,
|
||||
port: int,
|
||||
unit_id: int,
|
||||
db: asyncpg.Connection,
|
||||
) -> list[asyncpg.Record]:
|
||||
"""Všechny řádky journalu 62–64 ve stavu written pro daný invertor/endpoint."""
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT * FROM ems.modbus_command
|
||||
WHERE site_id = $1
|
||||
AND asset_type = 'inverter'
|
||||
AND asset_id = $2
|
||||
AND device_host = $3
|
||||
AND device_port = $4
|
||||
AND device_unit_id = $5
|
||||
AND register IN (62, 63, 64)
|
||||
AND status = 'written'
|
||||
ORDER BY register
|
||||
""",
|
||||
site_id,
|
||||
asset_id,
|
||||
host,
|
||||
port,
|
||||
unit_id,
|
||||
)
|
||||
return list(rows)
|
||||
|
||||
|
||||
async def _fetch_last_verified_inverter_registers(
|
||||
@@ -453,30 +480,72 @@ async def _switch_to_self_sustain(site_id: int, db: asyncpg.Connection, reason:
|
||||
logger.critical("Site %s switched to SELF_SUSTAIN: %s", site_id, reason)
|
||||
|
||||
|
||||
async def _verify_deye_clock_command_run(
|
||||
run: list[asyncpg.Record],
|
||||
values: list[int],
|
||||
db: asyncpg.Connection,
|
||||
def _modbus_cmd_register(cmd: Any) -> int:
|
||||
"""asyncpg.Record má __getitem__; objekty s atributem .register též (testy)."""
|
||||
try:
|
||||
return int(cmd["register"])
|
||||
except (KeyError, TypeError):
|
||||
return int(cmd.register)
|
||||
|
||||
|
||||
def _deye_expected_clock_triplet_for_verify(
|
||||
bundle: list[asyncpg.Record],
|
||||
last_verified: dict[int, int],
|
||||
a62: int,
|
||||
a63: int,
|
||||
a64: int,
|
||||
) -> tuple[int, int, int]:
|
||||
"""
|
||||
Sestaví očekávané (w62,w63,w64) pro toleranční verify.
|
||||
Chybějící registry doplní poslední verified nebo aktuálním přečtením ze zařízení
|
||||
(aby osiřelý zápis např. jen 64 nešel do striktního porovnání reg64).
|
||||
"""
|
||||
by_reg = {_modbus_cmd_register(c): c for c in bundle}
|
||||
def _vtw(c: Any) -> int:
|
||||
try:
|
||||
return int(c["value_to_write"])
|
||||
except (KeyError, TypeError):
|
||||
return int(c.value_to_write)
|
||||
|
||||
w62 = _vtw(by_reg[62]) if 62 in by_reg else last_verified.get(62, a62)
|
||||
w63 = _vtw(by_reg[63]) if 63 in by_reg else last_verified.get(63, a63)
|
||||
w64 = _vtw(by_reg[64]) if 64 in by_reg else last_verified.get(64, a64)
|
||||
return (int(w62), int(w63), int(w64))
|
||||
|
||||
|
||||
async def _verify_deye_clock_written_bundle(
|
||||
site_id: int,
|
||||
bundle: list[asyncpg.Record],
|
||||
a62: int,
|
||||
a63: int,
|
||||
a64: int,
|
||||
db: asyncpg.Connection,
|
||||
) -> bool:
|
||||
"""
|
||||
Ověření souvislého bloku 62–64: porovnání času z trojice registrů s tolerancí (sekundy na Deye běží).
|
||||
Při mismatch retry všech tří řádků journalu společně.
|
||||
Toleranční ověření pro jeden až tři řádky journalu 62–64 ve stavu written.
|
||||
Při mismatch retry společně; bez přepnutí do SELF_SUSTAIN po 3 pokusech.
|
||||
"""
|
||||
from services.notification_service import (
|
||||
notify_modbus_clock_verify_exhausted,
|
||||
notify_modbus_mismatch,
|
||||
)
|
||||
|
||||
run_s = sorted(run, key=lambda c: int(c["register"]))
|
||||
w62 = int(run_s[0]["value_to_write"])
|
||||
w63 = int(run_s[1]["value_to_write"])
|
||||
w64 = int(run_s[2]["value_to_write"])
|
||||
a62, a63, a64 = (int(values[0]), int(values[1]), int(values[2]))
|
||||
cmds_s = sorted(bundle, key=_modbus_cmd_register)
|
||||
try:
|
||||
asset_id = int(cmds_s[0]["asset_id"])
|
||||
except (KeyError, TypeError):
|
||||
asset_id = int(cmds_s[0].asset_id)
|
||||
last_v = await _fetch_last_verified_inverter_registers(site_id, asset_id, db)
|
||||
w62, w63, w64 = _deye_expected_clock_triplet_for_verify(bundle, last_v, a62, a63, a64)
|
||||
clock_ok = _deye_clock_registers_verify_match(w62, w63, w64, a62, a63, a64)
|
||||
actual_by_reg = {62: a62, 63: a63, 64: a64}
|
||||
|
||||
for cmd, actual in zip(run_s, values):
|
||||
for cmd in cmds_s:
|
||||
try:
|
||||
cid = int(cmd["id"])
|
||||
except (KeyError, TypeError):
|
||||
cid = int(cmd.id)
|
||||
r = _modbus_cmd_register(cmd)
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.modbus_command
|
||||
@@ -484,13 +553,12 @@ async def _verify_deye_clock_command_run(
|
||||
status=CASE WHEN $2::boolean THEN 'verified' ELSE 'mismatch' END
|
||||
WHERE id=$3::int
|
||||
""",
|
||||
int(actual),
|
||||
actual_by_reg[r],
|
||||
clock_ok,
|
||||
cid,
|
||||
)
|
||||
|
||||
if clock_ok:
|
||||
inv_asset_id = int(run_s[0]["asset_id"])
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.asset_inverter
|
||||
@@ -499,22 +567,35 @@ async def _verify_deye_clock_command_run(
|
||||
WHERE id = $2
|
||||
""",
|
||||
_prague_minute_start_utc(),
|
||||
inv_asset_id,
|
||||
asset_id,
|
||||
)
|
||||
for cmd, actual in zip(run_s, values):
|
||||
for cmd in cmds_s:
|
||||
try:
|
||||
cid_l = int(cmd["id"])
|
||||
except (KeyError, TypeError):
|
||||
cid_l = int(cmd.id)
|
||||
try:
|
||||
code_l = str(cmd["asset_code"])
|
||||
except (KeyError, TypeError):
|
||||
code_l = str(cmd.asset_code)
|
||||
rr = _modbus_cmd_register(cmd)
|
||||
logger.info(
|
||||
"[cmd %s] verified OK (clock tolerant): %s 0x%04X=%s",
|
||||
int(cmd["id"]),
|
||||
cmd["asset_code"],
|
||||
int(cmd["register"]),
|
||||
int(actual),
|
||||
cid_l,
|
||||
code_l,
|
||||
rr,
|
||||
actual_by_reg[rr],
|
||||
)
|
||||
return True
|
||||
|
||||
cmd0 = run_s[0]
|
||||
cmd0 = cmds_s[0]
|
||||
try:
|
||||
ac0 = str(cmd0["asset_code"])
|
||||
except (KeyError, TypeError):
|
||||
ac0 = str(cmd0.asset_code)
|
||||
logger.error(
|
||||
"[cmd clock] MISMATCH %s 62–64: written=(%s,%s,%s) actual=(%s,%s,%s)",
|
||||
cmd0["asset_code"],
|
||||
ac0,
|
||||
w62,
|
||||
w63,
|
||||
w64,
|
||||
@@ -524,15 +605,19 @@ async def _verify_deye_clock_command_run(
|
||||
)
|
||||
|
||||
attempts = 0
|
||||
for cmd in run_s:
|
||||
for cmd in cmds_s:
|
||||
try:
|
||||
cid_q = int(cmd["id"])
|
||||
except (KeyError, TypeError):
|
||||
cid_q = int(cmd.id)
|
||||
row_ac = await db.fetchrow(
|
||||
"SELECT attempt_count FROM ems.modbus_command WHERE id=$1", int(cmd["id"])
|
||||
"SELECT attempt_count FROM ems.modbus_command WHERE id=$1", cid_q
|
||||
)
|
||||
ac = int(row_ac["attempt_count"] or 0) if row_ac else 0
|
||||
attempts = max(attempts, ac)
|
||||
|
||||
await notify_modbus_mismatch(
|
||||
str(cmd0["asset_code"]),
|
||||
ac0,
|
||||
62,
|
||||
"system_time_62_64",
|
||||
w62,
|
||||
@@ -540,7 +625,12 @@ async def _verify_deye_clock_command_run(
|
||||
attempts,
|
||||
)
|
||||
|
||||
ids_ordered = [int(c["id"]) for c in run_s]
|
||||
ids_ordered = []
|
||||
for c in cmds_s:
|
||||
try:
|
||||
ids_ordered.append(int(c["id"]))
|
||||
except (KeyError, TypeError):
|
||||
ids_ordered.append(int(c.id))
|
||||
if attempts < 3:
|
||||
for cid in ids_ordered:
|
||||
await db.execute(
|
||||
@@ -556,7 +646,7 @@ async def _verify_deye_clock_command_run(
|
||||
site = await db.fetchrow("SELECT code FROM ems.site WHERE id=$1", site_id)
|
||||
await notify_modbus_clock_verify_exhausted(
|
||||
site["code"] if site else str(site_id),
|
||||
str(cmd0["asset_code"]),
|
||||
ac0,
|
||||
(w62, w63, w64),
|
||||
(a62, a63, a64),
|
||||
)
|
||||
@@ -663,7 +753,40 @@ async def verify_modbus_commands(
|
||||
all_ok = True
|
||||
for (host, port, unit), group in by_gw.items():
|
||||
client = await get_modbus_client(host, port)
|
||||
for run in _modbus_command_contiguous_runs(group):
|
||||
clock_cmds = [c for c in group if int(c["register"]) in DEYE_CLOCK_REGS]
|
||||
rest = [c for c in group if int(c["register"]) not in DEYE_CLOCK_REGS]
|
||||
|
||||
if clock_cmds:
|
||||
asset_id = int(clock_cmds[0]["asset_id"])
|
||||
bundle = await _fetch_written_deye_clock_commands(
|
||||
site_id, asset_id, host, port, unit, db
|
||||
)
|
||||
if not bundle:
|
||||
bundle = clock_cmds
|
||||
try:
|
||||
cvals = await client.read_holding_registers(62, 3, unit)
|
||||
except Exception as e:
|
||||
logger.error("verify clock read 62–64 failed: %s", e)
|
||||
all_ok = False
|
||||
else:
|
||||
if len(cvals) != 3:
|
||||
logger.error(
|
||||
"verify clock read: expected 3 regs, got %s", len(cvals)
|
||||
)
|
||||
all_ok = False
|
||||
else:
|
||||
matched = await _verify_deye_clock_written_bundle(
|
||||
site_id,
|
||||
bundle,
|
||||
int(cvals[0]),
|
||||
int(cvals[1]),
|
||||
int(cvals[2]),
|
||||
db,
|
||||
)
|
||||
if not matched:
|
||||
all_ok = False
|
||||
|
||||
for run in _modbus_command_contiguous_runs(rest):
|
||||
start_reg = int(run[0]["register"])
|
||||
n = len(run)
|
||||
try:
|
||||
@@ -683,11 +806,6 @@ async def verify_modbus_commands(
|
||||
)
|
||||
all_ok = False
|
||||
continue
|
||||
if _is_deye_contiguous_clock_run(run):
|
||||
matched = await _verify_deye_clock_command_run(run, values, db, site_id)
|
||||
if not matched:
|
||||
all_ok = False
|
||||
continue
|
||||
for cmd, actual in zip(run, values):
|
||||
matched = await _apply_verify_result(cmd, int(actual))
|
||||
if not matched:
|
||||
@@ -1172,7 +1290,7 @@ async def write_inverter_setpoints(
|
||||
|
||||
if skip_time:
|
||||
logger.info(
|
||||
"Deye clock 62–64 skipped (drift ≤ %ss, last write < %sh ago): %s CET",
|
||||
"Deye clock 62–64 skipped (drift ≤ %ss, last sync < %sh ago): %s CET",
|
||||
DEYE_CLOCK_DRIFT_OK_SEC,
|
||||
DEYE_CLOCK_RESYNC_INTERVAL_HOURS,
|
||||
now_cet.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
@@ -1290,6 +1408,19 @@ async def write_inverter_setpoints(
|
||||
return f"FAIL inverter: {inv.code}: Modbus write failed (see modbus_command)"
|
||||
logger.info("[control] Inverter %s journal write OK", inv.code)
|
||||
|
||||
will_write_time = any(int(r) in DEYE_CLOCK_REGS for r, _, _ in registers)
|
||||
if will_write_time:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.asset_inverter
|
||||
SET deye_last_system_time_sync_minute = $1,
|
||||
deye_last_system_time_sync_at = now()
|
||||
WHERE id = $2
|
||||
""",
|
||||
_prague_minute_start_utc(),
|
||||
inv.id,
|
||||
)
|
||||
|
||||
if need_inactive_tou or will_write_inactive:
|
||||
await db.execute(
|
||||
"""
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from services.control_exporter import (
|
||||
DEYE_CLOCK_DRIFT_OK_SEC,
|
||||
@@ -12,6 +13,7 @@ from services.control_exporter import (
|
||||
InverterConfig,
|
||||
PRAGUE_TZ,
|
||||
_deye_clock_registers_verify_match,
|
||||
_deye_expected_clock_triplet_for_verify,
|
||||
_deye_registers_to_prague_datetime,
|
||||
_deye_should_skip_time_sync_after_read,
|
||||
_deye_system_time_register_rows,
|
||||
@@ -121,5 +123,23 @@ class DeyeSkipTimeSyncPolicyTests(unittest.TestCase):
|
||||
self.assertGreater(DEYE_CLOCK_DRIFT_OK_SEC, 5)
|
||||
|
||||
|
||||
class DeyeClockTripletForVerifyTests(unittest.TestCase):
|
||||
def test_orphan_reg64_fills_w62_w63_from_device_read(self) -> None:
|
||||
a62 = (2026 - 2000) << 8 | 4
|
||||
a63 = 10 << 8 | 12
|
||||
a64 = 45 << 8 | 30
|
||||
bundle = [SimpleNamespace(register=64, value_to_write=(45 << 8) | 0)]
|
||||
w62, w63, w64 = _deye_expected_clock_triplet_for_verify(bundle, {}, a62, a63, a64)
|
||||
self.assertEqual(w62, a62)
|
||||
self.assertEqual(w63, a63)
|
||||
self.assertEqual(w64, 45 << 8)
|
||||
|
||||
def test_last_verified_used_when_not_in_bundle(self) -> None:
|
||||
bundle: list[SimpleNamespace] = []
|
||||
last = {62: 1, 63: 2, 64: 3}
|
||||
w62, w63, w64 = _deye_expected_clock_triplet_for_verify(bundle, last, 9, 8, 7)
|
||||
self.assertEqual((w62, w63, w64), (1, 2, 3))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Úč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:** souvislý blok Deye **62–64** (systémový čas) se ověřuje **tolerančně** podle dekódovaného data/času (kvůli tikajícím sekundám na invertoru); viz `modbus-registers.md`.
|
||||
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`.
|
||||
|
||||
## Schéma `ems.modbus_command`
|
||||
|
||||
@@ -30,7 +30,7 @@ Indexy: podle `(site_id, status, created_at)` a částečný index pro `pending`
|
||||
|
||||
**Discord při jakékoli změně režimu** (nejen Modbus): `notification_service.run_fn_set_mode_with_discord` volá `ems.fn_set_mode` a při změně `mode_code` oproti stavu před voláním pošle zprávu (`notify_operating_mode_changed`). Úroveň: `user:api` → info, obecné `system:*` → warning, `system:mismatch` → critical. Použití: HTTP `POST /api/v1/sites/{site_id}/mode`, `_switch_to_self_sustain` v `control_exporter`. Vypršení `valid_until`: `ems.fn_expire_modes()` vrací řádky `(site_id, site_code, old_mode, new_mode)` pro každé provedené přepnutí; scheduler v `main.py` (a lazy expire v `_fetch_operating_mode`) z nich pošle Discord.
|
||||
|
||||
Implementace: `services/control_exporter.py` — `verify_modbus_commands`, `_verify_deye_clock_command_run`, `_switch_to_self_sustain`; `services/notification_service.py` — `run_fn_set_mode_with_discord`, `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`; `services/notification_service.py` — `run_fn_set_mode_with_discord`, `notify_operating_mode_changed`.
|
||||
|
||||
## Střídač (Deye)
|
||||
|
||||
|
||||
@@ -100,11 +100,11 @@ Registry **62–64** nastavují invertoru čas v **Europe/Prague**.
|
||||
- reg **63:** `den << 8 | hodina`
|
||||
- reg **64:** `minuta << 8 | sekunda` — při zápisu z EMS jsou **sekundy vždy 0** (stabilnější hodnota; na zařízení pak sekundy dál běží).
|
||||
|
||||
**Řidší zápis:** před každým exportem setpointů EMS **přečte** 62–64 (FC 0x03). Do journalu **62–64 nezařadí**, pokud je dekódovaný čas invertoru vůči aktuální **Europe/Prague** v odchylce **≤ 60 s** *a zároveň* od posledního **úspěšného ověření** trojice 62–64 v journalu (stav `verified` po toleranční kontrole níže) neuplynulo **24 h**. Tomu odpovídají sloupce `asset_inverter.deye_last_system_time_sync_at` a `deye_last_system_time_sync_minute`: doplňují se **až po úspěšné toleranční verifikaci** v `_verify_deye_clock_command_run`, nikoli po samotném FC 0x10 zápisu (aby řídicí logika ne„myslela“, že je čas na invertoru sjednocený, dokud to verify nepotvrdí). Je-li `deye_last_system_time_sync_at` **NULL** (první provoz / nikdy neověřený sync), zápis času se **nevynechá**. Při **selhání čtení** 62–64 před rozhodnutím se čas **zařadí do journalu** (bezpečný fallback). Při scénáři „žádný řádek journalu, všechny hodnoty jako poslední `verified`“ se **čas v DB neaktualizuje** (žádný fiktivní sync).
|
||||
**Řidší zápis:** před každým exportem setpointů EMS **přečte** 62–64 (FC 0x03). Do journalu **62–64 nezařadí**, pokud je dekódovaný čas invertoru vůči aktuální **Europe/Prague** v odchylce **≤ 60 s** *a zároveň* od posledního **úspěšného syncu** neuplynulo **24 h**. Sloupce `asset_inverter.deye_last_system_time_sync_at` a `deye_last_system_time_sync_minute` se doplňují po **úspěšném FC 0x10 zápisu** batche obsahujícího 62–64 (`write_inverter_setpoints`) a znovu po **úspěšné toleranční verifikaci** (`_verify_deye_clock_written_bundle`) — obojí drží řidší zápis i když verify občas selže. Je-li `deye_last_system_time_sync_at` **NULL** (první provoz), zápis času se **nevynechá**. Při **selhání čtení** 62–64 před rozhodnutím se čas **zařadí do journalu** (bezpečný fallback). Při scénáři „žádný řádek journalu, všechny hodnoty jako poslední `verified`“ se **čas v DB neaktualizuje** (žádný fiktivní sync).
|
||||
|
||||
Zápis prochází journal jako každý jiný registr; na sběrnici jde souvislý blok **FC 0x10**.
|
||||
|
||||
**Verifikace (journal):** u souvislého bloku **62–64** není porovnání bajt po bajtu — invertor mezi zápisem a čtením posune sekundy. EMS považuje zápis za úspěšný, pokud se dekódované časy (z `value_to_write` trojice vs. přečtené hodnoty) liší nejvýše o **120 s** (`control_exporter._verify_deye_clock_command_run`). Po **třech neúspěšných ověřeních** bloku 62–64 EMS **nepřepíná** provozní režim na SELF_SUSTAIN (na rozdíl od ostatních registrů); pošle **kritický Discord** (`notify_modbus_clock_verify_exhausted`) a příkazy zůstanou ve stavu vhodném pro diagnostiku — viz `modbus-command-journal.md`.
|
||||
**Verifikace (journal):** registry **62–64** se **nikdy** neověřují striktním porovnáním po jednotlivých registrech (reg **64** by kvůli běžícím sekundám padal do `mismatch` a spouštěl SELF_SUSTAIN). Verifikační job vždy přečte **FC 0x03** souvisle **62–64** a použije toleranci **120 s** na dekódovaný čas (`_deye_clock_registers_verify_match`). Je-li ve stavu `written` jen podmnožina řádků (např. jen **64**), očekávané hodnoty pro chybějící registry se doplní z posledního `verified` nebo z aktuálního přečtení na sběrnici (`_deye_expected_clock_triplet_for_verify`). Po **třech neúspěšných ověřeních** bloku 62–64 EMS **nepřepíná** provozní režim na SELF_SUSTAIN; pošle **kritický Discord** (`notify_modbus_clock_verify_exhausted`) — viz `modbus-command-journal.md`.
|
||||
|
||||
**Před vytvořením journalu:** pokud je navrhovaná hodnota **shodná s posledním `verified`** záznamem daného registru v `modbus_command`, EMS **řádek nevytvoří** a na Modbus neposílá (žádný „X → X“ zápis jen kvůli periodickému exportu). Výjimky řeší stávající logika (řidší 62–64 výše, denní TOU 3–6 + meta sloupce na `asset_inverter`).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user