first commit
This commit is contained in:
10
collector/Dockerfile
Normal file
10
collector/Dockerfile
Normal 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
72
collector/aprs_is.py
Normal 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
32
collector/config.py
Normal 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
107
collector/db.py
Normal 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
194
collector/main.py
Normal 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)
|
||||
4
collector/requirements.txt
Normal file
4
collector/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
fastapi>=0.111.0
|
||||
uvicorn[standard]>=0.29.0
|
||||
asyncpg>=0.29.0
|
||||
aprslib>=0.7.0
|
||||
Reference in New Issue
Block a user