"""Geofence zones with haversine distance, enter/exit detection, and SQLite persistence.""" from __future__ import annotations import math from typing import Any from utils.database import get_db def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float: """Return distance in meters between two lat/lon points.""" R = 6_371_000 # Earth radius in meters phi1 = math.radians(lat1) phi2 = math.radians(lat2) dphi = math.radians(lat2 - lat1) dlam = math.radians(lon2 - lon1) a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2 return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) def _ensure_table() -> None: """Create geofence_zones table if it doesn't exist.""" with get_db() as conn: conn.execute(''' CREATE TABLE IF NOT EXISTS geofence_zones ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, lat REAL NOT NULL, lon REAL NOT NULL, radius_m REAL NOT NULL, alert_on TEXT DEFAULT 'enter_exit', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ''') class GeofenceManager: """Manages geofence zones with enter/exit detection.""" def __init__(self): self._inside: dict[str, set[int]] = {} # entity_id -> set of zone_ids inside _ensure_table() def list_zones(self) -> list[dict]: with get_db() as conn: cursor = conn.execute( 'SELECT id, name, lat, lon, radius_m, alert_on, created_at FROM geofence_zones ORDER BY id' ) return [dict(row) for row in cursor] def add_zone(self, name: str, lat: float, lon: float, radius_m: float, alert_on: str = 'enter_exit') -> int: with get_db() as conn: cursor = conn.execute( 'INSERT INTO geofence_zones (name, lat, lon, radius_m, alert_on) VALUES (?, ?, ?, ?, ?)', (name, lat, lon, radius_m, alert_on), ) return cursor.lastrowid def delete_zone(self, zone_id: int) -> bool: with get_db() as conn: cursor = conn.execute('DELETE FROM geofence_zones WHERE id = ?', (zone_id,)) # Clean up inside tracking for entity_zones in self._inside.values(): entity_zones.discard(zone_id) return cursor.rowcount > 0 def check_position(self, entity_id: str, entity_type: str, lat: float, lon: float, metadata: dict[str, Any] | None = None) -> list[dict]: """Check entity position against all zones. Returns list of events.""" zones = self.list_zones() if not zones: return [] events: list[dict] = [] prev_inside = self._inside.get(entity_id, set()) curr_inside: set[int] = set() for zone in zones: dist = haversine_distance(lat, lon, zone['lat'], zone['lon']) zid = zone['id'] if dist <= zone['radius_m']: curr_inside.add(zid) if zid not in prev_inside and zone['alert_on'] in ('enter', 'enter_exit'): events.append({ 'type': 'geofence_enter', 'zone_id': zid, 'zone_name': zone['name'], 'entity_id': entity_id, 'entity_type': entity_type, 'distance_m': round(dist, 1), 'lat': lat, 'lon': lon, **(metadata or {}), }) else: if zid in prev_inside and zone['alert_on'] in ('exit', 'enter_exit'): events.append({ 'type': 'geofence_exit', 'zone_id': zid, 'zone_name': zone['name'], 'entity_id': entity_id, 'entity_type': entity_type, 'distance_m': round(dist, 1), 'lat': lat, 'lon': lon, **(metadata or {}), }) self._inside[entity_id] = curr_inside return events # Singleton _manager: GeofenceManager | None = None def get_geofence_manager() -> GeofenceManager: global _manager if _manager is None: _manager = GeofenceManager() return _manager