"""In-memory WebSocket pub/sub with viewport filtering. Zero extra deps. Ingest workers publish AIS/ADS-B points; only clients whose current map bbox contains the point receive the payload. No Redis/Kafka. """ from __future__ import annotations import asyncio from typing import Any from uuid import UUID BBox = tuple[float, float, float, float] # minlon, minlat, maxlon, maxlat def _uuid_str(value: object) -> str | None: try: return str(UUID(str(value))) except (ValueError, TypeError, AttributeError): return None def point_in_bbox(lon: float, lat: float, bbox: BBox | None) -> bool: """True if (lon, lat) sits inside an axis-aligned viewport.""" if bbox is None: return False minlon, minlat, maxlon, maxlat = bbox return minlon <= lon <= maxlon and minlat <= lat <= maxlat class ConnectionManager: """Maps Tailscale/browser clients → viewport bbox + per-client queue.""" def __init__(self) -> None: self._queues: dict[str, asyncio.Queue] = {} self._viewports: dict[str, BBox] = {} self._watched: dict[str, set[str]] = {} def register(self, client_id: str, maxsize: int = 256) -> asyncio.Queue: q: asyncio.Queue = asyncio.Queue(maxsize=maxsize) self._queues[client_id] = q return q def unregister(self, client_id: str) -> None: self._queues.pop(client_id, None) self._viewports.pop(client_id, None) self._watched.pop(client_id, None) def set_watched_geofences(self, client_id: str, ids: list[str]) -> None: """Watch these fence UUIDs so geofence_alert delivers off-viewport. Invalid UUIDs are ignored. Empty list = watch none (viewport-only). """ if client_id not in self._queues: return watched: set[str] = set() for raw in ids: uid = _uuid_str(raw) if uid is not None: watched.add(uid) self._watched[client_id] = watched def set_viewport(self, client_id: str, bbox: BBox) -> None: if client_id in self._queues: self._viewports[client_id] = bbox def viewport_of(self, client_id: str) -> BBox | None: return self._viewports.get(client_id) def viewports(self) -> list[BBox]: return list(self._viewports.values()) def has_clients(self) -> bool: return bool(self._queues) async def publish_point( self, kind: str, payload: dict[str, Any], *, lat: float, lon: float, ) -> int: """Enqueue `{type, payload}` for clients whose viewport contains the point. kind=geofence_alert also delivers when payload.geofence_id is in the client's watch set (even if the point is off-viewport). Other kinds stay viewport-only. Drops the oldest queued message if a client's buffer is full. Returns the number of clients that got a copy. """ msg = {"type": kind, "payload": payload} sent = 0 gid = _uuid_str(payload.get("geofence_id")) if kind == "geofence_alert" else None for client_id, queue in list(self._queues.items()): in_view = point_in_bbox(lon, lat, self._viewports.get(client_id)) if kind == "geofence_alert": watching = gid is not None and gid in self._watched.get(client_id, set()) if not in_view and not watching: continue elif not in_view: continue if queue.full(): try: queue.get_nowait() except asyncio.QueueEmpty: pass try: queue.put_nowait(msg) except asyncio.QueueFull: continue sent += 1 return sent manager = ConnectionManager()