refactor-control-monolith #2

Merged
vojacekd merged 9 commits from refactor-control-monolith into main 2026-05-02 20:14:54 +02:00
3 changed files with 345 additions and 302 deletions
Showing only changes of commit 6d6341cde8 - Show all commits

View File

@@ -0,0 +1,236 @@
"""Čisté Deye konstanty a helpery pro control export."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from services.control.models import InverterConfig
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.
BATT_VOLTAGE_V = 51.2
# Reg 143 ve SELL: min(|grid_setpoint_w|, ...) nesmí klesnout pod tuto podlahu (W).
REG143_SELL_CAP_MIN_W = 200
# Reg 178 - bitové pole: bity 4-5 (peak shaving switch) a bity 0-1 (MI export cutoff).
REG178_SELL = 0b00100000
REG178_PASSIVE = 0b00110000
REG178_VERIFY_MASK = 0x0030
REG178_MI_EXPORT_MASK = 0x0003
REG178_MI_EXPORT_DISABLE = 0b10
REG178_MI_EXPORT_ENABLE = 0b11
REG178_VERIFY_MASK_COMBINED = REG178_VERIFY_MASK | REG178_MI_EXPORT_MASK
DEYE_CRITICAL_REGS_SELF_SUSTAIN = frozenset({108, 109, 142, 143, 145})
DEYE_TOU_POWER_REGS = frozenset(range(154, 160))
DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A = 350
# Neaktivní TOU bloky (3-6): Deye často 23:59 (2359) neuloží, 23:55 je stabilní.
DEYE_TOU_INACTIVE_HHMM = 2355
_DEYE_INACTIVE_TOU_REGISTERS: frozenset[int] = frozenset(
[
150,
151,
152,
153,
156,
157,
158,
159,
168,
169,
170,
171,
174,
175,
176,
177,
]
)
DEYE_CLOCK_REGS: frozenset[int] = frozenset({62, 63, 64})
DEYE_REGISTER_NAMES: dict[int, str] = {
108: "max_charge_a (max nabíjecí proud baterie)",
109: "max_discharge_a (max vybíjecí proud baterie)",
141: "energy_mode (0, EMS nemění)",
142: "limit_control (0=selling first, 1=zero export to load, 2=zero export to CT)",
143: "export_limit_w (max export do sítě)",
145: "solar_sell (0=disabled, 1=enabled)",
340: "max_solar_power_w (strop DC PV A v W; součet nominal_power_wp řiditelných polí)",
178: "control_board_special_1 (bits0-1: MI export cutoff disable=2 enable=3; bits4-5 peak shaving 32/48)",
148: "time_point_1_time",
149: "time_point_2_time",
154: "time_point_1_power_w",
155: "time_point_2_power_w",
166: "time_point_1_soc_min_pct",
167: "time_point_2_soc_min_pct",
172: "time_point_1_grid_charge",
173: "time_point_2_grid_charge",
62: "system_time_year_month",
63: "system_time_day_hour",
64: "system_time_min_sec",
}
for _tp_i in range(6):
_n = _tp_i + 1
DEYE_REGISTER_NAMES.setdefault(148 + _tp_i, f"time_point_{_n}_time")
DEYE_REGISTER_NAMES.setdefault(154 + _tp_i, f"time_point_{_n}_power_w")
DEYE_REGISTER_NAMES.setdefault(166 + _tp_i, f"time_point_{_n}_soc_min_pct")
DEYE_REGISTER_NAMES.setdefault(172 + _tp_i, f"time_point_{_n}_grid_charge")
def _deye_reg178_verify_match(expected_i: int, actual_i: int) -> bool:
return (int(expected_i) & REG178_VERIFY_MASK_COMBINED) == (
int(actual_i) & REG178_VERIFY_MASK_COMBINED
)
def deye_reg_triggers_self_sustain_after_verify_exhaust(reg: int) -> bool:
"""True = po 3x 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 x 51.2 V."""
if int(actual_i) == int(expected_i):
return True
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
def watts_to_amps(power_w: int | None, phases: int = 3, voltage: int = 230) -> int:
if not power_w or power_w <= 0:
return 0
return min(32, max(0, int(power_w / (phases * voltage))))
def battery_watts_to_amps(power_w: int, max_amps: int) -> int:
"""Proud z |výkonu| baterie; max_amps z DB."""
derived = int(abs(power_w) / BATT_VOLTAGE_V)
return min(max(0, max_amps), max(0, derived))
def current_slot_hhmm() -> int:
"""Začátek probíhajícího 15min slotu v Europe/Prague, formát HHMM."""
now = datetime.now(PRAGUE_TZ)
slot_min = (now.minute // 15) * 15
return now.hour * 100 + slot_min
def next_slot_hhmm() -> int:
"""Začátek příštího 15min slotu v Europe/Prague, formát HHMM."""
now = datetime.now(PRAGUE_TZ)
minutes = now.minute
slot_minutes = ((minutes // 15) + 1) * 15
if slot_minutes >= 60:
next_hour = (now.hour + 1) % 24
next_min = 0
else:
next_hour = now.hour
next_min = slot_minutes
return next_hour * 100 + next_min
def compute_pv_a_reg340_max_solar_w(cap_w: int, forecast_w: int, curtail_w: int) -> int:
"""Hodnota pro Deye reg 340 (max solar power, W) z capu a plánovaného curtailmentu pole A."""
if curtail_w <= 0:
return int(cap_w)
return max(0, min(int(cap_w), int(forecast_w) - int(curtail_w)))
def _prague_minute_start_utc() -> datetime:
"""UTC okamžik odpovídající začátku aktuální kalendářní minuty v Europe/Prague."""
p = datetime.now(PRAGUE_TZ).replace(second=0, microsecond=0)
return p.astimezone(timezone.utc)
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
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
nebo tolerančního ověření neuplynulo 24h.
"""
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

