implmemtace cuttoff genportu
Some checks failed
CI and deploy / migration-check (push) Failing after 9s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-04-20 10:41:10 +02:00
parent d8dbb284fd
commit b8515f30df
15 changed files with 265 additions and 5 deletions

View File

@@ -44,6 +44,14 @@ 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
# Reg 179 Control board special 1: bits 01 ovládají MI export cutoff (AC coupling / GEN).
REG179_MI_EXPORT_MASK = 0x0003
REG179_MI_EXPORT_DISABLE = 0b10
REG179_MI_EXPORT_ENABLE = 0b11
def _deye_reg179_verify_match(expected_i: int, actual_i: int) -> bool:
return (int(expected_i) & REG179_MI_EXPORT_MASK) == (int(actual_i) & REG179_MI_EXPORT_MASK)
# 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})
@@ -113,6 +121,7 @@ DEYE_REGISTER_NAMES: dict[int, str] = {
143: "export_limit_w (max export do sítě)",
145: "solar_sell (0=disabled, 1=enabled)",
178: "grid_peak_shaving_switch (SELL=32 bit4-5=10, PASSIVE/CHARGE=48 bit4-5=11)",
179: "control_board_special_1 (bits0-1: MI export cutoff disable=2 enable=3)",
148: "time_point_1_time",
149: "time_point_2_time",
154: "time_point_1_power_w",
@@ -192,6 +201,7 @@ class InverterConfig:
deye_last_tou_inactive_write_prague_date: date | None = None
deye_tou_inactive_signature: str | None = None
deye_zero_export_mode: int = 1
deye_gen_microinverter_cutoff_enabled: bool = False
def _prague_minute_start_utc() -> datetime:
@@ -342,6 +352,11 @@ class ControlSetpoints:
target_soc_pct: int | None = None
#: Explicitní fyzický režim z plánu (PASSIVE/SELL/CHARGE). Pokud je vyplněn, má přednost před detekcí ze znamének.
deye_physical_mode: str | None = None
#: True = zákaz exportu (BLOCK_EXPORT) pro daný slot: např. při efektivní vykupní ceně < 0.
export_ban: bool = False
#: True = odpojit GEN port (MI export cutoff) v tomto slotu dle plánu (reg 179 bits0-1).
#: None/False = neodpojovat.
deye_gen_cutoff_enabled: bool = False
#: Efektivní vykupní cena slotu (Kč/kWh z plánu); pro TOU řízení priorit baterie vs. přetok
effective_sell_price_czk_kwh: float | None = None
#: True = reg 108/109 na 0 (PRESERVE Deye baterii nepoužívá)
@@ -798,6 +813,8 @@ async def verify_modbus_commands(
first_178,
second_178,
)
if reg == 179:
matches = _deye_reg179_verify_match(expected_i, actual_i)
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)
@@ -821,7 +838,11 @@ async def verify_modbus_commands(
reg,
expected_i,
actual_i,
" (reg178 mask 0x%04X)" % REG178_VERIFY_MASK if reg == 178 else "",
(
" (reg178 mask 0x%04X)" % REG178_VERIFY_MASK
if reg == 178
else (" (reg179 mask 0x%04X)" % REG179_MI_EXPORT_MASK if reg == 179 else "")
),
)
row_ac = await db.fetchrow(
"SELECT attempt_count FROM ems.modbus_command WHERE id=$1", cmd_id
@@ -1047,6 +1068,7 @@ async def _load_inverter_config(
ai.deye_last_tou_inactive_write_prague_date,
ai.deye_tou_inactive_signature,
COALESCE(ai.deye_zero_export_mode, 1) AS deye_zero_export_mode,
COALESCE(ai.deye_gen_microinverter_cutoff_enabled, false) AS deye_gen_microinverter_cutoff_enabled,
COALESCE(
ai.deye_register_max_charge_a,
FLOOR(
@@ -1130,6 +1152,7 @@ async def _load_inverter_config(
],
deye_tou_inactive_signature=row["deye_tou_inactive_signature"],
deye_zero_export_mode=int(row["deye_zero_export_mode"]),
deye_gen_microinverter_cutoff_enabled=bool(row["deye_gen_microinverter_cutoff_enabled"] or False),
)
@@ -1226,6 +1249,9 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
pm: str | None = str(pm_raw).strip().upper() if pm_raw is not None else None
sell_raw = pi.get("effective_sell_price")
sell_f: float | None = float(sell_raw) if sell_raw is not None else None
export_ban = sell_f is not None and float(sell_f) < 0
gen_cutoff_raw = pi.get("deye_gen_cutoff_enabled")
gen_cutoff = bool(gen_cutoff_raw) if gen_cutoff_raw is not None else False
return ControlSetpoints(
battery_w=int(pi["battery_setpoint_w"] or 0),
grid_export_limit=abs(min(grid_sp, 0)),
@@ -1237,6 +1263,8 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
ev2_power_w=ev2_w,
target_soc_pct=target_soc,
deye_physical_mode=pm,
export_ban=bool(export_ban),
deye_gen_cutoff_enabled=bool(gen_cutoff),
effective_sell_price_czk_kwh=sell_f,
)
@@ -1511,7 +1539,7 @@ async def write_inverter_setpoints(
zero_exp_mode = int(inv.deye_zero_export_mode or 1)
selling_mode = 0 if deye_mode == "SELL" else zero_exp_mode
solar_sell = 1
solar_sell = 0 if (setpoints_now.export_ban and deye_mode != "SELL") else 1
export_limit = export_lim
if deye_mode == "SELL" and grid_w < 0:
export_limit = min(export_lim, max(REG143_SELL_CAP_MIN_W, abs(grid_w)))
@@ -1595,6 +1623,35 @@ async def write_inverter_setpoints(
]
)
if inv.deye_gen_microinverter_cutoff_enabled:
want_cutoff = bool(setpoints_now.deye_gen_cutoff_enabled) and deye_mode != "SELL"
target_bits = (
REG179_MI_EXPORT_DISABLE if want_cutoff else REG179_MI_EXPORT_ENABLE
)
try:
mb179 = await get_modbus_client(inv.host, inv.port)
r179 = await mb179.read_holding_registers(179, 1, unit)
if r179 and len(r179) >= 1:
current_179 = int(r179[0])
new_179 = (current_179 & ~REG179_MI_EXPORT_MASK) | int(target_bits)
registers.append((179, "control_board_special_1", new_179))
logger.info(
"[control] %s: reg179 MI cutoff %s (old=%s new=%s mask=0x%04X)",
inv.code,
"ON" if want_cutoff else "OFF",
current_179,
new_179,
REG179_MI_EXPORT_MASK,
)
else:
logger.warning(
"[control] %s: reg179 read returned %s values, skip cutoff write",
inv.code,
len(r179) if r179 is not None else None,
)
except Exception as e:
logger.warning("[control] %s: reg179 cutoff RMW failed: %s", inv.code, e)
logger.info(
"[control] %s: deye_mode=%s charge=%sA discharge=%sA "
"reg142=%s reg145=%s export=%sW "