Uprava aktualizace casu ve stridaci - mene casto, akceptujeme az 120s drift, zapisujeme presto 1x denne
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user