""" APRS collector ??? DMZ service. POST /ingest/rf ??? agw-forwarder on shack machine pushes RF frames here GET /health ??? Docker / uptime check APRS-IS collector runs as a background asyncio task alongside uvicorn. """ import asyncio import logging import sys from contextlib import asynccontextmanager from datetime import datetime, timezone from typing import Annotated, Optional import aprslib import uvicorn from fastapi import Depends, FastAPI, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from pydantic import BaseModel from aprs_is import run_aprs_is_collector from config import Config from db import init_db, insert_is_frame, insert_rf_frame logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # App state ??? populated during startup # --------------------------------------------------------------------------- _pool = None _config: Optional[Config] = None # --------------------------------------------------------------------------- # Startup / shutdown # --------------------------------------------------------------------------- @asynccontextmanager async def lifespan(app: FastAPI): global _pool, _config _config = Config.from_env() logging.basicConfig( level=getattr(logging, _config.log_level.upper(), logging.INFO), format="%(asctime)s %(levelname)-8s %(name)s %(message)s", stream=sys.stdout, ) # Wait for Postgres for attempt in range(1, 61): try: _pool = await init_db(_config.db_url) break except Exception as exc: logger.warning("DB not ready (%d/60): %s", attempt, exc) await asyncio.sleep(5) else: logger.error("Could not connect to database ??? giving up") sys.exit(1) # Start APRS-IS collector as background task is_task = asyncio.create_task(_run_aprs_is()) logger.info("Startup complete ??? listening for RF frames") yield # ??? app runs here is_task.cancel() try: await is_task except asyncio.CancelledError: pass async def _run_aprs_is() -> None: async def on_is_frame(*, ts: datetime, raw: str) -> None: try: src_call = raw.split(">")[0] if ">" in raw else "" lat, lon, comment = _parse_position(raw) path = _extract_path(raw) await insert_is_frame(_pool, ts, src_call, lat, lon, path, comment, raw) logger.debug("IS %-9s lat=%s lon=%s", src_call, f"{lat:.4f}" if lat else "-", f"{lon:.4f}" if lon else "-") except Exception as exc: logger.warning("IS frame error: %s raw=%r", exc, raw[:80]) await run_aprs_is_collector( host=_config.aprs_is_host, port=_config.aprs_is_port, callsign=_config.aprs_is_callsign, passcode=_config.aprs_is_passcode, filter_str=_config.aprs_is_filter, on_frame=on_is_frame, ) # --------------------------------------------------------------------------- # FastAPI app # --------------------------------------------------------------------------- app = FastAPI(title="aprs-collector", lifespan=lifespan) _bearer = HTTPBearer() def _require_api_key( creds: Annotated[HTTPAuthorizationCredentials, Depends(_bearer)], ) -> None: if creds.credentials != _config.api_key: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") # --------------------------------------------------------------------------- # Models # --------------------------------------------------------------------------- class RFFrameIn(BaseModel): ts: datetime # UTC timestamp from forwarder src_call: str dst_call: str via_path: str # comma-separated, may contain '*' info: str # APRS info field heard_direct: bool # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _parse_position(packet: str) -> tuple[Optional[float], Optional[float], Optional[str]]: try: p = aprslib.parse(packet) return p.get("latitude"), p.get("longitude"), p.get("comment") except Exception: return None, None, None def _extract_path(raw: str) -> str: try: header = raw.split(":")[0] parts = header.split(",") return ",".join(parts[1:]) if len(parts) > 1 else "" except Exception: return "" # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @app.get("/health") async def health(): return {"status": "ok"} @app.post("/ingest/rf", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(_require_api_key)]) async def ingest_rf(frame: RFFrameIn) -> None: """Receive a single APRS RF frame from the shack forwarder.""" try: # Build TNC2 string so aprslib can parse position tnc2 = f"{frame.src_call}>{frame.dst_call}" if frame.via_path: tnc2 += f",{frame.via_path}" tnc2 += f":{frame.info}" lat, lon, _ = _parse_position(tnc2) logger.info( "RF %-6s %-9s [%s] lat=%s lon=%s", "DIRECT" if frame.heard_direct else "VIA", frame.src_call, frame.via_path, f"{lat:.4f}" if lat else "-", f"{lon:.4f}" if lon else "-", ) await insert_rf_frame( pool=_pool, ts=frame.ts, rx_station=_config.station_call, src_call=frame.src_call, dst_call=frame.dst_call, lat=lat, lon=lon, heard_direct=frame.heard_direct, path=frame.via_path, info=frame.info, raw=tnc2, ) except Exception as exc: logger.warning("ingest_rf error (frame dropped): %s", exc)