fix rs485 s eror self_sustain
All checks were successful
CI and deploy / migration-check (push) Successful in 6s
CI and deploy / deploy (push) Successful in 29s

This commit is contained in:
Dusan Vojacek
2026-04-19 15:29:58 +02:00
parent efc2cbfded
commit f8e1eed127
6 changed files with 179 additions and 22 deletions

View File

@@ -40,10 +40,50 @@ DEYE_TOU_SOC_PASSIVE_BATTERY_PRIORITY_PCT = 100
# Verify: jen bity 45 (horní byte layout v dokumentaci); ostatní bity mohou mít firmware / Loxone
REG178_VERIFY_MASK = 0x0030
# Po 3 neúspěšných verify pokusech → SELF_SUSTAIN jen u těchto registrech (bezpečnost / export).
# 6264 řeší toleranční bundle (nemění režim). 178 a TOU power W jsou „soft“ — jen log + Discord.
DEYE_CRITICAL_REGS_SELF_SUSTAIN = frozenset({108, 109, 142, 143, 145})
# Výkonové řádky TOU (154 + slot_index 0…5) — firmware často přepíše na max W z max_charge/max_discharge A.
DEYE_TOU_POWER_REGS = frozenset(range(154, 160))
# Deye LV: firmware často odmítne 351 A a drží 350 — horní strop pro zápis z DB.
DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A = 350
def _deye_reg178_verify_match(expected_i: int, actual_i: int) -> bool:
return (int(expected_i) & REG178_VERIFY_MASK) == (int(actual_i) & REG178_VERIFY_MASK)
def deye_reg_triggers_self_sustain_after_verify_exhaust(reg: int) -> bool:
"""True = po 3× mismatch přepnout lokalitu do SELF_SUSTAIN (kritický registr)."""
return int(reg) in DEYE_CRITICAL_REGS_SELF_SUSTAIN
def _deye_tou_power_verify_match(
expected_i: int, actual_i: int, inv: InverterConfig
) -> bool:
"""Firmware často clampne TOU power W na max z reg. 108/109 × 51.2 V — akceptovat jako OK."""
if int(actual_i) == int(expected_i):
return True
# 51.2 V — nesmí int(BATT_VOLTAGE_V)==51 (off-by-one vs. firmware 17920 W @ 350 A)
max_w_charge = int(inv.max_charge_a * BATT_VOLTAGE_V)
max_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V)
a = int(actual_i)
return a == max_w_charge or a == max_w_discharge
def _deye_reg178_verify_with_double_read(
expected_i: int, actual_first: int, actual_second: int | None
) -> tuple[bool, int]:
"""
Vrátí (shoda, hodnota_pro_journal).
Druhé čtení použít jen když první neprojde maskou (RS485 / glitch).
"""
if _deye_reg178_verify_match(expected_i, actual_first):
return True, actual_first
if actual_second is not None and _deye_reg178_verify_match(expected_i, actual_second):
return True, int(actual_second)
return False, actual_first
# Neaktivní TOU bloky (36): „konec dne“ — Deye často 23:59 (2359) neuloží a vrátí např. 2355,
# verify pak hlásí mismatch. 23:55 je na zařízeních stabilní (viz HHMM jako desítkové číslo).
DEYE_TOU_INACTIVE_HHMM = 2355
@@ -679,10 +719,13 @@ async def verify_modbus_commands(
) -> bool:
"""
Přečte registry zpět (FC 3 po souvislých blocích) a porovná s value_to_write.
Při mismatch: retry → SELF_SUSTAIN + Discord.
Při mismatch: retry (až 3×). Po vyčerpání pokusů u kritických registrů (108, 109, 142, 143, 145)
→ SELF_SUSTAIN + Discord; u „soft“ (178, TOU power W) jen log + Discord, režim se nemění.
"""
from services.notification_service import notify_modbus_mismatch
inv_cfg = await _load_inverter_config(site_id, db)
async def _apply_verify_result(
cmd: asyncpg.Record,
actual_i: int,
@@ -732,7 +775,33 @@ async def verify_modbus_commands(
expected_i = int(cmd["value_to_write"])
matches = actual_i == expected_i
if reg == 178:
matches = _deye_reg178_verify_match(expected_i, actual_i)
first_178 = int(actual_i)
second_178: int | None = None
if not _deye_reg178_verify_match(expected_i, first_178):
try:
r178 = await client.read_holding_registers(178, 1, unit)
if r178 and len(r178) >= 1:
second_178 = int(r178[0])
except Exception as e:
logger.warning(
"[cmd %s] reg178 double-read failed: %s", cmd_id, e
)
matches, actual_i = _deye_reg178_verify_with_double_read(
expected_i, first_178, second_178
)
if (
matches
and second_178 is not None
and not _deye_reg178_verify_match(expected_i, first_178)
):
logger.info(
"[cmd %s] reg178 double-read recovered: first=%s second=%s",
cmd_id,
first_178,
second_178,
)
if not matches and reg in DEYE_TOU_POWER_REGS and inv_cfg is not None:
matches = _deye_tou_power_verify_match(expected_i, actual_i, inv_cfg)
await db.execute(
"""
@@ -777,18 +846,27 @@ async def verify_modbus_commands(
await execute_modbus_commands([cmd_id], db)
await verify_modbus_commands([cmd_id], db, site_id)
else:
logger.critical(
"[cmd %s] 3 failed attempts, switching to SELF_SUSTAIN",
cmd_id,
)
await _switch_to_self_sustain(
site_id,
db,
reason=(
f"Modbus mismatch po 3 pokusech: {cmd['asset_code']} "
f"reg 0x{reg:04X}"
),
)
if deye_reg_triggers_self_sustain_after_verify_exhaust(reg):
logger.critical(
"[cmd %s] 3 failed attempts, switching to SELF_SUSTAIN",
cmd_id,
)
await _switch_to_self_sustain(
site_id,
db,
reason=(
f"Modbus mismatch po 3 pokusech: {cmd['asset_code']} "
f"reg 0x{reg:04X}"
),
)
else:
logger.warning(
"[cmd %s] 3 failed verify attempts on non-critical reg 0x%04X "
"(no mode change): %s",
cmd_id,
reg,
cmd["asset_code"],
)
return False
if reg == 178 and actual_i != expected_i:
@@ -1009,6 +1087,9 @@ async def _load_inverter_config(
md = row["max_discharge_a"]
max_charge_a = int(mc) if mc is not None else 0
max_discharge_a = int(md) if md is not None else 0
# Firmware Deye často drží max 350 A — vyšší hodnota z DB → mismatch 351 vs 350.
max_charge_a = min(max_charge_a, DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A)
max_discharge_a = min(max_discharge_a, DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A)
port = int(row["port"] or 502)
uid = int(row["unit_id"] if row["unit_id"] is not None else 1)
return InverterConfig(

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import logging
from datetime import datetime
@@ -40,6 +41,32 @@ async def notify_operating_mode_changed(
await send_discord(msg, level=lvl)
async def _auto_rolling_replan_after_self_sustain_exit(site_id: int) -> None:
"""Po návratu z SELF_SUSTAIN do AUTO přepočítat rolling plán (nové DB spojení)."""
try:
from app.deps import get_pg_pool
from services.planning_engine import run_plan_api
pool = await get_pg_pool()
except Exception as e:
logger.warning("Auto replan after SELF_SUSTAIN→AUTO: pool unavailable: %s", e)
return
try:
async with pool.acquire() as replan_conn:
await run_plan_api(
site_id,
"rolling",
replan_conn,
triggered_by="mode:self_sustain_exit",
)
except Exception as e:
logger.warning(
"Auto rolling replan after SELF_SUSTAIN→AUTO failed: %s",
e,
exc_info=True,
)
async def run_fn_set_mode_with_discord(
conn: asyncpg.Connection,
site_id: int,
@@ -84,6 +111,15 @@ async def run_fn_set_mode_with_discord(
notes,
level=notify_level,
)
prev_u = str(prev).upper()
new_u = str(new).upper()
if prev_u == "SELF_SUSTAIN" and new_u == "AUTO":
try:
asyncio.get_running_loop().create_task(
_auto_rolling_replan_after_self_sustain_exit(site_id)
)
except RuntimeError:
logger.debug("No event loop; skip auto rolling replan")
return str(new)

View File

@@ -3,11 +3,15 @@
from __future__ import annotations
import unittest
from dataclasses import replace
from services.control_exporter import (
ControlSetpoints,
InverterConfig,
_deye_reg178_verify_with_double_read,
_deye_tou_params,
_deye_tou_power_verify_match,
deye_reg_triggers_self_sustain_after_verify_exhaust,
get_deye_mode,
)
@@ -33,6 +37,29 @@ def _inv(*, min_soc: int | None = 12, reserve_soc: int | None = 20) -> InverterC
)
def _inv_350a() -> InverterConfig:
"""350 A × 51.2 V = 17920 W — typický firmware clamp pro TOU power."""
return replace(_inv(), max_charge_a=350, max_discharge_a=350)
class ModbusVerifyPolicyTests(unittest.TestCase):
def test_tou_power_accepts_firmware_max_w_clamp(self) -> None:
inv = _inv_350a()
self.assertTrue(_deye_tou_power_verify_match(7752, 17920, inv))
self.assertTrue(_deye_tou_power_verify_match(16728, 17920, inv))
def test_reg178_double_read_recovers_from_glitch(self) -> None:
ok, v = _deye_reg178_verify_with_double_read(48, 12014, 48)
self.assertTrue(ok)
self.assertEqual(v, 48)
def test_reg178_not_critical_for_self_sustain(self) -> None:
self.assertFalse(deye_reg_triggers_self_sustain_after_verify_exhaust(178))
def test_reg108_critical_for_self_sustain(self) -> None:
self.assertTrue(deye_reg_triggers_self_sustain_after_verify_exhaust(108))
class DeyeTouParamsTests(unittest.TestCase):
def test_sell_uses_reserve_soc(self) -> None:
sp = ControlSetpoints(