195 lines
6.0 KiB
Python
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)
|