first commit

This commit is contained in:
Joakim Svensson
2026-04-26 17:20:58 +02:00
commit 42ba6feed4
13 changed files with 865 additions and 0 deletions

10
collector/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

72
collector/aprs_is.py Normal file
View File

@@ -0,0 +1,72 @@
import asyncio
import logging
from datetime import datetime, timezone
from typing import Awaitable, Callable
logger = logging.getLogger(__name__)
_KEEPALIVE_INTERVAL = 60
_RECONNECT_DELAY = 30
async def run_aprs_is_collector(
host: str,
port: int,
callsign: str,
passcode: str,
filter_str: str,
on_frame: Callable[..., Awaitable[None]],
) -> None:
writer = None
while True:
try:
logger.info("APRS-IS: connecting to %s:%d", host, port)
reader, writer = await asyncio.open_connection(host, port)
banner = await asyncio.wait_for(reader.readline(), timeout=15)
logger.info("APRS-IS: %s", banner.decode(errors="replace").strip())
login_line = (
f"user {callsign} pass {passcode} "
f"vers aprs-collector 0.1 "
f"filter {filter_str}\r\n"
)
writer.write(login_line.encode())
await writer.drain()
ack = await asyncio.wait_for(reader.readline(), timeout=15)
logger.info("APRS-IS login: %s", ack.decode(errors="replace").strip())
async def _keepalive() -> None:
while True:
await asyncio.sleep(_KEEPALIVE_INTERVAL)
writer.write(b"#ping\r\n")
await writer.drain()
ka_task = asyncio.create_task(_keepalive())
try:
while True:
line = await reader.readline()
if not line:
logger.warning("APRS-IS: server closed connection")
break
decoded = line.decode("utf-8", errors="replace").strip()
if not decoded or decoded.startswith("#"):
continue
await on_frame(ts=datetime.now(timezone.utc), raw=decoded)
finally:
ka_task.cancel()
except asyncio.TimeoutError:
logger.warning("APRS-IS: timeout during handshake")
except Exception as exc:
logger.error("APRS-IS: %s", exc)
finally:
if writer:
try:
writer.close()
except Exception:
pass
logger.info("APRS-IS: reconnecting in %ds...", _RECONNECT_DELAY)
await asyncio.sleep(_RECONNECT_DELAY)

32
collector/config.py Normal file
View File

@@ -0,0 +1,32 @@
import os
from dataclasses import dataclass
@dataclass
class Config:
db_url: str
api_key: str
station_call: str
aprs_is_host: str
aprs_is_port: int
aprs_is_callsign: str
aprs_is_passcode: str
aprs_is_filter: str
log_level: str
@classmethod
def from_env(cls) -> "Config":
api_key = os.environ.get("API_KEY", "")
if not api_key:
raise RuntimeError("API_KEY environment variable is required")
return cls(
db_url=os.getenv("DATABASE_URL", "postgresql://aprs:aprs@db:5432/aprs"),
api_key=api_key,
station_call=os.getenv("STATION_CALL", "SA6ANW-1"),
aprs_is_host=os.getenv("APRS_IS_HOST", "rotate.aprs2.net"),
aprs_is_port=int(os.getenv("APRS_IS_PORT", "14580")),
aprs_is_callsign=os.getenv("APRS_IS_CALLSIGN", "SA6ANW-1"),
aprs_is_passcode=os.getenv("APRS_IS_PASSCODE", "-1"),
aprs_is_filter=os.getenv("APRS_IS_FILTER", "r/58.35/14.05/200"),
log_level=os.getenv("LOG_LEVEL", "INFO"),
)

107
collector/db.py Normal file
View File

@@ -0,0 +1,107 @@
import logging
from datetime import datetime
from typing import Optional
import asyncpg
logger = logging.getLogger(__name__)
_DDL = """
CREATE EXTENSION IF NOT EXISTS timescaledb;
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE TABLE IF NOT EXISTS rf_frames (
ts TIMESTAMPTZ NOT NULL,
rx_station TEXT NOT NULL,
src_call TEXT NOT NULL,
dst_call TEXT,
lat DOUBLE PRECISION,
lon DOUBLE PRECISION,
heard_direct BOOLEAN NOT NULL DEFAULT FALSE,
path TEXT,
info TEXT,
raw TEXT
);
SELECT create_hypertable('rf_frames', 'ts', if_not_exists => TRUE);
CREATE INDEX IF NOT EXISTS rf_frames_src_ts ON rf_frames (src_call, ts DESC);
CREATE INDEX IF NOT EXISTS rf_frames_direct ON rf_frames (heard_direct, ts DESC);
CREATE INDEX IF NOT EXISTS rf_frames_geo
ON rf_frames USING GIST (ST_MakePoint(lon, lat))
WHERE heard_direct = TRUE AND lat IS NOT NULL AND lon IS NOT NULL;
CREATE TABLE IF NOT EXISTS is_frames (
ts TIMESTAMPTZ NOT NULL,
src_call TEXT NOT NULL,
lat DOUBLE PRECISION,
lon DOUBLE PRECISION,
path TEXT,
comment TEXT,
raw TEXT
);
SELECT create_hypertable('is_frames', 'ts', if_not_exists => TRUE);
CREATE INDEX IF NOT EXISTS is_frames_src_ts ON is_frames (src_call, ts DESC);
CREATE INDEX IF NOT EXISTS is_frames_geo
ON is_frames USING GIST (ST_MakePoint(lon, lat))
WHERE lat IS NOT NULL AND lon IS NOT NULL;
"""
async def init_db(url: str) -> asyncpg.Pool:
pool = await asyncpg.create_pool(url, min_size=2, max_size=10)
async with pool.acquire() as conn:
await conn.execute(_DDL)
logger.info("Database schema ready")
return pool
def _clean(s: Optional[str]) -> Optional[str]:
"""Strip null bytes ??? Postgres UTF8 rejects 0x00."""
if s is None:
return None
return s.replace("\x00", "")
async def insert_rf_frame(
pool: asyncpg.Pool,
ts: datetime,
rx_station: str,
src_call: str,
dst_call: Optional[str],
lat: Optional[float],
lon: Optional[float],
heard_direct: bool,
path: str,
info: str,
raw: str,
) -> None:
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO rf_frames
(ts, rx_station, src_call, dst_call, lat, lon,
heard_direct, path, info, raw)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
""",
ts, rx_station, _clean(src_call), _clean(dst_call), lat, lon,
heard_direct, _clean(path), _clean(info), _clean(raw),
)
async def insert_is_frame(
pool: asyncpg.Pool,
ts: datetime,
src_call: str,
lat: Optional[float],
lon: Optional[float],
path: str,
comment: Optional[str],
raw: str,
) -> None:
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO is_frames (ts, src_call, lat, lon, path, comment, raw)
VALUES ($1,$2,$3,$4,$5,$6,$7)
""",
ts, _clean(src_call), lat, lon, _clean(path), _clean(comment), _clean(raw),
)

194
collector/main.py Normal file
View File

@@ -0,0 +1,194 @@
"""
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)

View File

@@ -0,0 +1,4 @@
fastapi>=0.111.0
uvicorn[standard]>=0.29.0
asyncpg>=0.29.0
aprslib>=0.7.0