"""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)