View File

@@ -7,281 +7,53 @@ import json
import logging import logging
import os import os
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass
from typing import Any from typing import Any
from datetime import date, datetime, timedelta, timezone from datetime import datetime, timezone
from zoneinfo import ZoneInfo
import asyncpg import asyncpg
import httpx import httpx
from app.config import get_settings from app.config import get_settings
from services.control.deye_helpers import (
BATT_VOLTAGE_V,
DEYE_CLOCK_DRIFT_OK_SEC,
DEYE_CLOCK_REGS,
DEYE_CLOCK_RESYNC_INTERVAL_HOURS,
DEYE_CLOCK_VERIFY_MAX_DELTA_SEC, # noqa: F401 - re-export for compatibility
DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A,
DEYE_REGISTER_NAMES,
DEYE_TOU_INACTIVE_HHMM,
DEYE_TOU_POWER_REGS,
PRAGUE_TZ,
REG143_SELL_CAP_MIN_W,
REG178_MI_EXPORT_DISABLE,
REG178_MI_EXPORT_ENABLE,
REG178_MI_EXPORT_MASK,
REG178_PASSIVE,
REG178_SELL,
REG178_VERIFY_MASK,
REG178_VERIFY_MASK_COMBINED,
_DEYE_INACTIVE_TOU_REGISTERS,
_deye_clock_registers_verify_match,
_deye_reg178_verify_match,
_deye_reg178_verify_with_double_read,
_deye_registers_to_prague_datetime, # noqa: F401 - re-export for compatibility
_deye_should_skip_time_sync_after_read,
_deye_tou_power_verify_match,
_prague_minute_start_utc,
battery_watts_to_amps,
compute_pv_a_reg340_max_solar_w,
current_slot_hhmm,
deye_reg_triggers_self_sustain_after_verify_exhaust, # noqa: F401 - re-export
next_slot_hhmm,
watts_to_amps,
)
from services.control.models import ControlSetpoints, InverterConfig, OperatingModeInfo
from services.modbus_client import get_modbus_client from services.modbus_client import get_modbus_client
from services.signal_service import enqueue_site_signals from services.signal_service import enqueue_site_signals
logger = logging.getLogger(__name__) 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
# Reg 143 ve SELL: min(|grid_setpoint_w|, …) nesmí klesnout pod tuto podlahu (W) — kvůli chování firmware, ne mapování režimu.
REG143_SELL_CAP_MIN_W = 200
# Reg 178 bitové pole: používáme bity 45 (peak shaving switch) a bity 01 (MI export cutoff).
# Ostatní bity zachovat → read-modify-write.
REG178_SELL = 0b00100000 # 32, grid peak shaving disable
REG178_PASSIVE = 0b00110000 # 48, grid peak shaving enable (PASSIVE i CHARGE)
# Verify: jen bity 45 (horní byte layout v dokumentaci); ostatní bity mohou mít firmware / Loxone
REG178_VERIFY_MASK = 0x0030
# Reg 178 bits 01: MI export cutoff (AC coupling / GEN).
REG178_MI_EXPORT_MASK = 0x0003
REG178_MI_EXPORT_DISABLE = 0b10
REG178_MI_EXPORT_ENABLE = 0b11
REG178_VERIFY_MASK_COMBINED = REG178_VERIFY_MASK | REG178_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})
# 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_COMBINED) == (
int(actual_i) & REG178_VERIFY_MASK_COMBINED
)
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
# Registry TOU řádků 36 (slot index 2…5): 150153, 156159, … — pro detekci skutečného zápisu po filtru „unchanged“.
_DEYE_INACTIVE_TOU_REGISTERS: frozenset[int] = frozenset(
[
150, 151, 152, 153,
156, 157, 158, 159,
168, 169, 170, 171,
174, 175, 176, 177,
]
)
# Systémový čas Deye — vždy toleranční verify jako celek 6264 (reg 64 sám nesmí do striktní větve).
DEYE_CLOCK_REGS: frozenset[int] = frozenset({62, 63, 64})
DEYE_REGISTER_NAMES: dict[int, str] = {
108: "max_charge_a (max nabíjecí proud baterie)",
109: "max_discharge_a (max vybíjecí proud baterie)",
141: "energy_mode (0, EMS nemění)",
142: "limit_control (0=selling first, 1=zero export to load, 2=zero export to CT)",
143: "export_limit_w (max export do sítě)",
145: "solar_sell (0=disabled, 1=enabled)",
340: "max_solar_power_w (strop DC PV A v W; součet nominal_power_wp řiditelných polí)",
178: "control_board_special_1 (bits0-1: MI export cutoff disable=2 enable=3; bits4-5 peak shaving 32/48)",
148: "time_point_1_time",
149: "time_point_2_time",
154: "time_point_1_power_w",
155: "time_point_2_power_w",
166: "time_point_1_soc_min_pct",
167: "time_point_2_soc_min_pct",
172: "time_point_1_grid_charge",
173: "time_point_2_grid_charge",
62: "system_time_year_month",
63: "system_time_day_hour",
64: "system_time_min_sec",
}
for _tp_i in range(6):
_n = _tp_i + 1
DEYE_REGISTER_NAMES.setdefault(148 + _tp_i, f"time_point_{_n}_time")
DEYE_REGISTER_NAMES.setdefault(154 + _tp_i, f"time_point_{_n}_power_w")
DEYE_REGISTER_NAMES.setdefault(166 + _tp_i, f"time_point_{_n}_soc_min_pct")
DEYE_REGISTER_NAMES.setdefault(172 + _tp_i, f"time_point_{_n}_grid_charge")
def watts_to_amps(power_w: int | None, phases: int = 3, voltage: int = 230) -> int:
if not power_w or power_w <= 0:
return 0
return min(32, max(0, int(power_w / (phases * voltage))))
def battery_watts_to_amps(power_w: int, max_amps: int) -> int:
"""Proud z |výkonu| baterie; max_amps z DB (už COALESCE se stropy v SQL).
int(|W|/51.2) — u kladných hodnot stejné jako floor bez importu math.
"""
derived = int(abs(power_w) / BATT_VOLTAGE_V)
return min(max(0, max_amps), max(0, derived))
def current_slot_hhmm() -> int:
"""Začátek probíhajícího 15min slotu v Europe/Prague, formát HHMM (např. 1415)."""
now = datetime.now(ZoneInfo("Europe/Prague"))
slot_min = (now.minute // 15) * 15
return now.hour * 100 + slot_min
def next_slot_hhmm() -> int:
"""Začátek příštího 15min slotu v Europe/Prague, formát HHMM (např. 1430)."""
now = datetime.now(ZoneInfo("Europe/Prague"))
minutes = now.minute
slot_minutes = ((minutes // 15) + 1) * 15
if slot_minutes >= 60:
next_hour = (now.hour + 1) % 24
next_min = 0
else:
next_hour = now.hour
next_min = slot_minutes
return next_hour * 100 + next_min
@dataclass
class InverterConfig:
id: int
code: str
host: str
port: int
unit_id: int
max_export_power_w: int | None
max_import_power_w: int | None
no_export: bool
max_battery_charge_w: int | None
max_battery_discharge_w: int | None
min_soc_percent: int | None
reserve_soc_percent: int | None
max_soc_percent: int | None
usable_capacity_wh: int | None
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
deye_zero_export_mode: int = 1
deye_gen_microinverter_cutoff_enabled: bool = False
#: Součet nominal_power_wp controllable PV na invertoru; 0 = EMS nezapisuje reg 340.
pv_a_cap_w: int = 0
#: True = EMS smí řídit Deye reg 340 (max solar power); z SQL `fn_site_has_active_green_bonus_pv(site_id)` — není DB sloupec na invertoru.
deye_reg340_pv_a_control_enabled: bool = False
def compute_pv_a_reg340_max_solar_w(cap_w: int, forecast_w: int, curtail_w: int) -> int:
"""Hodnota pro Deye reg 340 (max solar power, W) z capu a plánovaného curtailmentu pole A."""
if curtail_w <= 0:
return int(cap_w)
return max(0, min(int(cap_w), int(forecast_w) - int(curtail_w)))
def _prague_minute_start_utc() -> datetime:
"""UTC okamžik odpovídající začátku aktuální kalendářní minuty v Europe/Prague."""
p = datetime.now(PRAGUE_TZ).replace(second=0, microsecond=0)
return p.astimezone(timezone.utc)
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
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 (FC 0x10 ACK)
nebo tolerančního ověření neuplynulo 24h — sloupec deye_last_system_time_sync_at doplňuje
write_inverter_setpoints po úspěšném zápisu batche obsahujícího 6264 a znovu po úspěšném verify.
"""
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
async def _fetch_written_deye_clock_commands( async def _fetch_written_deye_clock_commands(
site_id: int, site_id: int,
@@ -353,44 +125,6 @@ def _drop_registers_matching_last_verified(
return out, skipped return out, skipped
@dataclass
class ControlSetpoints:
battery_w: int | None
grid_export_limit: int
ev1_current_a: int
ev2_current_a: int
heat_pump_enable: bool
grid_setpoint_w: int
ev1_power_w: int
ev2_power_w: int
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 178 bits0-1, 0-based).
#: 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á)
lock_battery: bool = False
#: Režim SELF_SUSTAIN: plný rozsah nabíjení/vybíjení na invertoru + zero-export (reg 142) a nízké TOU %.
self_sustain_local_use: bool = False
#: Deye reg 340 (max solar power, W). None = EMS reg 340 v tomto ticku neřeší (PRESERVE/SELF_SUSTAIN/CHARGE_CHEAP/…).
pv_a_allowed_w: int | None = None
@dataclass
class OperatingModeInfo:
mode_code: str
battery_mode: str
grid_mode: str
ev_enabled: bool
heat_pump_enabled_def: bool
loxone_mode_value: int
async def create_modbus_commands( async def create_modbus_commands(
site_id: int, site_id: int,
planning_run_id: int | None, planning_run_id: int | None,

View File

@@ -0,0 +1,73 @@
"""Datové modely pro control export."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime
@dataclass
class InverterConfig:
id: int
code: str
host: str
port: int
unit_id: int
max_export_power_w: int | None
max_import_power_w: int | None
no_export: bool
max_battery_charge_w: int | None
max_battery_discharge_w: int | None
min_soc_percent: int | None
reserve_soc_percent: int | None
max_soc_percent: int | None
usable_capacity_wh: int | None
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
deye_zero_export_mode: int = 1
deye_gen_microinverter_cutoff_enabled: bool = False
#: Součet nominal_power_wp controllable PV na invertoru; 0 = EMS nezapisuje reg 340.
pv_a_cap_w: int = 0
#: True = EMS smí řídit Deye reg 340 (max solar power); z SQL `fn_site_has_active_green_bonus_pv(site_id)`.
deye_reg340_pv_a_control_enabled: bool = False
@dataclass
class ControlSetpoints:
battery_w: int | None
grid_export_limit: int
ev1_current_a: int
ev2_current_a: int
heat_pump_enable: bool
grid_setpoint_w: int
ev1_power_w: int
ev2_power_w: int
target_soc_pct: int | None = None
#: Explicitní fyzický režim z plánu (PASSIVE/SELL/CHARGE).
deye_physical_mode: str | None = None
#: True = zákaz exportu (BLOCK_EXPORT) pro daný slot.
export_ban: bool = False
#: True = odpojit GEN port (MI export cutoff) v tomto slotu dle plánu (reg 178 bits0-1).
deye_gen_cutoff_enabled: bool = False
#: Efektivní vykupní cena slotu (Kč/kWh z plánu).
effective_sell_price_czk_kwh: float | None = None
#: True = reg 108/109 na 0 (PRESERVE - Deye baterii nepoužívá).
lock_battery: bool = False
#: Režim SELF_SUSTAIN.
self_sustain_local_use: bool = False
#: Deye reg 340 (max solar power, W). None = EMS reg 340 v tomto ticku neřeší.
pv_a_allowed_w: int | None = None
@dataclass
class OperatingModeInfo:
mode_code: str
battery_mode: str
grid_mode: str
ev_enabled: bool
heat_pump_enabled_def: bool
loxone_mode_value: int