Files
ems/backend/services/control/outputs.py
Dusan Vojacek 54288ee2fd fix(modbus): reg 15 re-asert kazdy tick + per-charger failsafe (BUG1)
Zivy incident home-01 (TeltoCharge .16): od ~22:45 UTC 12.6. nevznikl zadny
telto journal radek (ani failed), auto jelo failsafe 8 A misto planovanych 0 A.

Root cause: reg 15 (amps) byl write-on-change proti journalu
(fn_modbus_device_state_map). Jakmile mel reg 15 radek "0 verified" a plan
dal chtel 0, NIKDY nevznikl novy prikaz -- a TeltoCharge si po vypadku
komunikace sam prepsal reg 15 na failsafe (reg 20) BEZ journal radku. Verify
cte zpet jen 'written' radky, takze tichy drift 0 -> 8 A nikdo nevidel ani
neopravil.

- reg 15 (amps to use) se zapisuje VZDY (re-asert) -- volatilni ridici
  registr, ne EEPROM; drzi verify jobu cerstvy written radek -> drift se
  zachyti a hned opravi. _split_amps_and_watchdog odděluje 15 od 19/20.
- reg 19/20 (watchdog config, EEPROM) zustavaji write-on-change.
- per-charger failsafe/timeout: asset_ev_charger.watchdog_failsafe_a /
  watchdog_comm_timeout_s (V106; default 8 A / 300 s). "Zakaz nabijeni" =
  reg 15 = 0 (protokol rev 0.5 nema samostatny enable registr).
- testy test_ev_write_on_change.py; docs teltocharge + journal + data-model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:03:11 +02:00

