refactor control output writers

This commit is contained in:
Dusan Vojacek
2026-05-02 19:47:12 +02:00
parent abe4255f88
commit 53288d130a
2 changed files with 155 additions and 131 deletions

View File

@@ -3,15 +3,12 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
from collections import defaultdict from collections import defaultdict
from typing import Any from typing import Any
from datetime import datetime, timezone from datetime import datetime, timezone
import asyncpg import asyncpg
import httpx
from app.config import get_settings
from services.control.deye_helpers import ( from services.control.deye_helpers import (
BATT_VOLTAGE_V, BATT_VOLTAGE_V,
DEYE_CLOCK_DRIFT_OK_SEC, DEYE_CLOCK_DRIFT_OK_SEC,
@@ -55,6 +52,12 @@ from services.control.modbus_journal import (
create_modbus_commands, create_modbus_commands,
execute_modbus_commands, execute_modbus_commands,
) )
from services.control.outputs import (
_current_limit_for_charger,
send_loxone_setpoints,
write_ev_setpoints,
write_heat_pump_setpoint,
)
from services.control.repository import ( from services.control.repository import (
_fetch_max_charge_power_w, _fetch_max_charge_power_w,
_fetch_operating_mode, _fetch_operating_mode,
@@ -877,134 +880,6 @@ async def read_deye_registers_live(site_id: int, db: asyncpg.Connection) -> dict
} }
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: async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None:
mode = await _fetch_operating_mode(site_id, db) mode = await _fetch_operating_mode(site_id, db)
if mode is None: if mode is None:

View File

@@ -0,0 +1,149 @@
"""Non-Deye output writers for control export."""
from __future__ import annotations
import logging
import os
import asyncpg
import httpx
from app.config import get_settings
from services.control.models import ControlSetpoints, OperatingModeInfo
logger = logging.getLogger(__name__)
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"