first commit
This commit is contained in:
21
.env.example
Normal file
21
.env.example
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# --- DMZ (docker-compose) ---
|
||||||
|
|
||||||
|
STATION_CALL=SA6ANW-1
|
||||||
|
DB_PASSWORD=changeme
|
||||||
|
|
||||||
|
# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
API_KEY=replace-with-random-secret
|
||||||
|
|
||||||
|
# APRS-IS receive-only (-1) is fine for data collection
|
||||||
|
APRS_IS_PASSCODE=-1
|
||||||
|
|
||||||
|
# 200 km radius around JO68II / Bor??s
|
||||||
|
APRS_IS_FILTER=r/58.35/14.05/200
|
||||||
|
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
|
||||||
|
# --- Shack (agw-forwarder) ??? copy relevant vars to shack machine ---
|
||||||
|
# COLLECTOR_URL=http://<dmz-ip>:8080
|
||||||
|
# API_KEY=<same as above>
|
||||||
|
# STATION_CALL=SA6ANW-1
|
||||||
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
dist-ssr/
|
||||||
|
*.local
|
||||||
|
.npm
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
104
README.md
Normal file
104
README.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# aprs-collector
|
||||||
|
|
||||||
|
```
|
||||||
|
[Shack-LAN] [DMZ]
|
||||||
|
Direwolf AGW:8000
|
||||||
|
???
|
||||||
|
agw_forwarder.py ??????POST/Bearer????????? FastAPI :8080/ingest/rf ????????? TimescaleDB
|
||||||
|
???
|
||||||
|
rotate.aprs2.net??? (APRS-IS, outbound)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DMZ ??? snabbstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Generera en API-nyckel
|
||||||
|
python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
# Klistra in i .env som API_KEY
|
||||||
|
|
||||||
|
docker compose up -d
|
||||||
|
docker compose logs -f collector
|
||||||
|
```
|
||||||
|
|
||||||
|
S??tt en reverse proxy (Caddy/nginx) framf??r port 8080 om du vill ha TLS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shack ??? agw-forwarder
|
||||||
|
|
||||||
|
Kopiera mappen `agw-forwarder/` till Direwolf-datorn.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip3 install requests
|
||||||
|
|
||||||
|
export AGW_HOST=localhost
|
||||||
|
export AGW_PORT=8000
|
||||||
|
export COLLECTOR_URL=http://<dmz-ip>:8080
|
||||||
|
export API_KEY=<samma nyckel som i DMZ .env>
|
||||||
|
export STATION_CALL=SA6ANW-1
|
||||||
|
|
||||||
|
python3 agw_forwarder.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### K??r som systemd-tj??nst
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Redigera agw-forwarder.service ??? fyll i COLLECTOR_URL och API_KEY
|
||||||
|
sudo cp agw-forwarder.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now agw-forwarder
|
||||||
|
journalctl -u agw-forwarder -f
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resiliens
|
||||||
|
|
||||||
|
Forwardern har en intern k?? (2000 frames). Om DMZ ??r tillf??lligt on??bar buffras
|
||||||
|
frames i minnet och skickas n??r anslutningen ??terkommer. Vid omstart av forwardern
|
||||||
|
f??rsvinner buffrade frames ??? tillr??ckligt f??r de flesta avbrott.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Endpoint | Metod | Auth | Beskrivning |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/ingest/rf` | POST | Bearer | RF-frame fr??n forwarder |
|
||||||
|
| `/health` | GET | ??? | Liveness check |
|
||||||
|
|
||||||
|
Swagger UI: `http://<dmz-ip>:8080/docs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nyttiga queries
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Direkt-h??rda stationer senaste 7 dagarna
|
||||||
|
SELECT ts, src_call, lat, lon, path
|
||||||
|
FROM rf_frames
|
||||||
|
WHERE heard_direct = TRUE
|
||||||
|
AND ts > NOW() - INTERVAL '7 days'
|
||||||
|
ORDER BY ts DESC;
|
||||||
|
|
||||||
|
-- Digis som h??rts senaste 30 min (= "online")
|
||||||
|
SELECT src_call, MAX(ts) AS last_seen, COUNT(*) AS frames
|
||||||
|
FROM rf_frames
|
||||||
|
WHERE ts > NOW() - INTERVAL '30 minutes'
|
||||||
|
GROUP BY src_call
|
||||||
|
ORDER BY last_seen DESC;
|
||||||
|
|
||||||
|
-- T??ckningspunkter per rutn??tscell
|
||||||
|
SELECT
|
||||||
|
ROUND(lat::numeric, 2) AS grid_lat,
|
||||||
|
ROUND(lon::numeric, 2) AS grid_lon,
|
||||||
|
COUNT(*) AS hits
|
||||||
|
FROM rf_frames
|
||||||
|
WHERE heard_direct = TRUE
|
||||||
|
AND lat IS NOT NULL
|
||||||
|
GROUP BY grid_lat, grid_lon;
|
||||||
|
```
|
||||||
18
agw-forwarder/agw-forwarder.service
Normal file
18
agw-forwarder/agw-forwarder.service
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=APRS AGW Forwarder
|
||||||
|
After=network.target direwolf.service
|
||||||
|
Wants=direwolf.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=joakim
|
||||||
|
WorkingDirectory=/home/joakim/heardlog/agw-forwarder
|
||||||
|
|
||||||
|
EnvironmentFile=/home/joakim/heardlog/.env
|
||||||
|
|
||||||
|
ExecStart=/usr/bin/python3 /home/joakim/heardlog/agw-forwarder/agw_forwarder.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
244
agw-forwarder/agw_forwarder.py
Normal file
244
agw-forwarder/agw_forwarder.py
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
agw_forwarder.py – Reads APRS frames from Direwolf AGW port and forwards
|
||||||
|
them to the aprs-collector API in DMZ.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 agw_forwarder.py
|
||||||
|
|
||||||
|
Configuration via environment variables (or edit DEFAULTS below):
|
||||||
|
AGW_HOST Direwolf host (default: localhost)
|
||||||
|
AGW_PORT Direwolf AGW port (default: 8000)
|
||||||
|
COLLECTOR_URL aprs-collector API URL (default: http://localhost:8080)
|
||||||
|
API_KEY shared secret (required)
|
||||||
|
STATION_CALL your callsign (default: SA6ANW-1)
|
||||||
|
LOG_LEVEL DEBUG / INFO (default: INFO)
|
||||||
|
|
||||||
|
Install deps:
|
||||||
|
pip3 install requests
|
||||||
|
|
||||||
|
Run as systemd service: see agw-forwarder.service
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
AGW_HOST = os.getenv("AGW_HOST", "localhost")
|
||||||
|
AGW_PORT = int(os.getenv("AGW_PORT", "8000"))
|
||||||
|
COLLECTOR_URL = os.getenv("COLLECTOR_URL", "http://localhost:8080")
|
||||||
|
API_KEY = os.getenv("API_KEY", "")
|
||||||
|
STATION_CALL = os.getenv("STATION_CALL", "SA6ANW-1")
|
||||||
|
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
||||||
|
|
||||||
|
INGEST_URL = f"{COLLECTOR_URL.rstrip('/')}/ingest/rf"
|
||||||
|
RECONNECT_DELAY = 10 # seconds between AGW reconnect attempts
|
||||||
|
MAX_QUEUE_SIZE = 2000 # frames buffered while API is unreachable
|
||||||
|
HTTP_TIMEOUT = 5 # seconds per POST
|
||||||
|
RETRY_DELAY = 5 # seconds between failed POST retries
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, LOG_LEVEL.upper(), logging.INFO),
|
||||||
|
format="%(asctime)s %(levelname)-8s %(message)s",
|
||||||
|
datefmt="%Y-%m-%dT%H:%M:%SZ",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
if not API_KEY:
|
||||||
|
raise SystemExit("ERROR: API_KEY environment variable is required")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AGW protocol helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_HEADER_SIZE = 36
|
||||||
|
|
||||||
|
|
||||||
|
def _build_frame(kind: str, pid: int = 0, data: bytes = b"", port: int = 0,
|
||||||
|
call_from: str = "", call_to: str = "") -> bytes:
|
||||||
|
cf = call_from.encode("ascii").ljust(10, b"\x00")[:10]
|
||||||
|
ct = call_to.encode("ascii").ljust(10, b"\x00")[:10]
|
||||||
|
return (
|
||||||
|
bytes([port, 0, 0, 0])
|
||||||
|
+ bytes([ord(kind), 0, pid, 0])
|
||||||
|
+ cf + ct
|
||||||
|
+ struct.pack("<I", len(data))
|
||||||
|
+ b"\x00\x00\x00\x00"
|
||||||
|
+ data
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_header(raw: bytes) -> tuple:
|
||||||
|
"""Return (port, kind, call_from, call_to, data_len)."""
|
||||||
|
port = raw[0]
|
||||||
|
kind = chr(raw[4])
|
||||||
|
call_from = raw[8:18].rstrip(b"\x00").decode("ascii", errors="replace").strip()
|
||||||
|
call_to = raw[18:28].rstrip(b"\x00").decode("ascii", errors="replace").strip()
|
||||||
|
data_len = struct.unpack_from("<I", raw, 28)[0]
|
||||||
|
return port, kind, call_from, call_to, data_len
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_monitoring_via(monitoring_line: str) -> str:
|
||||||
|
"""
|
||||||
|
Extract the via path from a Direwolf AGW monitoring header.
|
||||||
|
|
||||||
|
Direwolf sends 'U' frame data as:
|
||||||
|
[port:Fm CALL To CALL Via PATH <UI pid=F0 Len=N PF=0>[HH:MM:SS]]\r<info>
|
||||||
|
|
||||||
|
Extract PATH from between 'Via ' and ' <UI'.
|
||||||
|
If there is no 'Via' the frame had no path (direct / no-path beacon).
|
||||||
|
"""
|
||||||
|
m = re.search(r'\bVia\s+([^<\s][^<]*?)\s+<', monitoring_line)
|
||||||
|
if m:
|
||||||
|
return m.group(1).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _is_heard_direct(via_path: str) -> bool:
|
||||||
|
"""No '*' anywhere in path → frame arrived directly from the transmitter."""
|
||||||
|
if not via_path:
|
||||||
|
return True
|
||||||
|
return not any("*" in hop for hop in via_path.split(","))
|
||||||
|
|
||||||
|
|
||||||
|
def _recv_exact(sock: socket.socket, n: int) -> bytes:
|
||||||
|
buf = b""
|
||||||
|
while len(buf) < n:
|
||||||
|
chunk = sock.recv(n - len(buf))
|
||||||
|
if not chunk:
|
||||||
|
raise ConnectionError("AGW socket closed")
|
||||||
|
buf += chunk
|
||||||
|
return buf
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sender thread – dequeues frames and POSTs to collector API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def sender_thread(q: queue.Queue) -> None:
|
||||||
|
session = requests.Session()
|
||||||
|
session.headers.update({"Authorization": f"Bearer {API_KEY}",
|
||||||
|
"Content-Type": "application/json"})
|
||||||
|
while True:
|
||||||
|
payload = q.get()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
r = session.post(INGEST_URL, data=payload, timeout=HTTP_TIMEOUT)
|
||||||
|
if r.status_code == 204:
|
||||||
|
break
|
||||||
|
elif r.status_code == 401:
|
||||||
|
logger.error("API_KEY rejected – check configuration")
|
||||||
|
time.sleep(30)
|
||||||
|
else:
|
||||||
|
logger.warning("POST returned %d – retrying", r.status_code)
|
||||||
|
time.sleep(RETRY_DELAY)
|
||||||
|
except requests.exceptions.RequestException as exc:
|
||||||
|
logger.warning("POST failed (%s) – retrying in %ds", exc, RETRY_DELAY)
|
||||||
|
time.sleep(RETRY_DELAY)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AGW reader – main loop
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def run_agw_reader(q: queue.Queue) -> None:
|
||||||
|
while True:
|
||||||
|
sock = None
|
||||||
|
try:
|
||||||
|
logger.info("AGW: connecting to %s:%d", AGW_HOST, AGW_PORT)
|
||||||
|
sock = socket.create_connection((AGW_HOST, AGW_PORT), timeout=10)
|
||||||
|
sock.settimeout(None) # blocking reads after connect
|
||||||
|
|
||||||
|
# Capabilities request (warms up connection)
|
||||||
|
sock.sendall(_build_frame("G"))
|
||||||
|
# Enable monitoring mode → receive all UI frames
|
||||||
|
sock.sendall(_build_frame("m"))
|
||||||
|
|
||||||
|
logger.info("AGW: connected, monitoring enabled")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
hdr = _recv_exact(sock, _HEADER_SIZE)
|
||||||
|
_, kind, call_from, call_to, data_len = _parse_header(hdr)
|
||||||
|
|
||||||
|
data = b""
|
||||||
|
if data_len > 0:
|
||||||
|
data = _recv_exact(sock, data_len)
|
||||||
|
|
||||||
|
if kind != "U":
|
||||||
|
continue # skip control frames
|
||||||
|
|
||||||
|
try:
|
||||||
|
if b"\r" in data:
|
||||||
|
monitoring_raw, info_raw = data.split(b"\r", 1)
|
||||||
|
else:
|
||||||
|
monitoring_raw, info_raw = b"", data
|
||||||
|
|
||||||
|
monitoring = monitoring_raw.decode("ascii", errors="replace").strip()
|
||||||
|
info = info_raw.decode("ascii", errors="replace")
|
||||||
|
|
||||||
|
# monitoring line is "[port:Fm X To Y Via PATH <UI...>]"
|
||||||
|
# extract just the via hops from it
|
||||||
|
via_path = _parse_monitoring_via(monitoring) if "[" in monitoring else monitoring
|
||||||
|
direct = _is_heard_direct(via_path)
|
||||||
|
ts = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
payload = json.dumps({
|
||||||
|
"ts": ts,
|
||||||
|
"src_call": call_from,
|
||||||
|
"dst_call": call_to,
|
||||||
|
"via_path": via_path,
|
||||||
|
"info": info,
|
||||||
|
"heard_direct": direct,
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info("RF %-6s %-9s [%s]",
|
||||||
|
"DIRECT" if direct else "VIA", call_from, via_path)
|
||||||
|
|
||||||
|
if q.full():
|
||||||
|
logger.warning("Queue full – dropping oldest frame")
|
||||||
|
try:
|
||||||
|
q.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
q.put(payload)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Frame parse error: %s", exc)
|
||||||
|
|
||||||
|
except (ConnectionError, OSError, TimeoutError) as exc:
|
||||||
|
logger.warning("AGW: %s – reconnecting in %ds", exc, RECONNECT_DELAY)
|
||||||
|
finally:
|
||||||
|
if sock:
|
||||||
|
try:
|
||||||
|
sock.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
time.sleep(RECONNECT_DELAY)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logger.info("agw-forwarder starting station=%s agw=%s:%d api=%s",
|
||||||
|
STATION_CALL, AGW_HOST, AGW_PORT, INGEST_URL)
|
||||||
|
|
||||||
|
frame_queue: queue.Queue = queue.Queue(maxsize=MAX_QUEUE_SIZE)
|
||||||
|
|
||||||
|
t = threading.Thread(target=sender_thread, args=(frame_queue,), daemon=True)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
run_agw_reader(frame_queue) # blocks forever, reconnects on error
|
||||||
1
agw-forwarder/requirements.txt
Normal file
1
agw-forwarder/requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
requests>=2.31.0
|
||||||
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
|
||||||
32
docker-compose.yml
Normal file
32
docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: timescale/timescaledb-ha:pg16-all
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: aprs
|
||||||
|
POSTGRES_USER: aprs
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-aprs}
|
||||||
|
volumes:
|
||||||
|
- ./data/postgres:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U aprs -d aprs"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
collector:
|
||||||
|
build: ./collector
|
||||||
|
ports:
|
||||||
|
- "8085:8080" # expose API ??? put Caddy/nginx in front for TLS
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://aprs:${DB_PASSWORD:-aprs}@db:5432/aprs
|
||||||
|
API_KEY: ${API_KEY} # shared secret with forwarder
|
||||||
|
STATION_CALL: ${STATION_CALL:-SA6ANW-1}
|
||||||
|
APRS_IS_CALLSIGN: ${STATION_CALL:-SA6ANW-1}
|
||||||
|
APRS_IS_PASSCODE: ${APRS_IS_PASSCODE:--1}
|
||||||
|
APRS_IS_FILTER: ${APRS_IS_FILTER:-r/58.35/14.05/200}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
Reference in New Issue
Block a user