diff --git a/backend/services/telemetry_collector.py b/backend/services/telemetry_collector.py index 1edd41f..588d180 100644 --- a/backend/services/telemetry_collector.py +++ b/backend/services/telemetry_collector.py @@ -1,4 +1,4 @@ -"""Sběr telemetrie z Modbus (Deye) a placeholder záznamy pro EV / TČ.""" +"""Sběr telemetrie z Modbus: Deye (střídač), Teltonika TeltoCharge (EV); TČ zatím placeholder.""" from __future__ import annotations @@ -34,6 +34,49 @@ DEYE_REG_PV2_POWER = 673 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 0–40: 0–2 napětí L1–L3 (V), 3–5 proud L1–L3 (×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, 6–32), 16 start/stop. +TELTO_REG_BLOCK_START = 0 +TELTO_REG_BLOCK_COUNT = 41 + +#: 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ů 0–40 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: """ @@ -152,8 +195,29 @@ async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None: for row in rows: code = row["code"] charger_id = row["id"] - logger.info("TODO: EV charger Modbus registry pending | %s", code) - current_status = "available" + 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: + 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). + logger.warning("EV charger %s (%s:%s) read failed: %s", code, host, port, e) + continue + + 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"], + ) previous_status = await db.fetchval( """ @@ -168,14 +232,15 @@ async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None: ) await db.execute( - "select ems.fn_telemetry_ev_charger_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::text, $6::int, $7::float8)", + "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, - 0, - 0.0, + int(frame["power_w"]), + float(frame["session_energy_kwh"]), + float(frame["current_a"]), ) if previous_status is not None: diff --git a/backend/tests/test_teltocharge_parse.py b/backend/tests/test_teltocharge_parse.py new file mode 100644 index 0000000..7f8e3eb --- /dev/null +++ b/backend/tests/test_teltocharge_parse.py @@ -0,0 +1,58 @@ +"""Parser rámce TeltoCharge (registry 0–40) a mapování stavů na EV session logiku.""" + +from __future__ import annotations + +import unittest + +from services.telemetry_collector import ( + TELTO_REG_BLOCK_COUNT, + TELTO_STATUS_MAP, + parse_teltocharge_frame, +) + + +def _frame(**over: int) -> list[int]: + regs = [0] * TELTO_REG_BLOCK_COUNT + regs[0], regs[1], regs[2] = 230, 231, 229 # napětí + regs[3], regs[4], regs[5] = 160, 158, 0 # proud ×10 A (16.0 A max) + regs[6] = 7 # A – bez EV + regs[38] = 0 # výkon W + regs[39] = 0 # session kWh ×100 + for k, v in over.items(): + regs[int(k.lstrip("r"))] = v + return regs + + +class TeltoChargeParseTests(unittest.TestCase): + def test_charging_frame(self) -> None: + f = parse_teltocharge_frame(_frame(r6=0, r38=10870, r39=523)) + self.assertEqual(f["status"], "charging") + self.assertEqual(f["power_w"], 10870) + self.assertAlmostEqual(f["session_energy_kwh"], 5.23) + self.assertAlmostEqual(f["current_a"], 16.0) + + def test_no_ev_is_available(self) -> None: + self.assertEqual(parse_teltocharge_frame(_frame(r6=7))["status"], "available") + + def test_all_connected_states_are_not_available(self) -> None: + # detekce příjezdu (fn_ev_session_transition) stojí na ≠ 'available' + for raw, mapped in TELTO_STATUS_MAP.items(): + if raw == 7: + continue + self.assertNotEqual(mapped, "available", f"EVSE status {raw}") + + def test_unknown_raw_status(self) -> None: + self.assertEqual(parse_teltocharge_frame(_frame(r6=42))["status"], "unknown") + + def test_error_bits_passthrough(self) -> None: + f = parse_teltocharge_frame(_frame(r6=8, r35=0b10000)) + self.assertEqual(f["status"], "faulted") + self.assertEqual(f["error_bits"], 16) + + def test_short_frame_raises(self) -> None: + with self.assertRaises(ValueError): + parse_teltocharge_frame([0] * 10) + + +if __name__ == "__main__": + unittest.main() diff --git a/db/routines/R__047_fn_telemetry_ev_charger_sample.sql b/db/routines/R__047_fn_telemetry_ev_charger_sample.sql index 143dbe6..5b81d23 100644 --- a/db/routines/R__047_fn_telemetry_ev_charger_sample.sql +++ b/db/routines/R__047_fn_telemetry_ev_charger_sample.sql @@ -1,3 +1,11 @@ +-- Vzorek telemetrie EV nabíječky (1min, Timescale). Od 2026-06-11 vč. proudu +-- (Teltonika reg 3–5, max fáze) — starou 7-arg signaturu dropnout (jinak by +-- volání s defaultem bylo ambiguózní). + +drop function if exists ems.fn_telemetry_ev_charger_sample( + int, int, timestamptz, int, text, int, double precision +); + create or replace function ems.fn_telemetry_ev_charger_sample( p_site_id int, p_charger_id int, @@ -5,7 +13,8 @@ create or replace function ems.fn_telemetry_ev_charger_sample( p_connector_id int, p_status text, p_power_w int, - p_energy_kwh double precision + p_energy_kwh double precision, + p_current_a double precision default null ) returns void language sql @@ -17,7 +26,8 @@ as $fn$ connector_id, status, power_w, - energy_kwh + energy_kwh, + current_a ) values ( p_site_id, @@ -26,10 +36,11 @@ as $fn$ p_connector_id, p_status, p_power_w, - p_energy_kwh + p_energy_kwh, + p_current_a ) on conflict (charger_id, connector_id, measured_at) do nothing; $fn$; comment on function ems.fn_telemetry_ev_charger_sample is - 'Insert telemetrie nabíječky EV (placeholder Modbus).'; +'Vloží 1min vzorek telemetrie EV nabíječky (status, výkon, energie session, proud). Idempotentní na (charger, connector, čas).'; diff --git a/docs/04-modules/modbus-registers-teltocharge.md b/docs/04-modules/modbus-registers-teltocharge.md new file mode 100644 index 0000000..cfd8141 --- /dev/null +++ b/docs/04-modules/modbus-registers-teltocharge.md @@ -0,0 +1,42 @@ +# Teltonika TeltoCharge — Modbus registry (EMS) + +Zdroj: oficiální „TeltoCharge Modbus RTU Communication protocol" rev 0.5 +(2024-07-23). Připojení: RS485 → Waveshare → Modbus TCP (endpoint +`site_endpoint`, FC 3 čtení, FC 6/16 zápis). Wallbox musí být v aplikaci +nastaven jako *secondary (server)*. + +## Čtení (telemetry_collector, blok 0–40 jedním FC 3) + +| Reg | Význam | Formát | +|-----|--------|--------| +| 0–2 | Napětí L1–L3 | int16, V | +| 3–5 | Proud L1–L3 | int16, ×10 A | +| 6 | **EVSE status (DLM)** | 0=C nabíjí · 1–3=B1–B3 připojeno · 4=D1 stop od EV · 5–6=D2–D3 zákaz · 7=A bez EV · 8=F chyba · 9=E | +| 27 | Charge point state | 0–9 (informativní) | +| 33 | IEC61851 stav | 0–8 | +| 34/35 | Warning / Error bity | bitfield | +| 38 | Okamžitý výkon | uint16, W | +| 39 | Energie session | uint16, kWh×100 | +| 40 | Trvání session | uint16, s | +| 41–44 | Celková energie (FW ≥1.13) | uint64, kWh×100 | + +Mapování stavů v EMS (`TELTO_STATUS_MAP` v `telemetry_collector.py`): +7→`available`, 0→`charging`, 1–3→`preparing`, 4→`suspended_ev`, +5–6→`suspended_evse`, 8→`faulted`, 9→`unknown`. Detekce příjezdu/odjezdu +(`fn_ev_session_transition`) stojí na přechodu `available` ↔ ≠`available`. + +**Při selhání čtení se vzorek NEzapisuje** — fabrikovaný `available` by falešně +ukončil session a EV výkon 0 by špinil bazál (pravidlo 15). + +## Zápis (budoucí řízení — zatím NEimplementováno) + +| Reg | Význam | Hodnoty | +|-----|--------|---------| +| 15 | **Amps to use** (limit proudu) | 0 = stop, 6–32 A | +| 16 | Start/Stop session | 0 nic · 1 stop · 2 start | +| 19 | Communication timeout (watchdog) | 0–600 s (0 = vypnuto) | +| 20 | Failsafe current | 0, 6–32 A | + +Až se zapne řízení: zapisovat reg 15 přes journal `modbus_command` +(pravidlo 17) a nastavit watchdog (reg 19/20) — při výpadku EMS wallbox +spadne na failsafe proud.