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>
341 lines
12 KiB
Python
341 lines
12 KiB
Python
"""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, 6–32 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"
|