Files
ems/backend/services/telemetry_collector.py
Dusan Vojacek fc6d9833a7 feat(ev): geofence arrival trigger (default-off)
ev_vehicle_obs.trigger += 'geofence_arrival' (V109); presence cesta zapíše příjezd
i bez píchnutí (za flagem EV_GEOFENCE_ARRIVAL_OBS_ENABLED, default OFF); fn_ev_build_trips
páruje. Constraint name ověřen živě. Worktree agent.

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

1073 lines
43 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.
"""Sběr telemetrie z Modbus: Deye (střídač), Teltonika TeltoCharge (EV); TČ zatím placeholder."""
from __future__ import annotations
import asyncio
import logging
import time
from datetime import datetime, timezone
import asyncpg
from app.ws_manager import manager
from zoneinfo import ZoneInfo
from services.modbus_client import get_modbus_client
_PRAGUE_TZ_NOTIFY = ZoneInfo("Europe/Prague")
logger = logging.getLogger(__name__)
#: Pool pro fire-and-forget akce mimo hlavní poll spojení (např. replan po
#: příjezdu EV). Nastavuje run_telemetry_loop_wrapper.
_BG_POOL: asyncpg.Pool | None = None
# ---------------------------------------------------------------------------
# Idle-skip zápisů telemetrie (slabý server)
# ---------------------------------------------------------------------------
#: Heartbeat: max. rozestup uložených vzorků při idle. 840 s < 900 s → každý
#: 15min bucket má ≥1 řádek (latest view, TUV teplota, teplota bazénu).
IDLE_SKIP_MAX_GAP_S = 840.0
#: klíč (tabulka, asset_id) → (signature, last_stored_at epoch s)
_IDLE_SKIP_STATE: dict[tuple[str, int], tuple[object, float]] = {}
def _idle_skip(
key: tuple[str, int],
signature: object,
is_active: bool,
now: float,
max_gap_s: float = IDLE_SKIP_MAX_GAP_S,
) -> bool:
"""True = vzorek PŘESKOČIT (idle, signature beze změny, heartbeat neuplynul).
Ukládá se vždy když: klíč je po startu procesu neznámý; zařízení je aktivní;
signature se změnila; nebo od posledního uložení uplynulo > max_gap_s.
Čtecí dotazy nad takto řidšími tabulkami musí používat sumy / gapfill,
ne avg přes přítomné řádky — viz docs/04-modules/telemetry.md (Idle-skip).
Střídač (telemetry_inverter) se NIKDY nepřeskakuje.
"""
state = _IDLE_SKIP_STATE.get(key)
if (
state is None
or is_active
or signature != state[0]
or now - state[1] > max_gap_s
):
_IDLE_SKIP_STATE[key] = (signature, now)
return False
return True
def _sig_round(value: float | None, step: float) -> float | None:
"""Kvantizace hodnoty do idle-skip signature (None propouští)."""
if value is None:
return None
return round(round(value / step) * step, 3)
#: In-memory poslední pozorovaný status EV konektoru (charger_id, connector_id).
#: Detekce příjezdu/odjezdu nesmí stát na posledním ŘÁDKU v DB — idle-skip řádky
#: ředí. Po startu procesu se seeduje z vw_latest_ev_charger (přechod během
#: výpadku backendu se pozná; prázdná DB → žádný falešný příjezd).
_EV_LAST_STATUS: dict[tuple[int, int], str] = {}
async def _on_ev_arrival(site_id: int, charger_code: str) -> None:
"""Okamžitý replan + export po příjezdu EV (jinak by se čekalo až na */15).
Deferred importy: planning_engine/control_exporter importují control balík,
který se importuje nezávisle — vyhýbáme se importnímu cyklu při startu.
"""
if _BG_POOL is None:
logger.warning("EV arrival: BG pool není k dispozici, čeká se na rolling tick")
return
try:
from services.control_exporter import export_setpoints
from services.planning_engine import run_rolling_replan
async with _BG_POOL.acquire() as conn:
# Wallbox po píchnutí sám rozjíždí nabíjení svým defaultem — držet
# 0 A, dokud nerozhodne plán (export běží hned po replanu níže).
try:
from services.control.outputs import write_ev_arrival_hold
await write_ev_arrival_hold(site_id, charger_code, conn)
except Exception:
logger.exception(
"EV arrival hold failed (site=%s, %s) — WB pojede defaultem do exportu",
site_id, charger_code,
)
# Tesla: skutečné SoC do session PŘED replanem (selhání nesmí blokovat plán)
try:
await _patch_session_from_tesla(site_id, charger_code, conn)
except Exception:
logger.exception(
"Tesla SoC fetch failed (site=%s, %s) — replan jede na defaultech",
site_id, charger_code,
)
await run_rolling_replan(
site_id, conn, triggered_by=f"ev_arrival:{charger_code}"
)
await export_setpoints(site_id, conn)
try:
from services.ev_notify import send_ev_arrival
await send_ev_arrival(site_id, charger_code, conn)
except Exception:
logger.exception("EV arrival Discord notify failed (%s)", charger_code)
logger.info(
"EV arrival replan+export done (site=%s, charger=%s)",
site_id,
charger_code,
)
except Exception:
logger.exception(
"EV arrival replan failed (site=%s, charger=%s)", site_id, charger_code
)
async def _on_ev_departure(site_id: int, charger_code: str) -> None:
"""Odjezd: zapsat pozorování (odometer+SoC) — auto právě jede, je vzhůru.
Pár odjezd→příjezd dává jízdu (km, kWh) pro ev_usage_stats. Spící/nečitelné
auto (408) = tiché přeskočení, jízda se dopočítá z příštích pozorování.
"""
if _BG_POOL is None:
return
try:
from app.db_json import fetch_json
from services.tesla_client import get_charge_state
async with _BG_POOL.acquire() as conn:
ctx = await fetch_json(
conn,
"select ems.fn_tesla_arrival_context($1::int, $2::text)",
site_id,
charger_code,
)
if not isinstance(ctx, dict) or ctx.get("api_type") != "tesla":
return
state = await get_charge_state(conn, ctx.get("vin"))
if state is None:
return
await conn.execute(
"select ems.fn_ev_vehicle_obs_insert($1::int, $2::int, 'departure', $3::numeric, $4::numeric, $5::text)",
site_id,
int(ctx["vehicle_id"]),
state.get("odometer_km"),
float(state["battery_level"]),
state.get("charging_state"),
)
logger.info(
"EV departure obs (site=%s, %s): soc=%s%%, odo=%s km",
site_id, charger_code,
state["battery_level"], state.get("odometer_km"),
)
except Exception:
logger.exception(
"EV departure obs failed (site=%s, %s)", site_id, charger_code
)
async def _patch_session_from_tesla(
site_id: int, charger_code: str, conn: asyncpg.Connection
) -> None:
"""Po příjezdu: pro vozidlo s api_type='tesla' doplnit reálné SoC do session.
Energie se NEpočítá tady — soc_at_connect_pct + target_soc_pct si přebere
fn_planning_site_context (SQL-first). VIN se při prvním úspěchu naučí.
"""
from app.db_json import fetch_json
from services.tesla_client import get_charge_state
ctx = await fetch_json(
conn,
"select ems.fn_tesla_arrival_context($1::int, $2::text)",
site_id,
charger_code,
)
if not isinstance(ctx, dict) or ctx.get("api_type") != "tesla":
return
session_id = ctx.get("session_id")
if session_id is None:
logger.warning("Tesla hook: chybí otevřená session (%s)", charger_code)
return
state = await get_charge_state(conn, ctx.get("vin"))
if state is None:
return
await conn.execute(
"select ems.fn_ev_vehicle_obs_insert($1::int, $2::int, 'arrival', $3::numeric, $4::numeric, $5::text)",
site_id,
int(ctx["vehicle_id"]),
state.get("odometer_km"),
float(state["battery_level"]),
state.get("charging_state"),
)
if not ctx.get("vin") and state.get("vin"):
await conn.execute(
"select ems.fn_vehicle_set_vin($1::int, $2::text)",
int(ctx["vehicle_id"]),
str(state["vin"]),
)
patch: dict = {"soc_at_connect_pct": state["battery_level"]}
# Tesla charge_limit_soc je STROP (nad něj auto nenabije), NE cíl —
# cíl drží kaskáda fn_ev_session_defaults (weekly/forecast/default
# vozidla, např. 30 %). Snižovat target jen když limit auta je POD ním.
limit = state.get("charge_limit_soc")
current_target = ctx.get("target_soc_pct")
if limit and current_target is not None and float(current_target) > float(limit):
patch["target_soc_pct"] = limit
import json as _json
await fetch_json(
conn,
"select ems.fn_ev_session_apply_patch($1::int, $2::int, $3::jsonb)",
site_id,
int(session_id),
_json.dumps(patch),
)
logger.info(
"Tesla SoC -> session %s: level=%s%%, limit=%s%% (%s)",
session_id,
state["battery_level"],
state.get("charge_limit_soc"),
charger_code,
)
# Deye SUN holding registry (decimal adresa = přímo pro read_holding_registers)
DEYE_REG_RUN_STATE = 500
DEYE_REG_BATT_CHARGE_TODAY = 514
DEYE_REG_BATT_DISCHARGE_TODAY = 515
DEYE_REG_BATTERY_SOC = 588
DEYE_REG_BATTERY_POWER_FLOW = 590
DEYE_REG_GRID_TOTAL_POWER = 625 # tok na grid svorkách střídače
DEYE_REG_GRID_CT_TOTAL_POWER = 619 # tok na externím CT (= ulice; jen instalace s CT)
DEYE_REG_GEN_PORT_POWER = 667
DEYE_REG_LOAD_TOTAL_POWER = 653
DEYE_REG_GRID_IMPORT_TOTAL_LO = 522
DEYE_REG_GRID_IMPORT_TOTAL_HI = 523
DEYE_REG_GRID_EXPORT_TOTAL_LO = 524
DEYE_REG_GRID_EXPORT_TOTAL_HI = 525
DEYE_REG_PV1_POWER = 672
DEYE_REG_PV2_POWER = 673
# Solar sell (0 = přebytek řiditelné FVE nesmí do sítě) a GEN/MI cut-off (reg178 bits01 == 3 → cut-off ON).
# Pozn.: v některých manuálech/UI se uvádí "register 179" (1-based), ale Modbus adresa je 178 (0-based).
# Viz modbus-registers.md.
DEYE_REG_SOLAR_SELL = 145
DEYE_REG_CONTROL_BOARD_SPECIAL1 = 178
# Teltonika TeltoCharge holding registry (oficiální „Modbus RTU Communication
# protocol" rev 0.5, 2024-07-23; přes Waveshare RS485→TCP, FC 3 čtení).
# Blok 040: 02 napětí L1L3 (V), 35 proud L1L3 (×10 A), 6 EVSE status (DLM),
# 27 charge point state, 33 IEC61851, 34/35 warning/error bity,
# 38 okamžitý výkon (W), 39 energie session (kWh×100), 40 trvání session (s).
# Zápisové (řízení, zatím nepoužité): 15 Amps to use (0=stop, 632), 16 start/stop.
TELTO_REG_BLOCK_START = 0
TELTO_REG_BLOCK_COUNT = 41
#: Backoff pro nedosažitelný wallbox: čtení mrtvého unit_id drží exkluzivní
#: zámek brány až (retries+1)×timeout = 4×8 = 32 s (pymodbus) — každou minutu.
#: Po EV_POLL_FAIL_THRESHOLD selháních v řadě se poll daného (host,port,unit)
#: zkouší jen 1× za EV_POLL_BACKOFF_S; úspěšné čtení backoff resetuje.
EV_POLL_FAIL_THRESHOLD = 3
EV_POLL_BACKOFF_S = 300.0
_EV_POLL_FAIL_STREAK: dict[tuple[str, int, int], int] = {}
_EV_POLL_NEXT_ATTEMPT: dict[tuple[str, int, int], float] = {}
def _ev_poll_should_skip(key: tuple[str, int, int], now_mono: float) -> bool:
return (
_EV_POLL_FAIL_STREAK.get(key, 0) >= EV_POLL_FAIL_THRESHOLD
and now_mono < _EV_POLL_NEXT_ATTEMPT.get(key, 0.0)
)
def _ev_poll_record_failure(key: tuple[str, int, int], now_mono: float) -> int:
streak = _EV_POLL_FAIL_STREAK.get(key, 0) + 1
_EV_POLL_FAIL_STREAK[key] = streak
if streak >= EV_POLL_FAIL_THRESHOLD:
_EV_POLL_NEXT_ATTEMPT[key] = now_mono + EV_POLL_BACKOFF_S
return streak
def _ev_poll_record_success(key: tuple[str, int, int]) -> None:
_EV_POLL_FAIL_STREAK.pop(key, None)
_EV_POLL_NEXT_ATTEMPT.pop(key, None)
#: EVSE status (reg 6) → interní stav; session detekce stojí na 'available' vs ≠'available'
#: (fn_ev_session_transition), proto každý stav s připojeným EV musí být ≠ 'available'.
TELTO_STATUS_MAP = {
0: "charging", # C nabíjí
1: "preparing", # B1 EV připojeno, čeká na EV
2: "preparing", # B2 dříve nabíjelo, nedostatek výkonu
3: "preparing", # B3 nenabíjelo, nedostatek výkonu
4: "suspended_ev", # D1 zastaveno vozidlem
5: "suspended_evse", # D2 bez autorizace
6: "suspended_evse", # D3 nabíjení nepovoleno
7: "available", # A bez EV
8: "faulted", # F chyba
9: "unknown", # E
}
def parse_teltocharge_frame(regs: list[int]) -> dict[str, object]:
"""Čistý parser bloku registrů 040 TeltoCharge (testovatelné bez Modbus)."""
if len(regs) < TELTO_REG_BLOCK_COUNT:
raise ValueError(f"TeltoCharge frame too short: {len(regs)}")
status = TELTO_STATUS_MAP.get(int(regs[6]), "unknown")
current_a = max(int(regs[3]), int(regs[4]), int(regs[5])) / 10.0
return {
"status": status,
"power_w": int(regs[38]),
"session_energy_kwh": int(regs[39]) / 100.0,
"current_a": current_a,
"voltage_v": int(regs[0]),
"warning_bits": int(regs[34]),
"error_bits": int(regs[35]),
"evse_status_raw": int(regs[6]),
"charge_point_state_raw": int(regs[27]),
}
def aggregate_pv_production_w(pv1_w: int, pv2_w: int, gen_port_w: int) -> int:
"""
Okamžitá „výroba FVE“ pro dashboard / audit součtu: Deye registry 672/673/667
jsou int16 W; záporné hodnoty (např. večer při exportu) nejsou DC výroba.
"""
return max(0, int(pv1_w)) + max(0, int(pv2_w)) + max(0, int(gen_port_w))
def _export_limit_flags_from_deye_regs(reg145: int | None, reg179: int | None) -> tuple[bool | None, int | None]:
"""Odvoď is_export_limited / pv_derating_flags z přečtených holding registrů (NULL = neznámé)."""
if reg145 is None and reg179 is None:
return None, None
flags = 0
if reg145 is not None and int(reg145) == 0:
flags |= 1
if reg179 is not None and (int(reg179) & 3) == 3:
flags |= 2
return (flags != 0), flags
async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
rows = await db.fetch(
"""
select inverter_id as id, code, host, port, unit_id, deye_zero_export_mode
from ems.vw_asset_inverter_modbus_poll
where site_id = $1
""",
site_id,
)
measured_at = datetime.now(timezone.utc)
for row in rows:
inv_id = row["id"]
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)
try:
client = await get_modbus_client(host, port)
async with client.batch(unit_id) as mb:
run_state = await mb.read_register(DEYE_REG_RUN_STATE)
battery_soc = await mb.read_register(DEYE_REG_BATTERY_SOC)
battery_power = await mb.read_register_signed(DEYE_REG_BATTERY_POWER_FLOW)
batt_charge_today = await mb.read_register(DEYE_REG_BATT_CHARGE_TODAY)
batt_discharge_today = await mb.read_register(DEYE_REG_BATT_DISCHARGE_TODAY)
inverter_grid_port_w = await mb.read_register_signed(DEYE_REG_GRID_TOTAL_POWER)
ups_load_w = await mb.read_register_signed(DEYE_REG_LOAD_TOTAL_POWER)
grid_ct_w = await mb.read_register_signed(DEYE_REG_GRID_CT_TOTAL_POWER)
pv1_power = await mb.read_register_signed(DEYE_REG_PV1_POWER)
pv2_power = await mb.read_register_signed(DEYE_REG_PV2_POWER)
gen_port_power = await mb.read_register_signed(DEYE_REG_GEN_PORT_POWER)
grid_energy_regs = await mb.read_holding_registers(
DEYE_REG_GRID_IMPORT_TOTAL_LO, 4
)
reg145 = await mb.read_register(DEYE_REG_SOLAR_SELL)
reg179 = await mb.read_register(DEYE_REG_CONTROL_BOARD_SPECIAL1)
pv_power_w = aggregate_pv_production_w(pv1_power, pv2_power, gen_port_power)
# Ulice: instalace s CT (deye_zero_export_mode=2) čte reg 619; bez CT
# zůstává reg 625. Okruhy MEZI střídačem a CT (home-01: wallbox,
# kuchyň…) jsou vidět jen v CT — reg 625/653 je nezahrnují.
has_ct = int(row["deye_zero_export_mode"] or 1) == 2
grid_power = grid_ct_w if has_ct else inverter_grid_port_w
# Celková spotřeba domu = pv + baterie(+vybíjí) + grid(+import).
load_power = max(0, pv_power_w + battery_power + grid_power)
grid_import_total_wh = (grid_energy_regs[1] << 16 | grid_energy_regs[0]) * 100
grid_export_total_wh = (grid_energy_regs[3] << 16 | grid_energy_regs[2]) * 100
is_export_limited, pv_derating_flags = _export_limit_flags_from_deye_regs(reg145, reg179)
logger.debug("inverter:%s Deye run_state raw=%s", code, run_state)
await db.execute(
"select ems.fn_telemetry_inverter_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::int, $6::int, $7::int, $8::float8, $9::int, $10::int, $11::int, $12::int, $13::int, $14::bigint, $15::bigint, $16::int, $17::boolean, $18::int, $19::int, $20::int)",
site_id,
inv_id,
measured_at,
pv_power_w,
pv1_power,
pv2_power,
gen_port_power,
float(battery_soc),
battery_power,
batt_charge_today,
batt_discharge_today,
grid_power,
load_power,
grid_import_total_wh,
grid_export_total_wh,
run_state,
is_export_limited,
pv_derating_flags,
inverter_grid_port_w,
ups_load_w,
)
inv_temp: float | None = None
await manager.broadcast_telemetry(
{
"type": "telemetry",
"site_id": site_id,
"ts": datetime.now(timezone.utc).isoformat(),
"pv_power_w": pv_power_w,
"battery_soc_pct": float(battery_soc),
"battery_power_w": battery_power,
"grid_power_w": grid_power,
"load_power_w": load_power,
"gen_port_power_w": gen_port_power,
"inverter_temp_c": inv_temp,
"is_export_limited": is_export_limited,
"pv_derating_flags": pv_derating_flags,
}
)
except Exception as e:
logger.error("poll_inverter site=%s inverter=%s: %s", site_id, code, e)
async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None:
rows = await db.fetch(
"""
select charger_id as id, code, host, port, unit_id
from ems.vw_asset_ev_charger_modbus_poll
where site_id = $1
""",
site_id,
)
measured_at = datetime.now(timezone.utc)
connector_id = 1
for row in rows:
code = row["code"]
charger_id = row["id"]
host = row["host"]
port = int(row["port"] or 502)
unit_id = int(row["unit_id"] if row["unit_id"] is not None else 1)
poll_key = (str(host), port, unit_id)
now_mono = time.monotonic()
if _ev_poll_should_skip(poll_key, now_mono):
logger.debug(
"EV charger %s (%s:%s u%s) in backoff, poll skipped",
code, host, port, unit_id,
)
continue
try:
client = await get_modbus_client(host, port)
async with client.batch(unit_id) as mb:
regs = await mb.read_holding_registers(
TELTO_REG_BLOCK_START, TELTO_REG_BLOCK_COUNT
)
frame = parse_teltocharge_frame(regs)
except Exception as e:
# Při výpadku čtení NIC nezapisovat — fabrikovaný 'available' by
# falešně ukončoval EV session a špinil bazál (power 0).
streak = _ev_poll_record_failure(poll_key, time.monotonic())
backoff = (
f" (streak {streak} >= {EV_POLL_FAIL_THRESHOLD}, "
f"backoff {EV_POLL_BACKOFF_S:.0f}s — neblokovat bránu)"
if streak >= EV_POLL_FAIL_THRESHOLD
else ""
)
logger.warning(
"EV charger %s (%s:%s u%s) read failed: %s%s",
code, host, port, unit_id, e, backoff,
)
continue
_ev_poll_record_success(poll_key)
current_status = str(frame["status"])
if frame["error_bits"]:
logger.warning(
"EV charger %s error bits=0x%04x warning=0x%04x",
code, frame["error_bits"], frame["warning_bits"],
)
state_key = (int(charger_id), connector_id)
previous_status = _EV_LAST_STATUS.get(state_key)
if previous_status is None:
# Start procesu: seed z posledního uloženého řádku (stejná sémantika
# jako dřívější čtení z telemetry_ev_charger; read přes view).
previous_status = await db.fetchval(
"""
select status from ems.vw_latest_ev_charger
where charger_id = $1 and connector_id = $2
""",
charger_id,
connector_id,
)
power_w = int(frame["power_w"])
is_active = current_status != "available" or power_w > 50
signature = (current_status, round(power_w / 100.0) * 100)
if not _idle_skip(
("telemetry_ev_charger", int(charger_id)),
signature,
is_active,
measured_at.timestamp(),
):
await db.execute(
"select ems.fn_telemetry_ev_charger_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::text, $6::int, $7::float8, $8::float8)",
site_id,
charger_id,
measured_at,
connector_id,
current_status,
power_w,
float(frame["session_energy_kwh"]),
float(frame["current_a"]),
)
_EV_LAST_STATUS[state_key] = current_status
if previous_status is not None and str(previous_status) != current_status:
await db.fetchval(
"select ems.fn_ev_session_transition($1::int, $2::int, $3::text, $4::text, $5::timestamptz)",
site_id,
charger_id,
str(previous_status),
current_status,
measured_at,
)
if previous_status == "available" and current_status != "available":
logger.info("EV arrival detected on charger %s", code)
asyncio.create_task(_on_ev_arrival(site_id, str(code)))
elif previous_status != "available" and current_status == "available":
logger.info("EV departure detected on charger %s", code)
asyncio.create_task(_on_ev_departure(site_id, str(code)))
# Samsung MIM-B19N(T) — Modbus RTU slave za RS485→TCP (9600 8E1!).
# Adresace: vnitřní jednotka IU má blok base = 50 + IU*50; zde IU 0 → 50..99.
# Plný popis: docs/04-modules/modbus-registers-mim-b19n.md
MIM_IU_BASE = 50 # blok vnitřní jednotky 0
MIM_OFF_COMM_STATUS = 0 # b0 exist, b1 type OK, b2 ready, b3 comm error
MIM_OFF_UNIT_TYPE = 1 # lower byte: 110=HE, 115-117=EHS, 120=HT
MIM_OFF_ONOFF = 2
MIM_OFF_MODE = 3 # 0 auto, 1 cool, 4 heat
MIM_OFF_ROOM_TEMP = 9 # °C×10 signed
MIM_OFF_ERROR_CODE = 13 # 0 = OK, 100-999 kód
MIM_OFF_WATER_IN = 15 # °C×10 signed
MIM_OFF_WATER_OUT = 16 # °C×10 signed
MIM_OFF_DHW_ONOFF = 22
MIM_OFF_DHW_TEMP = 25 # °C×10 (zásobník TUV)
MIM_REG_DEFROST = 2 # modulový registr: 0 i 0xFF = off, jinak defrost
MIM_MODE_NAMES = {0: "auto", 1: "cool", 2: "dry", 3: "fan", 4: "heat"}
def _mim_temp_c(raw: int) -> float | None:
"""°C×10, signed 16bit; MIM drží 0 dokud jednotka hodnotu nedodá."""
v = raw - 65536 if raw > 32767 else raw
return round(v / 10.0, 1)
def mim_operating_mode(on: int, mode: int, dhw_on: int, comm_ready: bool, error: int) -> str:
if not comm_ready:
return "offline"
if error:
return "error"
parts = []
if int(on) == 1:
parts.append(MIM_MODE_NAMES.get(int(mode), f"mode{mode}"))
if int(dhw_on) == 1:
parts.append("dhw")
return "+".join(parts) if parts else "off"
async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None:
rows = await db.fetch(
"""
select heat_pump_id as id, code, host, port, unit_id
from ems.vw_asset_heat_pump_modbus_poll
where site_id = $1
""",
site_id,
)
measured_at = datetime.now(timezone.utc)
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)
try:
client = await get_modbus_client(host, port)
async with client.batch(unit_id) as mb:
iu = await mb.read_holding_registers(MIM_IU_BASE, 26)
defrost_raw = await mb.read_register(MIM_REG_DEFROST)
except Exception as e:
logger.warning("heat_pump %s: Modbus poll failed (%s)", code, e)
continue
comm = int(iu[MIM_OFF_COMM_STATUS])
comm_ready = (comm & 0b111) == 0b111
error_code = int(iu[MIM_OFF_ERROR_CODE])
mode_txt = mim_operating_mode(
iu[MIM_OFF_ONOFF], iu[MIM_OFF_MODE], iu[MIM_OFF_DHW_ONOFF],
comm_ready, error_code,
)
if not comm_ready:
# MIM odpovídá, ale jednotka není ztrackovaná (b0-b2) — telemetrii
# nezapisovat (samé nuly), jen log; trvalý stav = špatná adresa IU
# nebo SEG5 "Use of central control" vypnuté.
logger.warning(
"heat_pump %s: jednotka není ready (comm_status=%s) — vzorek přeskočen",
code, bin(comm),
)
continue
water_out_c = _mim_temp_c(iu[MIM_OFF_WATER_OUT])
dhw_temp_c = _mim_temp_c(iu[MIM_OFF_DHW_TEMP])
water_in_c = _mim_temp_c(iu[MIM_OFF_WATER_IN])
room_temp_c = _mim_temp_c(iu[MIM_OFF_ROOM_TEMP])
# manuál MIM: 0 i 0xFF = defrost OFF, ostatní hodnoty = ON
defrost = int(defrost_raw) not in (0, 0xFF)
# Idle-skip: TČ vypnuté a teploty (na 0.2 °C) beze změny → heartbeat;
# pomalý drift teplot zachytí heartbeat 840 s (TUV delta se normalizuje
# per minutu ve fn_update_tuv_usage_stats).
is_active = defrost or mode_txt not in ("off",)
signature = (
mode_txt,
_sig_round(water_out_c, 0.2),
_sig_round(dhw_temp_c, 0.2),
_sig_round(water_in_c, 0.2),
_sig_round(room_temp_c, 0.2),
)
if _idle_skip(
("telemetry_heat_pump", int(row["id"])),
signature,
is_active,
measured_at.timestamp(),
):
continue
await db.execute(
"select ems.fn_telemetry_heat_pump_sample("
"$1::int, $2::int, $3::timestamptz, $4::int, $5::float8, $6::float8,"
" $7::float8, $8::text, $9::float8, $10::float8, $11::boolean, $12::int)",
site_id,
row["id"],
measured_at,
None, # příkon: MIM neměří — doplní elektroměr (Shelly/Chint)
None, # venkovní teplota: v MIM mapě není
water_out_c,
dhw_temp_c,
mode_txt,
water_in_c,
room_temp_c,
defrost,
error_code,
)
if error_code:
logger.warning("heat_pump %s: error code %s", code, error_code)
async def poll_loxone_sensors(site_id: int, db: asyncpg.Connection) -> None:
"""Čidla z Loxone (teplota bazénu, akumulační nádrže…): GET /jdev/sps/io/<name>/state.
Endpoint = site loxone_http; auth LOXONE_USER/PASSWORD (env). Hodnota
z LL.value ("23.5°" → 23.5). Bez čidel v ems.loxone_sensor no-op.
"""
rows = await db.fetch(
"""
select ls.id, ls.loxone_name, se.host, se.port, se.protocol
from ems.loxone_sensor ls
join ems.site_endpoint se
on se.site_id = ls.site_id and se.endpoint_type = 'loxone_http' and se.enabled
where ls.site_id = $1 and ls.enabled
""",
site_id,
)
if not rows:
return
import os
import re as _re
import httpx
auth = None
user = os.getenv("LOXONE_USER") or ""
if user:
auth = (user, os.getenv("LOXONE_PASSWORD") or "")
measured_at = datetime.now(timezone.utc)
async with httpx.AsyncClient(timeout=5.0, auth=auth) as client:
for r in rows:
proto = (r["protocol"] or "http").lower()
port = int(r["port"] or (443 if proto == "https" else 80))
url = f"{proto}://{r['host']}:{port}/jdev/sps/io/{r['loxone_name']}/state"
try:
resp = await client.get(url)
resp.raise_for_status()
raw = str((resp.json().get("LL") or {}).get("value", ""))
m = _re.search(r"-?\d+(?:[.,]\d+)?", raw)
if m is None:
continue
value = float(m.group(0).replace(",", "."))
except Exception as e:
logger.warning("Loxone sensor %s read failed: %s", r["loxone_name"], e)
continue
# Idle-skip: čidlo nemá „aktivitu" — ukládá se změna ≥ 0.1 / heartbeat.
if _idle_skip(
("telemetry_loxone_sensor", int(r["id"])),
round(value, 1),
False,
measured_at.timestamp(),
):
continue
await db.execute(
"""
insert into ems.telemetry_loxone_sensor (sensor_id, measured_at, value)
values ($1, $2, $3) on conflict do nothing
""",
int(r["id"]),
measured_at,
value,
)
#: presence poll pacing (sekundy) a geofence poloměr (m).
#: Fleet API je placené (vehicle_data $0.002/req, wake $0.02 — wake NIKDY):
#: list 10 min; vehicle_data jen při přechodu asleep→online a pak max 1×/15 min;
#: při otevřené ev_session se nepolluje vůbec (auto je u wallboxu = doma,
#: a při AC nabíjení nespí — bez gatu by data cally tekly celou noc).
EV_PRESENCE_POLL_S = 600
EV_PRESENCE_DATA_MIN_S = 900
EV_PRESENCE_HOME_RADIUS_M = 150
#: anti-spam "píchni auto": min. rozestup notifikací per vozidlo (s)
EV_PLUG_NUDGE_COOLDOWN_S = 2 * 3600
_EV_PRESENCE_LAST_POLL: dict[int, float] = {}
_EV_PRESENCE_LAST_DATA: dict[int, float] = {}
_EV_PRESENCE_LAST_STATE: dict[int, str] = {}
_EV_PLUG_NUDGE_LAST: dict[int, float] = {}
#: Geofence arrival obs (trigger='geofence_arrival') — příjezd domů BEZ píchnutí
#: do wallboxu. DEFAULT VYPNUTO (env EV_GEOFENCE_ARRIVAL_OBS_ENABLED=true zapne);
#: vypnuté = funkce běží jako dřív, jen se nový obs nezapisuje (golden gate /
#: plánovač beze změny). Debounce: vyžaduje N po sobě jdoucích čtení at_home=true
#: (GPS jitter u 150m hranice nesmí jeden flip brát jako příjezd). Dedup: emituje
#: jen jednou na epizodu (po emitu se "odzbrojí", znovu se "nabije" až po odjezdu);
#: a vůbec neběží, když je auto na wallboxu (plug-in cesta je autoritativní —
#: poll_tesla_presence se při otevřené session vrací dřív, viz `plugged`).
EV_GEOFENCE_ARRIVAL_CONFIRM_SAMPLES = 2
_EV_GEOFENCE_HOME_STREAK: dict[int, int] = {}
_EV_GEOFENCE_ARMED: dict[int, bool] = {}
def _ev_geofence_obs_enabled() -> bool:
"""Feature flag: zápis geofence_arrival obs (default false → inertní)."""
import os
return (os.getenv("EV_GEOFENCE_ARRIVAL_OBS_ENABLED") or "").strip().lower() in (
"1", "true", "yes", "on",
)
def ev_presence_transition(prev_at_home: bool | None, new_at_home: bool | None) -> str | None:
"""Čistá detekce přechodu: 'arrived' / 'left' / None (testovatelné)."""
if new_at_home is None or prev_at_home is None:
return None
if not prev_at_home and new_at_home:
return "arrived"
if prev_at_home and not new_at_home:
return "left"
return None
def ev_geofence_arrival_decision(
vehicle_id: int,
at_home: bool | None,
confirm_samples: int = EV_GEOFENCE_ARRIVAL_CONFIRM_SAMPLES,
) -> bool:
"""Debounce + dedup geofence příjezdu (čistá, testovatelná funkce nad stavem).
Vstup `at_home` je výsledek aktuálního geofence čtení (None = poloha neznámá,
např. auto spí → stav se NEMĚNÍ). Vrací True právě jednou za epizodu příjezdu,
a to až po `confirm_samples` po sobě jdoucích čteních at_home=true:
- at_home is None → neznámé, streak ani armed se nemění (žádné rozhodnutí).
- at_home is False → auto je pryč: vynuluj streak, "nabij" (armed=True), aby
příští potvrzený příjezd mohl emitovat.
- at_home is True → inkrementuj streak; pokud streak dosáhl prahu a jsme
armed, "odzbroj" (armed=False) a vrať True (emituj jednou).
Tím se jeden GPS flip u hranice nepočítá jako příjezd a opakovaná at_home=true
čtení během stání doma negenerují duplicitní obs.
"""
if at_home is None:
return False
if at_home is False:
_EV_GEOFENCE_HOME_STREAK[vehicle_id] = 0
_EV_GEOFENCE_ARMED[vehicle_id] = True
return False
# at_home is True
streak = _EV_GEOFENCE_HOME_STREAK.get(vehicle_id, 0) + 1
_EV_GEOFENCE_HOME_STREAK[vehicle_id] = streak
if streak >= confirm_samples and _EV_GEOFENCE_ARMED.get(vehicle_id, False):
_EV_GEOFENCE_ARMED[vehicle_id] = False
return True
return False
async def poll_tesla_presence(site_id: int, db: asyncpg.Connection) -> None:
"""Přítomnost vozidla: /vehicles state (nebudí) + při online poloha → geofence.
Přechod pryč→doma + nepíchnuté → Discord pobídka (s aktuálním přebytkem).
Vše se loguje do ev_presence_obs (budoucí dostupnostní statistika).
"""
loop_now = asyncio.get_running_loop().time()
if loop_now - _EV_PRESENCE_LAST_POLL.get(site_id, 0.0) < EV_PRESENCE_POLL_S:
return
_EV_PRESENCE_LAST_POLL[site_id] = loop_now
veh = await db.fetchrow(
"""
select v.id, v.vin, v.name, s.latitude, s.longitude
from ems.asset_vehicle v join ems.site s on s.id = v.site_id
where v.site_id = $1 and v.api_type = 'tesla' and v.active
order by v.id limit 1
""",
site_id,
)
if veh is None or veh["latitude"] is None:
return
# auto na wallboxu (otevřená session) = doma; žádné API cally (šetří $)
plugged = await db.fetchval(
"""
select exists(
select 1 from ems.ev_session es
where es.vehicle_id = $1 and es.session_end is null
)
""",
int(veh["id"]),
)
if plugged:
return
from services.tesla_client import get_charge_state, get_vehicle_api_state, haversine_m
try:
api_state = await get_vehicle_api_state(db, veh["vin"])
except Exception as e:
logger.warning("Tesla presence: state poll failed: %s", e)
return
if api_state is None:
return
prev_state = _EV_PRESENCE_LAST_STATE.get(int(veh["id"]))
_EV_PRESENCE_LAST_STATE[int(veh["id"])] = api_state
woke_up = api_state == "online" and prev_state != "online"
data_due = (
loop_now - _EV_PRESENCE_LAST_DATA.get(int(veh["id"]), 0.0)
>= EV_PRESENCE_DATA_MIN_S
)
at_home = None
distance_m = None
charging_state = None
shift_state = None
st = None
if api_state == "online" and (woke_up or data_due):
_EV_PRESENCE_LAST_DATA[int(veh["id"])] = loop_now
try:
st = await get_charge_state(db, veh["vin"])
except Exception as e:
logger.warning("Tesla presence: data read failed: %s", e)
st = None
if st is not None and st.get("latitude") is not None:
distance_m = int(
haversine_m(
float(st["latitude"]), float(st["longitude"]),
float(veh["latitude"]), float(veh["longitude"]),
)
)
at_home = distance_m <= EV_PRESENCE_HOME_RADIUS_M
charging_state = st.get("charging_state")
shift_state = st.get("shift_state")
prev = await db.fetchrow(
"""
select at_home from ems.ev_presence_obs
where vehicle_id = $1 and at_home is not null
order by observed_at desc limit 1
""",
int(veh["id"]),
)
await db.execute(
"""
insert into ems.ev_presence_obs
(vehicle_id, api_state, at_home, distance_m, charging_state, shift_state)
values ($1, $2, $3, $4, $5, $6)
""",
int(veh["id"]), api_state, at_home, distance_m, charging_state, shift_state,
)
# Geofence příjezd (auto přijelo domů, NEpíchnuté — sem se dostaneme jen když
# NENÍ otevřená session, viz `plugged` výše: wallbox je autoritativní). Debounce
# + dedup řeší ev_geofence_arrival_decision; zápis je za feature flagem (default
# off → inertní). Zapisuje se z presence readu (st), proto jen když máme st se
# SoC i odometrem, ať jízda (km z odometru) dostane platný arrival.
if _ev_geofence_obs_enabled():
emit = ev_geofence_arrival_decision(int(veh["id"]), at_home)
if emit and st is not None and st.get("battery_level") is not None:
try:
await db.execute(
"select ems.fn_ev_vehicle_obs_insert($1::int, $2::int, 'geofence_arrival', $3::numeric, $4::numeric, $5::text)",
site_id,
int(veh["id"]),
st.get("odometer_km"),
float(st["battery_level"]),
st.get("charging_state"),
)
logger.info(
"EV geofence arrival obs (site=%s, vehicle=%s): soc=%s%%, odo=%s km",
site_id, veh["id"],
st["battery_level"], st.get("odometer_km"),
)
except Exception:
logger.exception(
"EV geofence arrival obs failed (site=%s, vehicle=%s)",
site_id, veh["id"],
)
trans = ev_presence_transition(prev["at_home"] if prev else None, at_home)
if trans == "arrived" and charging_state == "Disconnected":
if loop_now - _EV_PLUG_NUDGE_LAST.get(int(veh["id"]), 0.0) < EV_PLUG_NUDGE_COOLDOWN_S:
return
_EV_PLUG_NUDGE_LAST[int(veh["id"])] = loop_now
grid_w = await db.fetchval(
"select grid_power_w from ems.vw_latest_inverter where site_id = $1 limit 1",
site_id,
)
surplus = f" — právě teče {abs(int(grid_w))/1000:.1f} kW do sítě" if grid_w and grid_w < -500 else ""
from services.notification_service import send_discord
await send_discord(
db, site_id,
f"🚗 **{veh['name'] or 'EV'} je doma a nepíchnuté**{surplus}.\n"
f"Píchni ho a plán se o zbytek postará (přebytky / levné sloty).",
level="info",
)
logger.info("EV plug nudge sent (site=%s, vehicle=%s)", site_id, veh["id"])
async def run_telemetry_loop(conn: asyncpg.Connection) -> float:
"""Jeden průchod smyčky; vrátí uplynulý čas v sekundách (pro sleep).
Poll probíhá sekvenčně — jedno asyncpg spojení nesmí obsluhovat paralelní dotazy.
"""
loop = asyncio.get_running_loop()
start = loop.time()
sites = await conn.fetch(
"select id from ems.vw_site_directory where active = true"
)
for site in sites:
sid = site["id"]
try:
await poll_inverter(sid, conn)
await poll_ev_chargers(sid, conn)
await poll_heat_pump(sid, conn)
await poll_loxone_sensors(sid, conn)
await poll_tesla_presence(sid, conn)
await poll_pool_pumps(sid, conn)
except Exception as e:
logger.error("Telemetry loop error site %s: %s", sid, e)
return loop.time() - start
async def run_telemetry_loop_wrapper(pool: asyncpg.Pool) -> None:
"""Background task: každá iterace získá spojení z poolu; neblokuje pool během sleep."""
global _BG_POOL
_BG_POOL = pool
while True:
try:
async with pool.acquire() as conn:
elapsed = await run_telemetry_loop(conn)
except asyncio.CancelledError:
raise
except Exception as e:
logger.exception("Telemetry wrapper DB error: %s", e)
elapsed = 0.0
await asyncio.sleep(5)
continue
if elapsed > 50:
logger.warning("Telemetry loop took %.1fs (>50s)", elapsed)
await asyncio.sleep(max(0.0, 60.0 - elapsed))
async def poll_pool_pumps(site_id: int, db: asyncpg.Connection) -> None:
"""Poll bazénových čerpadel přes Shelly relé (Gen2 RPC Switch.GetStatus), 60 s.
Shelly nedrží historii — stavíme ji 1min vzorky jako u ostatních zařízení.
"""
# Lokální import: minimální dotyk hlavičky souboru (souběžné změny na main).
from services.shelly_client import get_switch_status, shelly_base_url
rows = await db.fetch(
"""
select pump_id as id, code, shelly_switch_id, protocol, host, port
from ems.vw_asset_pool_pump_http_poll
where site_id = $1
""",
site_id,
)
measured_at = datetime.now(timezone.utc)
for row in rows:
code = row["code"]
base = shelly_base_url(row["protocol"], row["host"], row["port"])
try:
status = await get_switch_status(base, int(row["shelly_switch_id"] or 0))
except Exception as e:
# Při výpadku čtení NIC nezapisovat — fabrikovaná nula by špinila
# historii spotřeby (stejný princip jako u EV nabíječek výše).
logger.warning("pool pump %s (%s) read failed: %s", code, base, e)
continue
# Idle-skip: zapnuté čerpadlo (is_on) se ukládá KAŽDOU minutu —
# vw_pool_pump_day_energy.on_minutes počítá ON řádky; vypnuté jen
# při změně / heartbeatu.
power_w = status.apower_w
is_active = bool(status.output) or (power_w is not None and power_w > 5)
signature = (
bool(status.output),
None if power_w is None else int(round(power_w / 10.0)) * 10,
)
if _idle_skip(
("telemetry_pool_pump", int(row["id"])),
signature,
is_active,
measured_at.timestamp(),
):
continue
await db.execute(
"select ems.fn_telemetry_pool_pump_sample($1::int, $2::int, $3::timestamptz, $4::boolean, $5::int, $6::bigint)",
site_id,
row["id"],
measured_at,
status.output,
int(round(status.apower_w)) if status.apower_w is not None else None,
int(round(status.aenergy_total_wh)) if status.aenergy_total_wh is not None else None,
)