Uprava aktualizace casu ve stridaci - mene casto, akceptujeme az 120s drift, zapisujeme presto 1x denne

This commit is contained in:
Dusan Vojacek
2026-04-03 22:21:55 +02:00
parent 99721ff184
commit bbb5e63d1d
6 changed files with 367 additions and 22 deletions

View File

@@ -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ů 60499:** 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 **36** 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 **36** = `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 6264:** 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ů 60499:** 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 **36** 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 **36** = `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 6264:** před zařazením do fronty **čtení** 6264; 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 6264 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 1112 %, migrace V029 + komentář sloupce).

View File

@@ -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 6264: 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 6264 (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 6264 (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 6264: 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 6264: 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 6264: 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 (6264 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 6264 (system time)"
),
)
if site:
await notify_self_sustain_activated(
site["code"],
(
f"Modbus mismatch: {cmd0['asset_code']} "
f"regs 6264 (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 6264 (Europe/Prague)."""
now = datetime.now(ZoneInfo("Europe/Prague"))
"""Hodnoty pro reg 6264 (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 6264",
len(tvals),
)
except Exception as e:
logger.warning("Deye clock read failed (will sync 6264): %s", e)
if skip_time:
logger.info(
"Deye clock 6264 skipped (same Prague minute as last sync): %s CET",
"Deye clock 6264 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,

View File

@@ -0,0 +1,125 @@
"""Deye systémový čas 6264: 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()

View File

@@ -0,0 +1,10 @@
-- Čas posledního zápisu nebo „drift OK“ kontroly hodin Deye (6264); 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 6264 (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 6264. Doplňuje se při zápisu času; rozhodování o řidším syncu je v aplikaci (drift + deye_last_system_time_sync_at).';

View File

@@ -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 **6264** (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 **6264** (čas — ne každý export) a **time pointy 148177** (bloky 36 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 **6264** (čas — po čtení z invertoru jen při driftu / 24h intervalu; viz `modbus-registers.md`) a **time pointy 148177** (bloky 36 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`

View File

@@ -94,15 +94,19 @@ Bloky 36 používají čas **2355** a stejnou **SOC** hodnotu jako PASSIVE (`
### Synchronizace času
Registry **6264** 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 **6264** 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** 6264 (FC 0x03). Do journalu **6264 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** 6264 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** 6264.
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 6264, denní TOU 36 + meta sloupce na `asset_inverter`).
**Verifikace (journal):** u souvislého bloku **6264** 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ší 6264 výše, denní TOU 36 + meta sloupce na `asset_inverter`).
### Mapování registrů (time point *i*, i = 0…5)