Files
intercept/utils/geofence.py
T
James Smith 96172ca593 style: apply ruff-format to entire codebase
First-time run of ruff-format via pre-commit hook normalises quote
style, trailing commas, and whitespace across 188 Python files.
No logic changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 14:48:11 +01:00

130 lines
4.7 KiB
Python

"""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