EV telemetrie: skutečné čtení Teltonika TeltoCharge (konec stub-u)
poll_ev_chargers četl placeholder ('available'/0 W) — EV spotřeba se nikdy
neodečítala z bazálu a session detekce nefungovala. Nyní: blok registrů 0-40
jedním FC 3 (oficiální protokol rev 0.5), parse_teltocharge_frame (status z
reg 6 → available/preparing/charging/..., výkon reg 38, energie session reg 39,
proud max L1-L3 reg 3-5). Při selhání čtení se vzorek NEzapisuje (fabrikovaný
available by falešně ukončoval session).
fn_telemetry_ev_charger_sample: + p_current_a (drop staré 7-arg signatury).
6 nových testů parseru; plná sada beze změny. Docs: modbus-registers-teltocharge.md.
Po deployi: home-01 ev-charger-1/2 začnou posílat reálná data; bazál se začne
čistit od EV (EMA 00:30); rebuild stats má smysl až po ~2 týdnech čisté historie.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -34,6 +34,49 @@ DEYE_REG_PV2_POWER = 673
|
|||||||
DEYE_REG_SOLAR_SELL = 145
|
DEYE_REG_SOLAR_SELL = 145
|
||||||
DEYE_REG_CONTROL_BOARD_SPECIAL1 = 178
|
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:
|
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:
|
for row in rows:
|
||||||
code = row["code"]
|
code = row["code"]
|
||||||
charger_id = row["id"]
|
charger_id = row["id"]
|
||||||
logger.info("TODO: EV charger Modbus registry pending | %s", code)
|
host = row["host"]
|
||||||
current_status = "available"
|
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(
|
previous_status = await db.fetchval(
|
||||||
"""
|
"""
|
||||||
@@ -168,14 +232,15 @@ async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
await db.execute(
|
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,
|
site_id,
|
||||||
charger_id,
|
charger_id,
|
||||||
measured_at,
|
measured_at,
|
||||||
connector_id,
|
connector_id,
|
||||||
current_status,
|
current_status,
|
||||||
0,
|
int(frame["power_w"]),
|
||||||
0.0,
|
float(frame["session_energy_kwh"]),
|
||||||
|
float(frame["current_a"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
if previous_status is not None:
|
if previous_status is not None:
|
||||||
|
|||||||
58
backend/tests/test_teltocharge_parse.py
Normal file
58
backend/tests/test_teltocharge_parse.py
Normal file
@@ -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()
|
||||||
@@ -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(
|
create or replace function ems.fn_telemetry_ev_charger_sample(
|
||||||
p_site_id int,
|
p_site_id int,
|
||||||
p_charger_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_connector_id int,
|
||||||
p_status text,
|
p_status text,
|
||||||
p_power_w int,
|
p_power_w int,
|
||||||
p_energy_kwh double precision
|
p_energy_kwh double precision,
|
||||||
|
p_current_a double precision default null
|
||||||
)
|
)
|
||||||
returns void
|
returns void
|
||||||
language sql
|
language sql
|
||||||
@@ -17,7 +26,8 @@ as $fn$
|
|||||||
connector_id,
|
connector_id,
|
||||||
status,
|
status,
|
||||||
power_w,
|
power_w,
|
||||||
energy_kwh
|
energy_kwh,
|
||||||
|
current_a
|
||||||
)
|
)
|
||||||
values (
|
values (
|
||||||
p_site_id,
|
p_site_id,
|
||||||
@@ -26,10 +36,11 @@ as $fn$
|
|||||||
p_connector_id,
|
p_connector_id,
|
||||||
p_status,
|
p_status,
|
||||||
p_power_w,
|
p_power_w,
|
||||||
p_energy_kwh
|
p_energy_kwh,
|
||||||
|
p_current_a
|
||||||
)
|
)
|
||||||
on conflict (charger_id, connector_id, measured_at) do nothing;
|
on conflict (charger_id, connector_id, measured_at) do nothing;
|
||||||
$fn$;
|
$fn$;
|
||||||
|
|
||||||
comment on function ems.fn_telemetry_ev_charger_sample is
|
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).';
|
||||||
|
|||||||
42
docs/04-modules/modbus-registers-teltocharge.md
Normal file
42
docs/04-modules/modbus-registers-teltocharge.md
Normal file
@@ -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.
|
||||||
Reference in New Issue
Block a user