341 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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__)
# Teltonika TeltoCharge zápisové registry (oficiální protokol rev 0.5;
# docs/04-modules/modbus-registers-teltocharge.md). FC 16 přes journal.
TELTO_REG_AMPS_TO_USE = 15 # 0 = stop, 632 A
TELTO_REG_COMM_TIMEOUT_S = 19 # watchdog: bez komunikace → failsafe
TELTO_REG_FAILSAFE_CURRENT_A = 20
#: Výpadek EMS: po watchdog_comm_timeout_s bez komunikace wallbox přejde na
#: failsafe proud — auto se přes noc nabije i bez EMS (pomalu), místo aby
#: stálo na 0 A. Defaulty (fallback, když řádek chargeru nemá vlastní hodnoty).
TELTO_WATCHDOG_TIMEOUT_S = 300
TELTO_WATCHDOG_FAILSAFE_A = 8
def _telto_setpoint_registers(
current_a: int,
*,
comm_timeout_s: int = TELTO_WATCHDOG_TIMEOUT_S,
failsafe_a: int = TELTO_WATCHDOG_FAILSAFE_A,
) -> list[tuple[int, str, int]]:
"""Registry pro jeden export tick: limit proudu + watchdog konfigurace.
**Reg 15 (amps to use) NENÍ write-on-change** — viz `_assert_amps_register`.
Je to volatilní řídicí registr: TeltoCharge ho po výpadku komunikace sám
přepíše na failsafe (reg 20), aniž by o tom vznikl journal řádek. Kdyby se
reg 15 dropoval proti journalu (poslední „0 verified"), EMS by tichý drift
0 → 8 A NIKDY nezahlédlo (verify čte zpět jen `written` řádky) a nikdy ho
neopravilo. Proto se reg 15 re-asertuje KAŽDÝ export tick (≤ 8×/hod) —
EEPROM wear se týká jen konfiguračních 19/20, které write-on-change zůstávají.
Watchdog timer TeltoCharge sytí jakákoli validní Modbus komunikace (i FC3
čtení telemetrie každých 60 s), takže periodické zápisy k udržení spojení
NEJSOU potřeba; failsafe/timeout (19/20) per charger z DB.
"""
a = int(current_a)
if a < 6:
a = 0
return [
(TELTO_REG_AMPS_TO_USE, "telto_amps_to_use", min(a, 32)),
(TELTO_REG_COMM_TIMEOUT_S, "telto_comm_timeout_s", int(comm_timeout_s)),
(TELTO_REG_FAILSAFE_CURRENT_A, "telto_failsafe_a", max(0, min(int(failsafe_a), 32))),
]
def _split_amps_and_watchdog(
registers: list[tuple[int, str, int]],
) -> tuple[list[tuple[int, str, int]], list[tuple[int, str, int]]]:
"""Rozdělí registry na (reg 15 = vždy zapsat) a (19/20 = write-on-change)."""
amps = [r for r in registers if r[0] == TELTO_REG_AMPS_TO_USE]
watchdog = [r for r in registers if r[0] != TELTO_REG_AMPS_TO_USE]
return amps, watchdog
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:
from services.control.modbus_journal import (
_drop_registers_matching_last_verified,
_fetch_device_state_registers,
create_modbus_commands,
execute_modbus_commands,
)
rows = await db.fetch(
"""
SELECT ec.id AS asset_id, ec.code, se.host, se.port, se.unit_id,
ec.watchdog_failsafe_a, ec.watchdog_comm_timeout_s
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"
written = 0
for row in rows:
code = row["code"]
asset_id = int(row["asset_id"])
host = str(row["host"])
port = int(row["port"] or 502)
unit_id = int(row["unit_id"] if row["unit_id"] is not None else 1)
current_a = _current_limit_for_charger(code, setpoints)
registers = _telto_setpoint_registers(
current_a,
comm_timeout_s=int(
row["watchdog_comm_timeout_s"]
if row["watchdog_comm_timeout_s"] is not None
else TELTO_WATCHDOG_TIMEOUT_S
),
failsafe_a=int(
row["watchdog_failsafe_a"]
if row["watchdog_failsafe_a"] is not None
else TELTO_WATCHDOG_FAILSAFE_A
),
)
amps_regs, watchdog_regs = _split_amps_and_watchdog(registers)
# Reg 15 = vždy (re-asert proti tichému watchdog failsafe driftu na
# zařízení, který nemá journal řádek). Reg 19/20 = write-on-change
# proti fn_modbus_device_state_map (poslední written/verified stav).
device_state = await _fetch_device_state_registers(
site_id, asset_id, db, asset_type="ev_charger"
)
watchdog_regs, skipped = _drop_registers_matching_last_verified(
watchdog_regs, device_state
)
to_write = amps_regs + watchdog_regs
if not to_write:
logger.debug("EV setpoint [%s]: beze změny (%s A)", code, current_a)
continue
cmd_ids = await create_modbus_commands(
site_id,
None,
"ev_charger",
asset_id,
code,
host,
port,
unit_id,
to_write,
db,
)
ok = await execute_modbus_commands(cmd_ids, db)
written += 1
logger.info(
"EV setpoint [%s]: %s A (regs %s%s) -> %s",
code,
current_a,
[r for r, _, _ in to_write],
f", skip {skipped}" if skipped else "",
"written" if ok else "FAILED",
)
return f"OK EV: {written}/{len(rows)} charger(s) written"
async def write_ev_arrival_hold(
site_id: int, charger_code: str, db: asyncpg.Connection
) -> bool:
"""Okamžitě po DETEKCI příjezdu zapsat 0 A na daný wallbox (přes journal).
TeltoCharge po připojení kabelu sám rozjede nabíjení svým defaultem —
nabíjet smí až PLÁN (replan + export běží hned poté v _on_ev_arrival,
takže držení trvá sekundy až ~1 min). Write-on-change: registry shodné
s posledním written/verified stavem (typicky watchdog 19/20, často
i 15=0) se přeskočí — žádný zbytečný zápis při každém píchnutí kabelu.
"""
from services.control.modbus_journal import (
_drop_registers_matching_last_verified,
_fetch_device_state_registers,
create_modbus_commands,
execute_modbus_commands,
)
row = await db.fetchrow(
"""
SELECT ec.id AS asset_id, ec.code, se.host, se.port, se.unit_id,
ec.watchdog_failsafe_a, ec.watchdog_comm_timeout_s
FROM ems.asset_ev_charger ec
JOIN ems.site_endpoint se ON se.id = ec.endpoint_id
WHERE ec.site_id = $1
AND ec.code = $2
AND ec.schedulable = true
AND se.enabled = true
AND se.endpoint_type = 'modbus_tcp'
""",
site_id,
charger_code,
)
if row is None:
return False
asset_id = int(row["asset_id"])
registers = _telto_setpoint_registers(
0,
comm_timeout_s=int(
row["watchdog_comm_timeout_s"]
if row["watchdog_comm_timeout_s"] is not None
else TELTO_WATCHDOG_TIMEOUT_S
),
failsafe_a=int(
row["watchdog_failsafe_a"]
if row["watchdog_failsafe_a"] is not None
else TELTO_WATCHDOG_FAILSAFE_A
),
)
amps_regs, watchdog_regs = _split_amps_and_watchdog(registers)
# Reg 15 = 0 A se zapíše VŽDY (tvrdé zastavení po píchnutí kabelu; wallbox
# po připojení sám rozjíždí nabíjení defaultem). Reg 19/20 write-on-change.
device_state = await _fetch_device_state_registers(
site_id, asset_id, db, asset_type="ev_charger"
)
watchdog_regs, skipped = _drop_registers_matching_last_verified(
watchdog_regs, device_state
)
to_write = amps_regs + watchdog_regs
cmd_ids = await create_modbus_commands(
site_id,
None,
"ev_charger",
asset_id,
str(row["code"]),
str(row["host"]),
int(row["port"] or 502),
int(row["unit_id"] if row["unit_id"] is not None else 1),
to_write,
db,
)
ok = await execute_modbus_commands(cmd_ids, db)
logger.info(
"EV arrival hold [%s]: 0 A (regs %s%s) %s",
charger_code,
[r for r, _, _ in to_write],
f", skip {skipped}" if skipped else "",
"written" if ok else "FAILED",
)
return bool(ok)
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"