second version

This commit is contained in:
Dusan Vojacek
2026-04-03 14:23:16 +02:00
parent 897b95f728
commit 9f4126946d
105 changed files with 9738 additions and 1470 deletions

View File

@@ -6,15 +6,51 @@ import asyncio
import logging
import os
from dataclasses import dataclass
from typing import Any
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
import asyncpg
import httpx
from app.config import get_settings
from services.telemetry_collector import ModbusDevice
from services.modbus_client import get_modbus_client
logger = logging.getLogger(__name__)
# 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 178 pevné hodnoty (bit45); bez read-modify-write (kolize s Loxone / transaction ID)
REG178_SELL = 0b00100000 # 32, grid peak shaving disable
REG178_PASSIVE = 0b00110000 # 48, grid peak shaving enable (PASSIVE i CHARGE)
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 built-in CT)",
143: "export_limit_w (max export do sítě)",
178: "grid_peak_shaving_switch (SELL=32 bit4-5=10, PASSIVE/CHARGE=48 bit4-5=11)",
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:
@@ -22,6 +58,50 @@ def watts_to_amps(power_w: int | None, phases: int = 3, voltage: int = 230) -> i
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 výhradně z DB (_load_inverter_config)."""
return min(max(0, max_amps), max(0, round(abs(power_w) / BATT_VOLTAGE_V)))
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
reserve_soc_percent: int | None
usable_capacity_wh: int | None
max_charge_a: int
max_discharge_a: int
@dataclass
class ControlSetpoints:
battery_w: int | None
@@ -32,6 +112,9 @@ class ControlSetpoints:
grid_setpoint_w: int
ev1_power_w: int
ev2_power_w: int
target_soc_pct: int | None = None
#: True = reg 108/109 na 0 (PRESERVE Deye baterii nepoužívá)
lock_battery: bool = False
@dataclass
@@ -44,8 +127,253 @@ class OperatingModeInfo:
loxone_mode_value: int
def _clamp_u16(value: int) -> int:
return max(0, min(65535, int(value)))
async def create_modbus_commands(
site_id: int,
planning_run_id: int | None,
asset_type: str,
asset_id: int,
asset_code: str,
host: str,
port: int,
unit_id: int,
registers: list[tuple[int, str, int]],
db: asyncpg.Connection,
deye_physical_mode: str | None = None,
) -> list[int]:
"""
Vytvoří záznamy v modbus_command pro sadu zápisů.
Vrátí list command IDs.
Pro Deye se jméno registru bere z DEYE_REGISTER_NAMES (prostřední položka tuplu se ignoruje).
"""
ids: list[int] = []
for reg, _ignored_name, val in registers:
register_name = DEYE_REGISTER_NAMES.get(reg, f"reg_{reg}")
cmd_id = await db.fetchval(
"""
INSERT INTO ems.modbus_command
(site_id, asset_type, asset_id, asset_code,
device_host, device_port, device_unit_id,
register, register_name, value_to_write,
planning_run_id, status, deye_physical_mode)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'pending',$12)
RETURNING id
""",
site_id,
asset_type,
asset_id,
asset_code,
host,
port,
unit_id,
reg,
register_name,
val,
planning_run_id,
deye_physical_mode,
)
if cmd_id is not None:
ids.append(int(cmd_id))
return ids
async def execute_modbus_commands(
command_ids: list[int],
db: asyncpg.Connection,
) -> bool:
"""
Zapíše příkazy z modbus_command do zařízení.
Aktualizuje status na 'written' nebo 'failed'.
Vrátí True pokud všechny příkazy uspěly.
"""
MAX_RETRIES = 3
RETRY_DELAY = 0.5
all_ok = True
for cmd_id in command_ids:
cmd = await db.fetchrow(
"SELECT * FROM ems.modbus_command WHERE id=$1", cmd_id
)
if cmd is None:
continue
client = await get_modbus_client(
cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"])
)
for attempt in range(MAX_RETRIES):
try:
await client.write_registers(
int(cmd["register"]), [int(cmd["value_to_write"])]
)
await db.execute(
"""
UPDATE ems.modbus_command
SET status='written', value_written=$1, written_at=now(),
attempt_count=attempt_count+1, error_msg=NULL
WHERE id=$2
""",
int(cmd["value_to_write"]),
cmd_id,
)
logger.info(
"[cmd %s] %s 0x%04X=%s OK (attempt %s)",
cmd_id,
cmd["asset_code"],
int(cmd["register"]),
int(cmd["value_to_write"]),
attempt + 1,
)
break
except Exception as e:
if attempt < MAX_RETRIES - 1:
logger.warning(
"[cmd %s] attempt %s failed: %s, retrying...",
cmd_id,
attempt + 1,
e,
)
await asyncio.sleep(RETRY_DELAY)
client._client = None # force reconnect
else:
await db.execute(
"""
UPDATE ems.modbus_command
SET status='failed', error_msg=$1,
attempt_count=attempt_count+1
WHERE id=$2
""",
str(e),
cmd_id,
)
logger.error(
"[cmd %s] all %s attempts failed: %s",
cmd_id,
MAX_RETRIES,
e,
)
all_ok = False
return all_ok
async def _switch_to_self_sustain(site_id: int, db: asyncpg.Connection, reason: str) -> None:
"""Přepne lokalitu na SELF_SUSTAIN a zaloguje důvod."""
await db.execute(
"SELECT ems.fn_set_mode($1, $2, $3, $4, $5)",
site_id,
"SELF_SUSTAIN",
"system:mismatch",
None,
reason,
)
logger.critical("Site %s switched to SELF_SUSTAIN: %s", site_id, reason)
async def verify_modbus_commands(
command_ids: list[int],
db: asyncpg.Connection,
site_id: int,
) -> bool:
"""
Přečte registry zpět a porovná s value_to_write.
Při mismatch: retry → SELF_SUSTAIN + Discord.
"""
from services.notification_service import (
notify_modbus_mismatch,
notify_self_sustain_activated,
)
all_ok = True
for cmd_id in command_ids:
cmd = await db.fetchrow(
"SELECT * FROM ems.modbus_command WHERE id=$1", cmd_id
)
if cmd is None or cmd["status"] != "written":
continue
try:
client = await get_modbus_client(
cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"])
)
actual = await client.read_register(int(cmd["register"]))
await db.execute(
"""
UPDATE ems.modbus_command
SET value_verified=$1, verified_at=now(),
status=CASE WHEN $1=$2 THEN 'verified' ELSE 'mismatch' END
WHERE id=$3
""",
actual,
int(cmd["value_to_write"]),
cmd_id,
)
if actual != int(cmd["value_to_write"]):
logger.error(
"[cmd %s] MISMATCH %s 0x%04X: expected=%s actual=%s",
cmd_id,
cmd["asset_code"],
int(cmd["register"]),
cmd["value_to_write"],
actual,
)
row_ac = await db.fetchrow(
"SELECT attempt_count FROM ems.modbus_command WHERE id=$1", cmd_id
)
attempts = int(row_ac["attempt_count"] or 0) if row_ac else 0
await notify_modbus_mismatch(
cmd["asset_code"],
int(cmd["register"]),
cmd["register_name"] or "",
int(cmd["value_to_write"]),
actual,
attempts,
)
if attempts < 3:
await db.execute(
"UPDATE ems.modbus_command SET status='retrying' WHERE id=$1",
cmd_id,
)
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,
)
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: {cmd['asset_code']} "
f"reg 0x{cmd['register']:04X}"
),
)
if site:
await notify_self_sustain_activated(
site["code"],
(
f"Modbus mismatch: {cmd['asset_code']} "
f"0x{cmd['register']:04X} expected={cmd['value_to_write']} "
f"actual={actual}"
),
)
all_ok = False
else:
logger.info(
"[cmd %s] verified OK: %s 0x%04X=%s",
cmd_id,
cmd["asset_code"],
int(cmd["register"]),
actual,
)
except Exception as e:
logger.error("[cmd %s] verify read failed: %s", cmd_id, e)
all_ok = False
return all_ok
async def _fetch_operating_mode(site_id: int, db: asyncpg.Connection) -> OperatingModeInfo | None:
@@ -80,21 +408,155 @@ async def _fetch_operating_mode(site_id: int, db: asyncpg.Connection) -> Operati
)
async def _fetch_current_slot_plan_row(site_id: int, db: asyncpg.Connection) -> asyncpg.Record | None:
"""Řádek plánu pro následující 15min slot (export ~1 min před hranicí, např. 14:29 → 14:3014:45)."""
return await db.fetchrow(
async def _get_current_soc(site_id: int, db: asyncpg.Connection) -> int:
soc = await db.fetchval(
"""
SELECT battery_soc_percent
FROM ems.telemetry_inverter
WHERE site_id = $1 AND battery_soc_percent IS NOT NULL
ORDER BY measured_at DESC
LIMIT 1
""",
site_id,
)
return int(soc) if soc is not None else 50
async def _load_inverter_config(
site_id: int, db: asyncpg.Connection
) -> InverterConfig | None:
row = await db.fetchrow(
"""
SELECT
ai.id, ai.code,
se.host, se.port, se.unit_id,
sgc.max_export_power_w,
sgc.max_import_power_w,
sgc.no_export,
ai.max_battery_charge_w,
ai.max_battery_discharge_w,
ab.reserve_soc_percent,
ab.usable_capacity_wh,
LEAST(
COALESCE(ab.bms_max_charge_w, ai.max_battery_charge_w),
ai.max_battery_charge_w
) / 51.2 AS max_charge_a,
LEAST(
COALESCE(ab.bms_max_discharge_w, ai.max_battery_discharge_w),
ai.max_battery_discharge_w
) / 51.2 AS max_discharge_a
FROM ems.asset_inverter ai
JOIN ems.site_endpoint se ON se.id = ai.endpoint_id
JOIN ems.asset_battery ab ON ab.inverter_id = ai.id
LEFT JOIN ems.site_grid_connection sgc ON sgc.site_id = ai.site_id
WHERE ai.site_id = $1
AND ai.active = true
AND ai.controllable = true
AND se.enabled = true
AND se.endpoint_type = 'modbus_tcp'
ORDER BY ai.id
LIMIT 1
""",
site_id,
)
if row is None:
return None
mc = row["max_charge_a"]
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
port = int(row["port"] or 502)
uid = int(row["unit_id"] if row["unit_id"] is not None else 1)
return InverterConfig(
id=int(row["id"]),
code=row["code"],
host=row["host"],
port=port,
unit_id=uid,
max_export_power_w=int(row["max_export_power_w"])
if row["max_export_power_w"] is not None
else None,
max_import_power_w=int(row["max_import_power_w"])
if row["max_import_power_w"] is not None
else None,
no_export=bool(row["no_export"] or False),
max_battery_charge_w=int(row["max_battery_charge_w"])
if row["max_battery_charge_w"] is not None
else None,
max_battery_discharge_w=int(row["max_battery_discharge_w"])
if row["max_battery_discharge_w"] is not None
else None,
reserve_soc_percent=int(row["reserve_soc_percent"])
if row["reserve_soc_percent"] is not None
else None,
usable_capacity_wh=int(row["usable_capacity_wh"])
if row["usable_capacity_wh"] is not None
else None,
max_charge_a=max_charge_a,
max_discharge_a=max_discharge_a,
)
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"))
reg62 = ((now.year - 2000) << 8) | now.month
reg63 = (now.day << 8) | now.hour
reg64 = (now.minute << 8) | now.second
rows = [
(62, "", reg62),
(63, "", reg63),
(64, "", reg64),
]
return now, rows
def _deye_time_point_rows(
slot_index: int,
time_hhmm: int,
power_w: int,
soc_pct: int,
grid_charge: bool,
) -> list[tuple[int, str, int]]:
g = 1 if grid_charge else 0
return [
(148 + slot_index, "", time_hhmm),
(154 + slot_index, "", power_w),
(166 + slot_index, "", soc_pct),
(172 + slot_index, "", g),
]
def _slot_start_prague_sql(slot_offset: int) -> str:
"""Výraz TIMESTAMPTZ = začátek aktuálního (+offset) 15min slotu v Europe/Prague."""
off = int(slot_offset)
return f"""
(
WITH loc AS (SELECT now() AT TIME ZONE 'Europe/Prague' AS ts)
SELECT (
(date_trunc('day', ts)
+ make_interval(
hours => EXTRACT(HOUR FROM ts)::int,
mins => (FLOOR(EXTRACT(MINUTE FROM ts) / 15) * 15)::int
)
)::timestamp AT TIME ZONE 'Europe/Prague'
) + INTERVAL '{off * 15} minutes'
FROM loc
)
"""
async def _fetch_plan_row_for_slot_offset(
site_id: int, db: asyncpg.Connection, slot_offset: int
) -> asyncpg.Record | None:
"""Řádek plánu pro slot: 0 = probíhající 15min, 1 = následující (hranice v Europe/Prague)."""
t = _slot_start_prague_sql(slot_offset)
return await db.fetchrow(
f"""
SELECT pi.* FROM ems.planning_interval pi
JOIN ems.planning_run pr ON pr.id = pi.run_id
WHERE pr.site_id = $1 AND pr.status = 'active'
AND pi.interval_start = (
SELECT MIN(pi2.interval_start) FROM ems.planning_interval pi2
JOIN ems.planning_run pr2 ON pr2.id = pi2.run_id
WHERE pr2.site_id = $1 AND pr2.status = 'active'
AND pi2.interval_start >= date_trunc('hour', now())
+ INTERVAL '15 min' * FLOOR(EXTRACT(MINUTE FROM now()) / 15)
+ INTERVAL '15 minutes'
)
AND pi.interval_start = {t}
LIMIT 1
""",
site_id,
@@ -104,10 +566,20 @@ async def _fetch_current_slot_plan_row(site_id: int, db: asyncpg.Connection) ->
async def _fetch_max_charge_power_w(site_id: int, db: asyncpg.Connection) -> int:
v = await db.fetchval(
"""
SELECT ai.max_charge_power_w
FROM ems.asset_inverter ai
WHERE ai.site_id = $1 AND ai.controllable = true AND ai.active = true
ORDER BY ai.id
SELECT LEAST(
COALESCE(ai.max_battery_charge_w, ai.max_charge_power_w),
COALESCE(
ab.bms_max_charge_w,
CASE WHEN ab.max_charge_c_rate IS NOT NULL
THEN (ab.max_charge_c_rate * ab.usable_capacity_wh)::bigint
END,
COALESCE(ai.max_battery_charge_w, ai.max_charge_power_w)
)
) AS effective_charge_w
FROM ems.asset_battery ab
JOIN ems.asset_inverter ai ON ai.id = ab.inverter_id AND ai.site_id = ab.site_id
WHERE ab.site_id = $1 AND ai.controllable = true AND ai.active = true
ORDER BY ab.id
LIMIT 1
""",
site_id,
@@ -129,6 +601,8 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
ev1_w = int(pi["ev1_setpoint_w"] or 0) if "ev1_setpoint_w" in pi else 0
ev2_w = int(pi["ev2_setpoint_w"] or 0) if "ev2_setpoint_w" in pi else 0
hp_en = bool(pi["heat_pump_enabled"])
tgt = pi["battery_soc_target_pct"]
target_soc = int(round(float(tgt))) if tgt is not None else None
return ControlSetpoints(
battery_w=int(pi["battery_setpoint_w"] or 0),
grid_export_limit=abs(min(grid_sp, 0)),
@@ -138,6 +612,7 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
grid_setpoint_w=grid_sp,
ev1_power_w=ev1_w,
ev2_power_w=ev2_w,
target_soc_pct=target_soc,
)
if code == "SELF_SUSTAIN":
@@ -150,6 +625,7 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
grid_setpoint_w=0,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=None,
)
if code == "CHARGE_CHEAP":
@@ -163,6 +639,7 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
grid_setpoint_w=0,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=None,
)
if code == "PRESERVE":
@@ -175,62 +652,240 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
grid_setpoint_w=0,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=None,
lock_battery=True,
)
logger.warning("Unknown mode_code %s for site export, skipping", code)
return None
async def write_inverter_setpoints(site_id: int, setpoints: ControlSetpoints, db: asyncpg.Connection) -> str:
if setpoints.battery_w is None:
return "OK inverter: skipped (battery_w=None, Deye unchanged)"
rows = await db.fetch(
"""
SELECT ai.code, se.host, se.port, se.unit_id
FROM ems.asset_inverter ai
JOIN ems.site_endpoint se ON se.id = ai.endpoint_id
WHERE ai.site_id = $1
AND ai.controllable = true
AND ai.active = true
AND se.enabled = true
AND se.endpoint_type = 'modbus_tcp'
""",
def _apply_price_failsafe_guard(
site_id: int,
mode: OperatingModeInfo,
pi: asyncpg.Record | None,
sp: ControlSetpoints,
) -> ControlSetpoints:
if mode.mode_code != "AUTO" or pi is None:
return sp
if "is_predicted_price" not in pi or not bool(pi["is_predicted_price"]):
return sp
logger.warning(
"control export site=%s: AUTO slot uses predicted price -> forcing PASSIVE no-export guard",
site_id,
)
if not rows:
return ControlSetpoints(
battery_w=0,
grid_export_limit=0,
ev1_current_a=sp.ev1_current_a,
ev2_current_a=sp.ev2_current_a,
heat_pump_enable=sp.heat_pump_enable,
grid_setpoint_w=max(0, int(sp.grid_setpoint_w or 0)),
ev1_power_w=sp.ev1_power_w,
ev2_power_w=sp.ev2_power_w,
target_soc_pct=sp.target_soc_pct,
)
def _deye_reg143_export_w(no_export: bool, max_export_power_w: int | None) -> int:
"""Reg 143 max export W z DB (např. SUN-20K / home-01 = 13 500 W)."""
if no_export:
return 0
return max(0, int(max_export_power_w or 0))
def get_deye_mode(setpoints: ControlSetpoints) -> str:
"""
Fyzický režim Deye: SELL | CHARGE | PASSIVE.
Solver: záporný grid_setpoint_w = export; kladný výrazný + nabíjení = CHARGE ze sítě.
battery_w=None (SELF_SUSTAIN) → bat_w považuj za 0 → typicky PASSIVE při grid_setpoint_w=0.
"""
grid_w = int(setpoints.grid_setpoint_w or 0)
if setpoints.battery_w is None:
bat_w = 0
else:
bat_w = int(setpoints.battery_w)
if grid_w < -200:
return "SELL"
if bat_w > 500 and grid_w > 200:
return "CHARGE"
return "PASSIVE"
def _deye_tou_params(
setpoints: ControlSetpoints,
inv: InverterConfig,
) -> tuple[int, int, bool]:
"""
Parametry jednoho Deye time pointu: výkon W, SOC min %, grid_charge.
Musí odpovídat logice get_deye_mode / lock_battery v write_inverter_setpoints.
"""
reserve_soc = inv.reserve_soc_percent or 20
max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V)
tp_discharge_w = 0 if setpoints.lock_battery else max_batt_w_discharge
if setpoints.lock_battery:
return tp_discharge_w, reserve_soc, False
deye_mode = get_deye_mode(setpoints)
if deye_mode == "CHARGE":
raw_bat = setpoints.battery_w
battery_w = int(raw_bat) if raw_bat is not None else 0
target_soc = min(95, setpoints.target_soc_pct or 80)
tp_charge_w = battery_watts_to_amps(battery_w, inv.max_charge_a) * int(BATT_VOLTAGE_V)
return tp_charge_w, target_soc, True
return tp_discharge_w, reserve_soc, False
async def write_inverter_setpoints(
site_id: int,
setpoints_now: ControlSetpoints,
setpoints_next: ControlSetpoints | None,
db: asyncpg.Connection,
planning_run_id: int | None = None,
) -> str:
inv = await _load_inverter_config(site_id, db)
if inv is None:
return "FAIL inverter: no controllable Modbus endpoint"
bw = setpoints.battery_w
gex = _clamp_u16(setpoints.grid_export_limit)
chg = _clamp_u16(bw) if bw >= 0 else 0
dis = _clamp_u16(abs(bw)) if bw < 0 else 0
raw_bat = setpoints_now.battery_w
grid_w = int(setpoints_now.grid_setpoint_w or 0)
no_export = inv.no_export
export_lim = _deye_reg143_export_w(no_export, inv.max_export_power_w)
reserve_soc = inv.reserve_soc_percent or 20
max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V)
tp_discharge_w = 0 if setpoints_now.lock_battery else max_batt_w_discharge
errors: list[str] = []
for row in rows:
code = row["code"]
host = row["host"]
port = int(row["port"] or 502)
unit_id = int(row["unit_id"] if row["unit_id"] is not None else 1)
dev = ModbusDevice(host, port, unit_id, f"inverter-write:{code}")
try:
if bw >= 0:
ok1 = await dev.write_register(0x00F3, chg)
ok2 = await dev.write_register(0x00F4, 0)
else:
ok1 = await dev.write_register(0x00F3, 0)
ok2 = await dev.write_register(0x00F4, dis)
ok3 = await dev.write_register(0x00F6, gex)
if not (ok1 and ok2 and ok3):
errors.append(f"{code}: Modbus write failed")
except Exception as e:
errors.append(f"{code}: {e}")
finally:
await dev.close()
try:
soc_telemetry = await _get_current_soc(site_id, db)
if errors:
return "FAIL inverter: " + "; ".join(errors)
return f"OK inverter: batt_w={bw} export_limit_w={gex}"
deye_mode = get_deye_mode(setpoints_now)
if setpoints_now.lock_battery:
charge_a = 0
discharge_a = 0
elif deye_mode == "CHARGE":
battery_w = int(raw_bat) if raw_bat is not None else 0
charge_a = battery_watts_to_amps(battery_w, inv.max_charge_a)
discharge_a = 0
else:
charge_a = int(inv.max_charge_a)
discharge_a = int(inv.max_discharge_a)
selling_mode = 0 if deye_mode == "SELL" else 1
export_limit = export_lim
reg178_val = REG178_SELL if deye_mode == "SELL" else REG178_PASSIVE
logger.info(
f"[control] site={site_id} fyzický režim Deye: {deye_mode} | "
f"battery_w={raw_bat!r} grid_w={grid_w} | "
f"charge_a={charge_a} discharge_a={discharge_a} | "
f"reg142={'0=SELL' if deye_mode == 'SELL' else '1=ZERO_EXP'} "
f"reg178={reg178_val}"
)
now_cet, time_rows = _deye_system_time_register_rows()
logger.info("Deye time synced: %s CET", now_cet.strftime("%Y-%m-%d %H:%M:%S"))
registers: list[tuple[int, str, int]] = list(time_rows)
sp_tp2 = setpoints_next if setpoints_next is not None else setpoints_now
hh_cur = current_slot_hhmm()
hh_nxt = next_slot_hhmm()
p1, s1, g1 = _deye_tou_params(setpoints_now, inv)
p2, s2, g2 = _deye_tou_params(sp_tp2, inv)
registers.extend(_deye_time_point_rows(0, hh_cur, p1, s1, g1))
registers.extend(_deye_time_point_rows(1, hh_nxt, p2, s2, g2))
for idx in range(2, 6):
registers.extend(
_deye_time_point_rows(
idx, 2359, tp_discharge_w, reserve_soc, False
)
)
registers.extend(
[
(108, "", charge_a),
(109, "", discharge_a),
(141, "energy_mode (0)", 0),
(142, "limit_control (0=selling, 1=zero_export)", selling_mode),
(178, "grid_peak_shaving_switch", reg178_val),
(143, "", export_limit),
]
)
logger.info(
"[control] %s: deye_mode=%s charge=%sA discharge=%sA limit_control=%s export=%sW "
"time_point1=%s time_point2=%s soc_telemetry=%s%% (batt=%r grid=%sW)",
inv.code,
deye_mode,
charge_a,
discharge_a,
selling_mode,
export_limit,
hh_cur,
hh_nxt,
soc_telemetry,
raw_bat,
grid_w,
)
cmd_ids = await create_modbus_commands(
site_id,
planning_run_id,
"inverter",
inv.id,
inv.code,
inv.host,
inv.port,
inv.unit_id,
registers,
db,
deye_physical_mode=deye_mode,
)
if not await execute_modbus_commands(cmd_ids, db):
return f"FAIL inverter: {inv.code}: Modbus write failed (see modbus_command)"
logger.info("[control] Inverter %s journal write OK", inv.code)
except Exception as e:
return f"FAIL inverter: {inv.code}: {e}"
return (
f"OK inverter: batt_w={raw_bat!r} "
f"(time points + FC 0x10: 108/109/141/142/178/143)"
)
async def read_deye_registers_live(site_id: int, db: asyncpg.Connection) -> dict[str, Any]:
"""
Živé čtení holding registrů Deye 108, 109, 141, 142, 143, 178, 191 (stejné TCP spojení jako telemetrie/export).
"""
inv = await _load_inverter_config(site_id, db)
if inv is None:
raise ValueError("no controllable Modbus inverter for site")
client = await get_modbus_client(inv.host, inv.port, inv.unit_id)
read_at = datetime.now(timezone.utc)
try:
r108 = await client.read_register(108)
r109 = await client.read_register(109)
r141 = await client.read_register(141)
r142 = await client.read_register(142)
r143 = await client.read_register(143)
r178 = await client.read_register(178)
r191 = await client.read_register(191)
except Exception:
logger.exception("read_deye_registers_live site=%s failed", site_id)
raise
return {
"reg108_charge_a": int(r108),
"reg109_discharge_a": int(r109),
"reg141_energy_mode": int(r141),
"reg142_limit_control": int(r142),
"reg143_export_limit_w": int(r143),
"reg178_peak_shaving_switch": int(r178),
"reg191_peak_shaving_w": int(r191),
"read_at": read_at.isoformat(),
}
def _current_limit_for_charger(charger_code: str, sp: ControlSetpoints) -> int:
@@ -371,18 +1026,20 @@ async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None:
logger.info("control export site=%s: MANUAL, skip writes", site_id)
return
pi = await _fetch_current_slot_plan_row(site_id, db)
sp = _build_setpoints(mode, pi)
pi_now = await _fetch_plan_row_for_slot_offset(site_id, db, 0)
pi_next = await _fetch_plan_row_for_slot_offset(site_id, db, 1)
sp_now = _build_setpoints(mode, pi_now)
sp_next = _build_setpoints(mode, pi_next)
if mode.mode_code == "AUTO" and sp is None:
if pi is None:
if mode.mode_code == "AUTO" and sp_now is None:
if pi_now is None:
logger.warning(
"control export site=%s: AUTO but no planning_interval for current slot, skip",
site_id,
)
return
if sp is None:
if sp_now is None:
logger.warning(
"control export site=%s: no setpoints for mode %s, skip",
site_id,
@@ -392,27 +1049,67 @@ async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None:
if mode.mode_code == "CHARGE_CHEAP":
max_ch = await _fetch_max_charge_power_w(site_id, db)
sp = ControlSetpoints(
# Kladný grid_setpoint_w > 200 → fyzický CHARGE (nabíjení ze sítě), viz get_deye_mode
grid_for_charge = max(300, max_ch)
sp_now = ControlSetpoints(
battery_w=max_ch,
grid_export_limit=0,
ev1_current_a=0,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=0,
grid_setpoint_w=grid_for_charge,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=None,
)
sp_next = sp_now
else:
sp_now = _apply_price_failsafe_guard(site_id, mode, pi_now, sp_now)
if sp_next is not None:
sp_next = _apply_price_failsafe_guard(site_id, mode, pi_next, sp_next)
planning_run_id = await db.fetchval(
"""
SELECT id FROM ems.planning_run
WHERE site_id = $1 AND status = 'active'
ORDER BY created_at DESC
LIMIT 1
""",
site_id,
)
if planning_run_id is not None:
planning_run_id = int(planning_run_id)
try:
inv_res = await write_inverter_setpoints(
site_id, sp_now, sp_next, db, planning_run_id=planning_run_id
)
except Exception as e:
logger.error("inverter write failed: %s", e)
inv_res = f"FAIL inverter: {e}"
try:
ev_res = await write_ev_setpoints(site_id, sp_now, db)
except Exception as e:
logger.error("ev write failed: %s", e)
ev_res = f"FAIL ev: {e}"
try:
hp_res = await write_heat_pump_setpoint(site_id, sp_now, db)
except Exception as e:
logger.error("hp write failed: %s", e)
hp_res = f"FAIL heat pump: {e}"
try:
lox_res = await send_loxone_setpoints(site_id, sp_now, mode, db)
except Exception as e:
logger.error("loxone write failed: %s", e)
lox_res = f"FAIL Loxone: {e}"
results = list(
zip(
("inverter", "ev", "heat_pump", "loxone"),
await asyncio.gather(
write_inverter_setpoints(site_id, sp, db),
write_ev_setpoints(site_id, sp, db),
write_heat_pump_setpoint(site_id, sp, db),
send_loxone_setpoints(site_id, sp, mode, db),
return_exceptions=True,
),
(inv_res, ev_res, hp_res, lox_res),
)
)