Files
heardlog/collector/main.py
Joakim Svensson 42ba6feed4 first commit
2026-04-26 17:20:58 +02:00

195 lines
6.0 KiB
Python

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