From bbb5e63d1de428cf3305efcd99738645a9fe9eb8 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Fri, 3 Apr 2026 22:21:55 +0200 Subject: [PATCH] Uprava aktualizace casu ve stridaci - mene casto, akceptujeme az 120s drift, zapisujeme presto 1x denne --- CLAUDE.md | 2 +- backend/services/control_exporter.py | 236 ++++++++++++++++++++-- backend/tests/test_deye_clock.py | 125 ++++++++++++ db/migration/V030__deye_clock_sync_at.sql | 10 + docs/04-modules/modbus-command-journal.md | 6 +- docs/04-modules/modbus-registers.md | 10 +- 6 files changed, 367 insertions(+), 22 deletions(-) create mode 100644 backend/tests/test_deye_clock.py create mode 100644 db/migration/V030__deye_clock_sync_at.sql diff --git a/CLAUDE.md b/CLAUDE.md index 0b8255f..7ddce54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 → přepnutí na **SELF_SUSTAIN** (`fn_set_mode`, `system:mismatch`) + **Discord** alert, pokud je `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:** jen když se oproti poslednímu zápisu posunula **minuta** (Europe/Prague). **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 zápisu času (`deye_last_system_time_sync_at` se mění jen při 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**). **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). diff --git a/backend/services/control_exporter.py b/backend/services/control_exporter.py index 809060b..56ca8ca 100644 --- a/backend/services/control_exporter.py +++ b/backend/services/control_exporter.py @@ -8,7 +8,7 @@ import os from collections import defaultdict from dataclasses import dataclass from typing import Any -from datetime import date, datetime, timezone +from datetime import date, datetime, timedelta, timezone from zoneinfo import ZoneInfo import asyncpg @@ -21,6 +21,13 @@ logger = logging.getLogger(__name__) PRAGUE_TZ = ZoneInfo("Europe/Prague") +# Hodiny Deye 62–64: po zápisu sekundy na zařízení dál běží → verify musí být toleranční. +DEYE_CLOCK_VERIFY_MAX_DELTA_SEC = 120 +# Řidší zápis: bez zápisu, pokud čas na invertoru neodbočí od Prahy víc než o tolik sekund… +DEYE_CLOCK_DRIFT_OK_SEC = 60 +# …a zároveň neuplynul tento interval od posledního syncu / potvrzení driftu. +DEYE_CLOCK_RESYNC_INTERVAL_HOURS = 24 + # Deye LV baterie: převod výkon → proud pro registry 108/109 (viz docs/04-modules/modbus-registers.md) BATT_VOLTAGE_V = 51.2 @@ -120,6 +127,7 @@ class InverterConfig: max_charge_a: int max_discharge_a: int deye_last_system_time_sync_minute: datetime | None = None + deye_last_system_time_sync_at: datetime | None = None deye_last_tou_inactive_write_prague_date: date | None = None deye_tou_inactive_signature: str | None = None @@ -130,14 +138,74 @@ def _prague_minute_start_utc() -> datetime: return p.astimezone(timezone.utc) -def _deye_skip_time_registers(inv: InverterConfig) -> bool: - """True = neposílat 62–64 (stejná pražská minuta jako u posledního úspěšného zápisu).""" - last = inv.deye_last_system_time_sync_minute - if last is None: +def _deye_registers_to_prague_datetime(r62: int, r63: int, r64: int) -> datetime | None: + """Dekódování reg 62–64 (Deye system time v Europe/Prague).""" + try: + year = (int(r62) >> 8) + 2000 + month = int(r62) & 0xFF + day = int(r63) >> 8 + hour = int(r63) & 0xFF + minute = int(r64) >> 8 + second = int(r64) & 0xFF + if not (1 <= month <= 12 and 1 <= day <= 31 and 0 <= hour <= 23): + return None + if not (0 <= minute <= 59 and 0 <= second <= 59): + return None + return datetime(year, month, day, hour, minute, second, tzinfo=PRAGUE_TZ) + except (ValueError, OverflowError): + return None + + +def _deye_clock_registers_verify_match( + w62: int, + w63: int, + w64: int, + a62: int, + a63: int, + a64: int, +) -> bool: + w_dt = _deye_registers_to_prague_datetime(w62, w63, w64) + a_dt = _deye_registers_to_prague_datetime(a62, a63, a64) + if w_dt is None or a_dt is None: return False - if last.tzinfo is None: - last = last.replace(tzinfo=timezone.utc) - return last.astimezone(timezone.utc) == _prague_minute_start_utc() + return abs((a_dt - w_dt).total_seconds()) <= DEYE_CLOCK_VERIFY_MAX_DELTA_SEC + + +def _deye_should_skip_time_sync_after_read( + inv: InverterConfig, + r62: int, + r63: int, + r64: int, +) -> bool: + """ + True = nezařazovat zápis 62–64: drift je malý a od posledního úspěšného zápisu času + neuplynul 24h (deye_last_system_time_sync_at se mění jen při zápisu, ne při přeskočení). + """ + dev = _deye_registers_to_prague_datetime(r62, r63, r64) + if dev is None: + return False + wall = datetime.now(PRAGUE_TZ) + drift = abs((wall - dev).total_seconds()) + if drift > DEYE_CLOCK_DRIFT_OK_SEC: + return False + last_write = inv.deye_last_system_time_sync_at + if last_write is None: + return False + if last_write.tzinfo is None: + last_write = last_write.replace(tzinfo=timezone.utc) + else: + last_write = last_write.astimezone(timezone.utc) + age = datetime.now(timezone.utc) - last_write + if age >= timedelta(hours=DEYE_CLOCK_RESYNC_INTERVAL_HOURS): + return False + 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_last_verified_inverter_registers( @@ -382,6 +450,116 @@ 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, + site_id: int, +) -> 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ě. + """ + from services.notification_service import ( + notify_modbus_mismatch, + notify_self_sustain_activated, + ) + + 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])) + clock_ok = _deye_clock_registers_verify_match(w62, w63, w64, a62, a63, a64) + + for cmd, actual in zip(run_s, values): + cid = int(cmd["id"]) + await db.execute( + """ + UPDATE ems.modbus_command + SET value_verified=$1::int, verified_at=now(), + status=CASE WHEN $2::boolean THEN 'verified' ELSE 'mismatch' END + WHERE id=$3::int + """, + int(actual), + clock_ok, + cid, + ) + + if clock_ok: + for cmd, actual in zip(run_s, values): + logger.info( + "[cmd %s] verified OK (clock tolerant): %s 0x%04X=%s", + int(cmd["id"]), + cmd["asset_code"], + int(cmd["register"]), + int(actual), + ) + return True + + cmd0 = run_s[0] + logger.error( + "[cmd clock] MISMATCH %s 62–64: written=(%s,%s,%s) actual=(%s,%s,%s)", + cmd0["asset_code"], + w62, + w63, + w64, + a62, + a63, + a64, + ) + + attempts = 0 + for cmd in run_s: + row_ac = await db.fetchrow( + "SELECT attempt_count FROM ems.modbus_command WHERE id=$1", int(cmd["id"]) + ) + 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"]), + 62, + "system_time_62_64", + w62, + a62, + attempts, + ) + + ids_ordered = [int(c["id"]) for c in run_s] + if attempts < 3: + for cid in ids_ordered: + await db.execute( + "UPDATE ems.modbus_command SET status='retrying' WHERE id=$1", + cid, + ) + await execute_modbus_commands(ids_ordered, db) + await verify_modbus_commands(ids_ordered, db, site_id) + else: + logger.critical( + "[cmd clock] 3 failed attempts (62–64 batch), switching to SELF_SUSTAIN" + ) + site = await db.fetchrow("SELECT code FROM ems.site WHERE id=$1", site_id) + await _switch_to_self_sustain( + site_id, + db, + reason=( + f"Modbus mismatch po 3 pokusech: {cmd0['asset_code']} " + "regs 62–64 (system time)" + ), + ) + if site: + await notify_self_sustain_activated( + site["code"], + ( + f"Modbus mismatch: {cmd0['asset_code']} " + f"regs 62–64 (system time) written=({w62},{w63},{w64}) " + f"actual=({a62},{a63},{a64})" + ), + ) + return False + + async def verify_modbus_commands( command_ids: list[int], db: asyncpg.Connection, @@ -517,6 +695,11 @@ 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: @@ -589,6 +772,7 @@ async def _load_inverter_config( ab.max_soc_percent, ab.usable_capacity_wh, ai.deye_last_system_time_sync_minute, + ai.deye_last_system_time_sync_at, ai.deye_last_tou_inactive_write_prague_date, ai.deye_tou_inactive_signature, LEAST( @@ -655,6 +839,7 @@ async def _load_inverter_config( max_charge_a=max_charge_a, max_discharge_a=max_discharge_a, deye_last_system_time_sync_minute=row["deye_last_system_time_sync_minute"], + deye_last_system_time_sync_at=row["deye_last_system_time_sync_at"], deye_last_tou_inactive_write_prague_date=row[ "deye_last_tou_inactive_write_prague_date" ], @@ -663,11 +848,11 @@ async def _load_inverter_config( def _deye_system_time_register_rows() -> tuple[datetime, list[tuple[int, str, int]]]: - """Hodnoty pro reg 62–64 (Europe/Prague).""" - now = datetime.now(ZoneInfo("Europe/Prague")) + """Hodnoty pro reg 62–64 (Europe/Prague); sekundy v reg 64 = 0 (stabilnější zápis).""" + now = datetime.now(PRAGUE_TZ).replace(second=0, microsecond=0) reg62 = ((now.year - 2000) << 8) | now.month reg63 = (now.day << 8) | now.hour - reg64 = (now.minute << 8) | now.second + reg64 = (now.minute << 8) | 0 rows = [ (62, "", reg62), (63, "", reg63), @@ -969,10 +1154,29 @@ async def write_inverter_setpoints( ) now_cet, time_rows = _deye_system_time_register_rows() - skip_time = _deye_skip_time_registers(inv) + skip_time = False + try: + mb_clock = await get_modbus_client(inv.host, inv.port) + tvals = await mb_clock.read_holding_registers( + 62, 3, int(inv.unit_id if inv.unit_id is not None else 1) + ) + if len(tvals) == 3: + skip_time = _deye_should_skip_time_sync_after_read( + inv, int(tvals[0]), int(tvals[1]), int(tvals[2]) + ) + else: + logger.warning( + "Deye clock read: expected 3 registers, got %s; will sync 62–64", + len(tvals), + ) + except Exception as e: + logger.warning("Deye clock read failed (will sync 62–64): %s", e) + if skip_time: logger.info( - "Deye clock 62–64 skipped (same Prague minute as last sync): %s CET", + "Deye clock 62–64 skipped (drift ≤ %ss, last write < %sh ago): %s CET", + DEYE_CLOCK_DRIFT_OK_SEC, + DEYE_CLOCK_RESYNC_INTERVAL_HOURS, now_cet.strftime("%Y-%m-%d %H:%M:%S"), ) else: @@ -1068,7 +1272,8 @@ async def write_inverter_setpoints( await db.execute( """ UPDATE ems.asset_inverter - SET deye_last_system_time_sync_minute = $1 + SET deye_last_system_time_sync_minute = $1, + deye_last_system_time_sync_at = now() WHERE id = $2 """, _prague_minute_start_utc(), @@ -1105,7 +1310,8 @@ async def write_inverter_setpoints( await db.execute( """ UPDATE ems.asset_inverter - SET deye_last_system_time_sync_minute = $1 + SET deye_last_system_time_sync_minute = $1, + deye_last_system_time_sync_at = now() WHERE id = $2 """, minute_utc, diff --git a/backend/tests/test_deye_clock.py b/backend/tests/test_deye_clock.py new file mode 100644 index 0000000..3ba42fa --- /dev/null +++ b/backend/tests/test_deye_clock.py @@ -0,0 +1,125 @@ +"""Deye systémový čas 62–64: dekódování, toleranční verify, politika driftu.""" + +from __future__ import annotations + +import unittest +from datetime import datetime, timedelta, timezone + +from services.control_exporter import ( + DEYE_CLOCK_DRIFT_OK_SEC, + DEYE_CLOCK_RESYNC_INTERVAL_HOURS, + DEYE_CLOCK_VERIFY_MAX_DELTA_SEC, + InverterConfig, + PRAGUE_TZ, + _deye_clock_registers_verify_match, + _deye_registers_to_prague_datetime, + _deye_should_skip_time_sync_after_read, + _deye_system_time_register_rows, +) + + +def _inv( + *, + sync_at: datetime | None = None, +) -> InverterConfig: + return InverterConfig( + id=1, + code="deye-main", + host="127.0.0.1", + port=502, + unit_id=1, + max_export_power_w=13_500, + max_import_power_w=13_500, + no_export=False, + max_battery_charge_w=10_000, + max_battery_discharge_w=10_000, + min_soc_percent=12, + reserve_soc_percent=20, + max_soc_percent=95, + usable_capacity_wh=64_000, + max_charge_a=100, + max_discharge_a=100, + deye_last_system_time_sync_at=sync_at, + ) + + +class DeyeClockDecodeTests(unittest.TestCase): + def test_roundtrip_encode_decode(self) -> None: + now_cet, rows = _deye_system_time_register_rows() + r62 = next(v for a, _, v in rows if a == 62) + r63 = next(v for a, _, v in rows if a == 63) + r64 = next(v for a, _, v in rows if a == 64) + dt = _deye_registers_to_prague_datetime(r62, r63, r64) + self.assertIsNotNone(dt) + assert dt is not None + self.assertEqual(dt.tzinfo, PRAGUE_TZ) + self.assertEqual(dt.year, now_cet.year) + self.assertEqual(dt.month, now_cet.month) + self.assertEqual(dt.day, now_cet.day) + self.assertEqual(dt.hour, now_cet.hour) + self.assertEqual(dt.minute, now_cet.minute) + self.assertEqual(dt.second, 0) + + def test_verify_same_triplet(self) -> None: + # 2006-10-03 15:45:00 Praha (platné měsíc/den/hod/min/sec) + r62, r63, r64 = 0x060A, 0x030F, 0x2D00 + self.assertTrue(_deye_clock_registers_verify_match(r62, r63, r64, r62, r63, r64)) + + def test_verify_seconds_within_tolerance(self) -> None: + w62 = (2026 - 2000) << 8 | 4 + w63 = 3 << 8 | 14 + w64 = 30 << 8 | 10 + a64 = 30 << 8 | 50 + self.assertTrue(_deye_clock_registers_verify_match(w62, w63, w64, w62, w63, a64)) + self.assertLessEqual(40, DEYE_CLOCK_VERIFY_MAX_DELTA_SEC) + + def test_verify_fails_when_minute_differs_beyond_tolerance(self) -> None: + w62 = (2026 - 2000) << 8 | 4 + w63 = 3 << 8 | 14 + w64 = 30 << 8 | 0 + a64 = 33 << 8 | 0 + self.assertFalse(_deye_clock_registers_verify_match(w62, w63, w64, w62, w63, a64)) + + +class DeyeSkipTimeSyncPolicyTests(unittest.TestCase): + def test_no_skip_when_never_written_even_if_drift_ok(self) -> None: + wall = datetime.now(PRAGUE_TZ) + r62 = ((wall.year - 2000) << 8) | wall.month + r63 = (wall.day << 8) | wall.hour + r64 = (wall.minute << 8) | wall.second + self.assertFalse( + _deye_should_skip_time_sync_after_read(_inv(sync_at=None), r62, r63, r64) + ) + + def test_no_skip_when_drift_large(self) -> None: + wall = datetime.now(PRAGUE_TZ) + wrong_min = (wall.minute - 3) % 60 + r62 = ((wall.year - 2000) << 8) | wall.month + r63 = (wall.day << 8) | wall.hour + r64 = (wrong_min << 8) | wall.second + self.assertFalse(_deye_should_skip_time_sync_after_read(_inv(sync_at=None), r62, r63, r64)) + + def test_no_skip_after_24h_even_if_drift_small(self) -> None: + wall = datetime.now(PRAGUE_TZ) + r62 = ((wall.year - 2000) << 8) | wall.month + r63 = (wall.day << 8) | wall.hour + r64 = (wall.minute << 8) | wall.second + old = datetime.now(timezone.utc) - timedelta(hours=DEYE_CLOCK_RESYNC_INTERVAL_HOURS) - timedelta(minutes=1) + self.assertFalse( + _deye_should_skip_time_sync_after_read(_inv(sync_at=old), r62, r63, r64) + ) + + def test_skip_within_24h_and_small_drift(self) -> None: + wall = datetime.now(PRAGUE_TZ) + r62 = ((wall.year - 2000) << 8) | wall.month + r63 = (wall.day << 8) | wall.hour + r64 = (wall.minute << 8) | min(59, wall.second + 5) + recent = datetime.now(timezone.utc) - timedelta(hours=1) + self.assertTrue( + _deye_should_skip_time_sync_after_read(_inv(sync_at=recent), r62, r63, r64) + ) + self.assertGreater(DEYE_CLOCK_DRIFT_OK_SEC, 5) + + +if __name__ == "__main__": + unittest.main() diff --git a/db/migration/V030__deye_clock_sync_at.sql b/db/migration/V030__deye_clock_sync_at.sql new file mode 100644 index 0000000..df5364b --- /dev/null +++ b/db/migration/V030__deye_clock_sync_at.sql @@ -0,0 +1,10 @@ +-- Čas posledního zápisu nebo „drift OK“ kontroly hodin Deye (62–64); periodický re-sync i při malém driftu + +ALTER TABLE ems.asset_inverter + ADD COLUMN IF NOT EXISTS deye_last_system_time_sync_at TIMESTAMPTZ; + +COMMENT ON COLUMN ems.asset_inverter.deye_last_system_time_sync_at IS +'Okamžik posledního úspěšného zápisu 62–64 (FC 0x10). Slouží k periodickému re-syncu každých 24h i při malém driftu; při přeskočení zápisu (malý drift) se sloupec nemění.'; + +COMMENT ON COLUMN ems.asset_inverter.deye_last_system_time_sync_minute IS +'UTC začátek pražské minuty posledního úspěšného zápisu 62–64. Doplňuje se při zápisu času; rozhodování o řidším syncu je v aplikaci (drift + deye_last_system_time_sync_at).'; diff --git a/docs/04-modules/modbus-command-journal.md b/docs/04-modules/modbus-command-journal.md index ed552e9..4f26c91 100644 --- a/docs/04-modules/modbus-command-journal.md +++ b/docs/04-modules/modbus-command-journal.md @@ -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`. +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`. ## Schéma `ems.modbus_command` @@ -30,7 +30,7 @@ Implementace: `services/control_exporter.py` — `verify_modbus_commands`, `_swi ## Střídač (Deye) -`write_inverter_setpoints` přidá do journalu podle potřeby **62–64** (čas — ne každý export) 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`. **Dávky:** `execute_modbus_commands` slučuje souvislé adresy do jednoho **`write_registers`** (FC **0x10**). `verify_modbus_commands` čte zpět po souvislých blocích (`read_holding_registers`, FC 0x03). Detail režimů: `modbus-registers.md`. @@ -56,6 +56,6 @@ Tabulka pro budoucí logování **cut-off** přepínačů (mikroinvertory / GEN ## Související soubory -- Migrace: `db/migration/V023__modbus_command_journal.sql`, `V025__deye_physical_mode.sql` +- Migrace: `db/migration/V023__modbus_command_journal.sql`, `V025__deye_physical_mode.sql`, `V030__deye_clock_sync_at.sql` - Backend: `backend/services/control_exporter.py`, `backend/services/modbus_client.py`, `backend/services/notification_service.py`, `backend/app/main.py` - Registry Deye: `docs/04-modules/modbus-registers.md` diff --git a/docs/04-modules/modbus-registers.md b/docs/04-modules/modbus-registers.md index 47395e5..60fd29a 100644 --- a/docs/04-modules/modbus-registers.md +++ b/docs/04-modules/modbus-registers.md @@ -94,15 +94,19 @@ Bloky 3–6 používají čas **2355** a stejnou **SOC** hodnotu jako PASSIVE (` ### Synchronizace času -Registry **62–64** nastavují invertoru čas v **Europe/Prague**. **EMS je do fronty nezařadí**, pokud ještě neuplynula **nová kalendářní minuta** oproti poslednímu úspěšnému zápisu (sloupec `asset_inverter.deye_last_system_time_sync_minute`). Jinak platí: +Registry **62–64** nastavují invertoru čas v **Europe/Prague**. - reg **62:** `(rok - 2000) << 8 | měsíc` - reg **63:** `den << 8 | hodina` -- reg **64:** `minuta << 8 | sekunda` +- 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 zápisu** 62–64 neuplynulo **24 h** (`asset_inverter.deye_last_system_time_sync_at`, mění se jen při zápisu). Je-li sloupec NULL (např. první provoz), zápis času se **nevynechá**. Při selhání čtení se čas **zapíše** (bezpečný fallback). Sloupec `deye_last_system_time_sync_minute` doplňuje začátek pražské minuty u **úspěšného zápisu** 62–64. Zápis prochází journal jako každý jiný registr; na sběrnici jde souvislý blok **FC 0x10**. -**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 (nová minuta u 62–64, denní TOU 3–6 + meta sloupce na `asset_inverter`). +**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`). + +**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`). ### Mapování registrů (time point *i*, i = 0…5)