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:
|
||||
"""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)
|
||||
|
||||
@@ -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
|
||||
|
||||
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)
|
||||
- [ ] 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).
|
||||
|
||||
Reference in New Issue
Block a user