diff --git a/backend/services/telemetry_collector.py b/backend/services/telemetry_collector.py index 1adcfaa..735a33c 100644 --- a/backend/services/telemetry_collector.py +++ b/backend/services/telemetry_collector.py @@ -499,6 +499,118 @@ async def poll_loxone_sensors(site_id: int, db: asyncpg.Connection) -> None: ) +#: presence poll pacing (sekundy) a geofence poloměr (m) +EV_PRESENCE_POLL_S = 300 +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_PLUG_NUDGE_LAST: dict[int, float] = {} + + +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 + + +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 + + 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 + + at_home = None + distance_m = None + charging_state = None + shift_state = None + if api_state == "online": + 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, + ) + + 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). @@ -516,6 +628,7 @@ async def run_telemetry_loop(conn: asyncpg.Connection) -> float: 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) diff --git a/backend/services/tesla_client.py b/backend/services/tesla_client.py index ae5dca2..1b77b1e 100644 --- a/backend/services/tesla_client.py +++ b/backend/services/tesla_client.py @@ -166,3 +166,33 @@ async def get_charge_state( return None r.raise_for_status() return parse_charge_state(r.json()) + + +def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Vzdálenost dvou GPS bodů v metrech (čisté, testovatelné).""" + import math + + r = 6_371_000.0 + p1, p2 = math.radians(lat1), math.radians(lat2) + dp = math.radians(lat2 - lat1) + dl = math.radians(lon2 - lon1) + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 + return 2 * r * math.asin(math.sqrt(a)) + + +async def get_vehicle_api_state(db: asyncpg.Connection, vin: str | None) -> str | None: + """Jen state z /vehicles (online/asleep/offline) — NIKDY nebudí auto.""" + token = await _get_access_token(db) + if token is None: + return None + async with httpx.AsyncClient( + timeout=HTTP_TIMEOUT_S, headers={"Authorization": f"Bearer {token}"} + ) as client: + r = await client.get(f"{API_BASE}/api/1/vehicles") + r.raise_for_status() + vehicles = r.json().get("response") or [] + if vin: + v = next((x for x in vehicles if x.get("vin") == vin), None) + else: + v = vehicles[0] if len(vehicles) == 1 else None + return str(v["state"]) if v else None diff --git a/backend/tests/test_ev_presence.py b/backend/tests/test_ev_presence.py new file mode 100644 index 0000000..01f57e5 --- /dev/null +++ b/backend/tests/test_ev_presence.py @@ -0,0 +1,41 @@ +"""EV presence — čisté helpery (haversine, přechody).""" + +from __future__ import annotations + +import unittest + +from services.telemetry_collector import ev_presence_transition +from services.tesla_client import haversine_m + + +class HaversineTests(unittest.TestCase): + def test_zero_distance(self) -> None: + self.assertAlmostEqual(haversine_m(49.2445, 17.4070, 49.2445, 17.4070), 0.0, places=2) + + def test_known_distance(self) -> None: + # ~111 km na 1° zeměpisné šířky + d = haversine_m(49.0, 17.0, 50.0, 17.0) + self.assertAlmostEqual(d, 111_195, delta=300) + + def test_geofence_scale(self) -> None: + # ~100 m posun (0.0009° lat) + d = haversine_m(49.24457, 17.407054, 49.24547, 17.407054) + self.assertTrue(80 < d < 120, d) + + +class TransitionTests(unittest.TestCase): + def test_arrived(self) -> None: + self.assertEqual(ev_presence_transition(False, True), "arrived") + + def test_left(self) -> None: + self.assertEqual(ev_presence_transition(True, False), "left") + + def test_none_cases(self) -> None: + self.assertIsNone(ev_presence_transition(None, True)) + self.assertIsNone(ev_presence_transition(True, None)) + self.assertIsNone(ev_presence_transition(True, True)) + self.assertIsNone(ev_presence_transition(False, False)) + + +if __name__ == "__main__": + unittest.main() diff --git a/db/migration/V095__ev_presence.sql b/db/migration/V095__ev_presence.sql new file mode 100644 index 0000000..868f1ec --- /dev/null +++ b/db/migration/V095__ev_presence.sql @@ -0,0 +1,22 @@ +-- Presence vozidla (Tesla location scope): kde auto je, kdy bývá doma. +-- Zdroj: levný poll /vehicles (state, NEbudí) + při online location_data. +-- Účel: (a) notifikace "auto doma a nepíchlé + svítí přebytek → píchni ho", +-- (b) dostupnostní statistika per DOW×hodina pro plánovač (maska ev_connected +-- a zreálnění oportunistické hodnoty) — follow-up nad těmito daty. + +create table ems.ev_presence_obs ( + id bigserial primary key, + vehicle_id int not null references ems.asset_vehicle (id), + observed_at timestamptz not null default now(), + api_state text, -- online / asleep / offline (z /vehicles, bez buzení) + at_home boolean, -- null = poloha neznámá (asleep) + distance_m int, + charging_state text, -- Disconnected / Stopped / Charging… + shift_state text +); + +create index idx_ev_presence_obs_vehicle_time + on ems.ev_presence_obs (vehicle_id, observed_at desc); + +comment on table ems.ev_presence_obs is +'Pozorování přítomnosti vozidla (geofence vs GPS site). Poll ~5 min, poloha jen když je auto vzhůru (nebudí). Vstup pro "píchni auto" notifikace a budoucí dostupnostní statistiku.'; diff --git a/docs/tesla-fleet-api.md b/docs/tesla-fleet-api.md index 8ec072b..a47ab57 100644 --- a/docs/tesla-fleet-api.md +++ b/docs/tesla-fleet-api.md @@ -101,3 +101,20 @@ curl -s https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token \ /opt/ems-deploy/.env up -d backend` (recreate kvůli env) - [ ] ověření: po příjezdu Tesly log `Tesla SoC -> session …` + `select soc_at_connect_pct, target_soc_pct from ems.ev_session order by id desc limit 1` + +## Presence watcher (2026-06-12, dev) + +Poll ~5 min: `GET /vehicles` (state, NEBUDÍ) → při `online` poloha +(`location_data`, vyžaduje scope `vehicle_location`) → geofence 150 m vs GPS +site → `ems.ev_presence_obs` (V095). Přechod pryč→doma + `Disconnected` → +Discord pobídka „auto doma a nepíchnuté (+aktuální přebytek)“; cooldown 2 h. +Data = základ dostupnostní statistiky per DOW×hodina (follow-up: maska +ev_connected v plánovači + zreálnění oportunistické hodnoty). + +### Zákopy z reauth (12. 6.) — ať se neopakují +1. redirect URI `/t-auth` (musí sedět všude), 2. refresh token ROTUJE → +provozní hodnota v `ems.tesla_token` (ne .env), 3. po revokaci souhlasu ~10 min +výpadek auth, 4. `client_not_found` = app smazána/nové ID → opravit .env + +recreate, 5. **public key hash je vázán na app** — po smazání app nutná rotace +klíče (`mv private.pem .old` + `setup_tesla_domain.sh`) před partner registrací, +6. prázdný seznam vozidel = chybí partner registrace (ne spánek).