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)
|
||||
|
||||
Reference in New Issue
Block a user