426 lines
14 KiB
Python
426 lines
14 KiB
Python
"""Export plánovaných setpointů na Modbus (Deye, EV, TČ) a HTTP do Loxone."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
import os
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timezone
|
||
import asyncpg
|
||
import httpx
|
||
|
||
from app.config import get_settings
|
||
from services.telemetry_collector import ModbusDevice
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
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))))
|
||
|
||
|
||
@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
|
||
|
||
|
||
@dataclass
|
||
class OperatingModeInfo:
|
||
mode_code: str
|
||
battery_mode: str
|
||
grid_mode: str
|
||
ev_enabled: bool
|
||
heat_pump_enabled_def: bool
|
||
loxone_mode_value: int
|
||
|
||
|
||
def _clamp_u16(value: int) -> int:
|
||
return max(0, min(65535, int(value)))
|
||
|
||
|
||
async def _fetch_operating_mode(site_id: int, db: asyncpg.Connection) -> OperatingModeInfo | None:
|
||
sql = """
|
||
SELECT som.mode_code, omd.battery_mode, omd.grid_mode,
|
||
omd.ev_enabled, omd.heat_pump_enabled, omd.loxone_mode_value,
|
||
som.valid_until
|
||
FROM ems.site_operating_mode som
|
||
JOIN ems.operating_mode_def omd ON omd.code = som.mode_code
|
||
WHERE som.site_id = $1
|
||
"""
|
||
row = await db.fetchrow(sql, site_id)
|
||
if row is None:
|
||
return None
|
||
vu = row["valid_until"]
|
||
if vu is not None:
|
||
now_utc = datetime.now(timezone.utc)
|
||
if vu.tzinfo is None:
|
||
vu = vu.replace(tzinfo=timezone.utc)
|
||
if vu <= now_utc:
|
||
await db.execute("SELECT ems.fn_expire_modes()")
|
||
row = await db.fetchrow(sql, site_id)
|
||
if row is None:
|
||
return None
|
||
return OperatingModeInfo(
|
||
mode_code=row["mode_code"],
|
||
battery_mode=row["battery_mode"],
|
||
grid_mode=row["grid_mode"],
|
||
ev_enabled=bool(row["ev_enabled"]),
|
||
heat_pump_enabled_def=bool(row["heat_pump_enabled"]),
|
||
loxone_mode_value=int(row["loxone_mode_value"]),
|
||
)
|
||
|
||
|
||
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:30–14:45)."""
|
||
return await db.fetchrow(
|
||
"""
|
||
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'
|
||
)
|
||
LIMIT 1
|
||
""",
|
||
site_id,
|
||
)
|
||
|
||
|
||
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
|
||
LIMIT 1
|
||
""",
|
||
site_id,
|
||
)
|
||
if v is None:
|
||
return 0
|
||
return int(v)
|
||
|
||
|
||
def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> ControlSetpoints | None:
|
||
code = mode.mode_code
|
||
if code == "MANUAL":
|
||
return None
|
||
|
||
if code == "AUTO":
|
||
if pi is None:
|
||
return None
|
||
grid_sp = int(pi["grid_setpoint_w"] or 0)
|
||
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"])
|
||
return ControlSetpoints(
|
||
battery_w=int(pi["battery_setpoint_w"] or 0),
|
||
grid_export_limit=abs(min(grid_sp, 0)),
|
||
ev1_current_a=watts_to_amps(ev1_w, phases=3),
|
||
ev2_current_a=watts_to_amps(ev2_w, phases=1),
|
||
heat_pump_enable=hp_en,
|
||
grid_setpoint_w=grid_sp,
|
||
ev1_power_w=ev1_w,
|
||
ev2_power_w=ev2_w,
|
||
)
|
||
|
||
if code == "SELF_SUSTAIN":
|
||
return ControlSetpoints(
|
||
battery_w=None,
|
||
grid_export_limit=0,
|
||
ev1_current_a=0,
|
||
ev2_current_a=0,
|
||
heat_pump_enable=False,
|
||
grid_setpoint_w=0,
|
||
ev1_power_w=0,
|
||
ev2_power_w=0,
|
||
)
|
||
|
||
if code == "CHARGE_CHEAP":
|
||
# max_charge doplníme v export_setpoints z DB
|
||
return ControlSetpoints(
|
||
battery_w=0,
|
||
grid_export_limit=0,
|
||
ev1_current_a=0,
|
||
ev2_current_a=0,
|
||
heat_pump_enable=False,
|
||
grid_setpoint_w=0,
|
||
ev1_power_w=0,
|
||
ev2_power_w=0,
|
||
)
|
||
|
||
if code == "PRESERVE":
|
||
return ControlSetpoints(
|
||
battery_w=0,
|
||
grid_export_limit=0,
|
||
ev1_current_a=0,
|
||
ev2_current_a=0,
|
||
heat_pump_enable=False,
|
||
grid_setpoint_w=0,
|
||
ev1_power_w=0,
|
||
ev2_power_w=0,
|
||
)
|
||
|
||
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'
|
||
""",
|
||
site_id,
|
||
)
|
||
if not rows:
|
||
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
|
||
|
||
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()
|
||
|
||
if errors:
|
||
return "FAIL inverter: " + "; ".join(errors)
|
||
return f"OK inverter: batt_w={bw} export_limit_w={gex}"
|
||
|
||
|
||
def _current_limit_for_charger(charger_code: str, sp: ControlSetpoints) -> int:
|
||
c = (charger_code or "").strip().lower()
|
||
if c == "ev-charger-1":
|
||
a = sp.ev1_current_a
|
||
elif c == "ev-charger-2":
|
||
a = sp.ev2_current_a
|
||
elif c.endswith("-1") or c == "ev1":
|
||
a = sp.ev1_current_a
|
||
elif c.endswith("-2") or c == "ev2":
|
||
a = sp.ev2_current_a
|
||
else:
|
||
a = 0
|
||
if a < 6:
|
||
a = 0
|
||
return a
|
||
|
||
|
||
async def write_ev_setpoints(site_id: int, setpoints: ControlSetpoints, db: asyncpg.Connection) -> str:
|
||
rows = await db.fetch(
|
||
"""
|
||
SELECT ec.code, se.host, se.port, se.unit_id
|
||
FROM ems.asset_ev_charger ec
|
||
JOIN ems.site_endpoint se ON se.id = ec.endpoint_id
|
||
WHERE ec.site_id = $1
|
||
AND ec.schedulable = true
|
||
AND se.enabled = true
|
||
AND se.endpoint_type = 'modbus_tcp'
|
||
ORDER BY ec.code
|
||
""",
|
||
site_id,
|
||
)
|
||
if not rows:
|
||
return "OK EV: no schedulable chargers"
|
||
|
||
for row in rows:
|
||
code = row["code"]
|
||
current_a = _current_limit_for_charger(code, setpoints)
|
||
logger.info(
|
||
"EV setpoint [%s]: %sA (TODO: Modbus registers)",
|
||
code,
|
||
current_a,
|
||
)
|
||
return f"OK EV: logged {len(rows)} charger(s) (Modbus TODO)"
|
||
|
||
|
||
async def write_heat_pump_setpoint(site_id: int, setpoints: ControlSetpoints, db: asyncpg.Connection) -> str:
|
||
rows = await db.fetch(
|
||
"""
|
||
SELECT hp.code, se.host, se.port, se.unit_id
|
||
FROM ems.asset_heat_pump hp
|
||
JOIN ems.site_endpoint se ON se.id = hp.endpoint_id
|
||
WHERE hp.site_id = $1
|
||
AND hp.schedulable = true
|
||
AND se.enabled = true
|
||
AND se.endpoint_type = 'modbus_tcp'
|
||
""",
|
||
site_id,
|
||
)
|
||
if not rows:
|
||
return "OK heat pump: no schedulable unit"
|
||
for row in rows:
|
||
logger.info(
|
||
"HP setpoint [%s]: enable=%s (TODO: Modbus registers)",
|
||
row["code"],
|
||
setpoints.heat_pump_enable,
|
||
)
|
||
return "OK heat pump: logged (Modbus TODO)"
|
||
|
||
|
||
async def send_loxone_setpoints(
|
||
site_id: int,
|
||
setpoints: ControlSetpoints,
|
||
mode: OperatingModeInfo,
|
||
db: asyncpg.Connection,
|
||
) -> str:
|
||
endpoint = await db.fetchrow(
|
||
"""
|
||
SELECT host, port, protocol
|
||
FROM ems.site_endpoint
|
||
WHERE site_id = $1 AND endpoint_type = 'loxone_http' AND enabled = true
|
||
ORDER BY id
|
||
LIMIT 1
|
||
""",
|
||
site_id,
|
||
)
|
||
if not endpoint:
|
||
return "OK Loxone: no endpoint, skipped"
|
||
|
||
proto = (endpoint["protocol"] or "http").lower()
|
||
if proto not in ("http", "https"):
|
||
proto = "http"
|
||
host = endpoint["host"]
|
||
port = int(endpoint["port"] or (443 if proto == "https" else 80))
|
||
base = f"{proto}://{host}:{port}/dev/sps/io"
|
||
|
||
settings = get_settings()
|
||
user = settings.loxone_user or os.getenv("LOXONE_USER") or ""
|
||
password = settings.loxone_password or os.getenv("LOXONE_PASSWORD") or ""
|
||
auth = (user, password) if user else None
|
||
|
||
batt_display = 0 if setpoints.battery_w is None else int(setpoints.battery_w)
|
||
|
||
paths: list[tuple[str, int]] = [
|
||
(f"{base}/EMS_Mode/{mode.loxone_mode_value}", mode.loxone_mode_value),
|
||
(f"{base}/EMS_Battery_Setpoint_W/{batt_display}", batt_display),
|
||
(f"{base}/EMS_Grid_Setpoint_W/{setpoints.grid_setpoint_w}", setpoints.grid_setpoint_w),
|
||
(f"{base}/EMS_EV1_Power_W/{setpoints.ev1_power_w}", setpoints.ev1_power_w),
|
||
(f"{base}/EMS_EV2_Power_W/{setpoints.ev2_power_w}", setpoints.ev2_power_w),
|
||
(f"{base}/EMS_HeatPump_Enable/{1 if setpoints.heat_pump_enable else 0}", 1 if setpoints.heat_pump_enable else 0),
|
||
]
|
||
|
||
errs: list[str] = []
|
||
try:
|
||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||
for url, _ in paths:
|
||
try:
|
||
r = await client.get(url, auth=auth)
|
||
r.raise_for_status()
|
||
except Exception as e:
|
||
errs.append(f"{url!s}: {e}")
|
||
except Exception as e:
|
||
return f"FAIL Loxone: client {e}"
|
||
|
||
if errs:
|
||
return "FAIL Loxone: " + "; ".join(errs[:3])
|
||
return "OK Loxone: all virtual inputs updated"
|
||
|
||
|
||
async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None:
|
||
mode = await _fetch_operating_mode(site_id, db)
|
||
if mode is None:
|
||
logger.warning("control export site=%s: no operating mode row", site_id)
|
||
return
|
||
|
||
if mode.mode_code == "MANUAL":
|
||
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)
|
||
|
||
if mode.mode_code == "AUTO" and sp is None:
|
||
if pi is None:
|
||
logger.warning(
|
||
"control export site=%s: AUTO but no planning_interval for current slot, skip",
|
||
site_id,
|
||
)
|
||
return
|
||
|
||
if sp is None:
|
||
logger.warning(
|
||
"control export site=%s: no setpoints for mode %s, skip",
|
||
site_id,
|
||
mode.mode_code,
|
||
)
|
||
return
|
||
|
||
if mode.mode_code == "CHARGE_CHEAP":
|
||
max_ch = await _fetch_max_charge_power_w(site_id, db)
|
||
sp = 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,
|
||
ev1_power_w=0,
|
||
ev2_power_w=0,
|
||
)
|
||
|
||
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,
|
||
),
|
||
)
|
||
)
|
||
|
||
for name, res in results:
|
||
if isinstance(res, Exception):
|
||
logger.error("control export site=%s %s: FAIL %s", site_id, name, res)
|
||
elif isinstance(res, str) and res.startswith("FAIL"):
|
||
logger.error("control export site=%s %s: %s", site_id, name, res)
|
||
else:
|
||
logger.info("control export site=%s %s: %s", site_id, name, res)
|