Tesla presence watcher: geofence, ev_presence_obs, 'píchni auto' pobídka
- V095 ems.ev_presence_obs (state/at_home/distance/charging/shift per ~5 min) - tesla_client: get_vehicle_api_state (jen /vehicles — nebudí), haversine_m - collector poll_tesla_presence: online → poloha → geofence 150 m vs GPS site; přechod pryč→doma + Disconnected → Discord pobídka s aktuálním přebytkem (cooldown 2 h); vše logováno pro budoucí dostupnostní statistiku - 6 testů (haversine, přechody); docs: zákopy reauth procesu (6 bodů) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
async def run_telemetry_loop(conn: asyncpg.Connection) -> float:
|
||||||
"""Jeden průchod smyčky; vrátí uplynulý čas v sekundách (pro sleep).
|
"""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_ev_chargers(sid, conn)
|
||||||
await poll_heat_pump(sid, conn)
|
await poll_heat_pump(sid, conn)
|
||||||
await poll_loxone_sensors(sid, conn)
|
await poll_loxone_sensors(sid, conn)
|
||||||
|
await poll_tesla_presence(sid, conn)
|
||||||
await poll_pool_pumps(sid, conn)
|
await poll_pool_pumps(sid, conn)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Telemetry loop error site %s: %s", sid, e)
|
logger.error("Telemetry loop error site %s: %s", sid, e)
|
||||||
|
|||||||
@@ -166,3 +166,33 @@ async def get_charge_state(
|
|||||||
return None
|
return None
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return parse_charge_state(r.json())
|
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
|
||||||
|
|||||||
41
backend/tests/test_ev_presence.py
Normal file
41
backend/tests/test_ev_presence.py
Normal file
@@ -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()
|
||||||
22
db/migration/V095__ev_presence.sql
Normal file
22
db/migration/V095__ev_presence.sql
Normal file
@@ -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.';
|
||||||
@@ -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)
|
/opt/ems-deploy/.env up -d backend` (recreate kvůli env)
|
||||||
- [ ] ověření: po příjezdu Tesly log `Tesla SoC -> session …` +
|
- [ ] 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`
|
`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).
|
||||||
|
|||||||
Reference in New Issue
Block a user