6.5 KiB
heardlog
APRS RF coverage logger and map for amateur radio digipeaters.
Collects every packet heard on RF by a Direwolf digipeater and builds an interactive coverage map showing which Maidenhead subsquares have been reached, which paths were used, and which stations have been heard.
Live: aprs.sa6anw.se
Architecture
[Shack LAN] [DMZ]
Direwolf AGW :8000
|
agw_forwarder.py --POST/Bearer--> FastAPI :8085 --> TimescaleDB + PostGIS
|
rotate.aprs2.net| (APRS-IS regional feed, outbound)
|
nginx :80/443
|
index.html (Leaflet map)
| Source | Table | Key data |
|---|---|---|
| Direwolf AGW (RF) | rf_frames |
Every packet received over the air |
| APRS-IS regional | is_frames |
Regional feed within 200 km radius |
Repository layout
heardlog/
????????? .env.example
????????? README.md
????????? docker-compose.yml
????????? collector/ # DMZ - runs in Docker
??? ????????? Dockerfile
??? ????????? requirements.txt
??? ????????? config.py
??? ????????? db.py # TimescaleDB schema + insert helpers
??? ????????? maidenhead.py # Maidenhead subsquare calculations
??? ????????? aprs_is.py # APRS-IS client
??? ????????? main.py # FastAPI app + APRS-IS background task
????????? agw-forwarder/ # Shack - runs directly on the Direwolf machine
??? ????????? agw_forwarder.py
??? ????????? requirements.txt
??? ????????? agw-forwarder.service
????????? web/
????????? index.html # Leaflet map (served by nginx)
????????? nginx.conf
Getting started
DMZ
git clone <repo> heardlog
cd heardlog
cp .env.example .env
# Edit .env - set DB_PASSWORD and API_KEY
# Generate API key
python3 -c "import secrets; print(secrets.token_hex(32))"
docker compose up -d
docker compose logs -f collector
Expected output:
INFO db Database schema ready
INFO main Startup complete - listening for RF frames
INFO aprs_is APRS-IS login: # logresp SA6ANW-1 unverified, server T2...
Shack (agw-forwarder)
pip3 install requests
cat > heardlog/agw-forwarder/.env << 'EOF'
AGW_HOST=localhost
AGW_PORT=8000
COLLECTOR_URL=http://<dmz-ip>:8085
API_KEY=<same key as DMZ .env>
STATION_CALL=SA6ANW-1
LOG_LEVEL=INFO
EOF
# Test manually first
cd heardlog/agw-forwarder
python3 agw_forwarder.py
Run as systemd services (shack)
sudo cp heardlog/agw-forwarder/direwolf.service /etc/systemd/system/
sudo cp heardlog/agw-forwarder/agw-forwarder.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now direwolf
sudo systemctl enable --now agw-forwarder
journalctl -u direwolf -u agw-forwarder -f
agw-forwarder has Wants=direwolf.service - systemd starts Direwolf automatically.
Configuration
DMZ (.env)
| Variable | Default | Description |
|---|---|---|
STATION_CALL |
SA6ANW-1 |
Your callsign |
DB_PASSWORD |
- | Postgres password |
API_KEY |
- | Shared secret with forwarder |
APRS_IS_PASSCODE |
-1 |
-1 for receive-only |
APRS_IS_FILTER |
r/58.35/14.05/200 |
lat/lon/km around your QTH |
LOG_LEVEL |
INFO |
DEBUG for per-frame logging |
Shack (agw-forwarder/.env)
| Variable | Default | Description |
|---|---|---|
AGW_HOST |
localhost |
Direwolf host |
AGW_PORT |
8000 |
Direwolf AGW port |
COLLECTOR_URL |
- | DMZ API URL |
API_KEY |
- | Same as DMZ .env |
STATION_CALL |
SA6ANW-1 |
Your callsign |
Map
The map has three zoom levels:
| Zoom | Display |
|---|---|
| < 7 | One dot per Maidenhead subsquare |
| 7 - 13 | Transparent subsquare rectangles (~5 x 4.6 km each) |
| >= 14 | Individual observation points |
Clicking a square opens a popup with two tabs:
- PATHS - actual digipeater hops heard from that square, sorted by count. Digipeaters without a known position are shown in red.
- STATIONS - callsigns heard from that square. Clicking a station shows detailed statistics including distance, timestamps, and top paths.
Hovering over a path row draws the route on the map: square center to each digipeater to RX station. The RX station marker (pulsing yellow dot) shows all paths heard aggregated across all squares.
Clicking SQUARES in the header opens a feed of all squares sorted by most recently added, with distance from the RX station.
Popups can be dragged to move them out of the way.
API
| Endpoint | Auth | Description |
|---|---|---|
POST /ingest/rf |
Bearer | RF frame from forwarder |
GET /health |
- | Liveness check |
GET /coverage/squares |
- | Subsquares with path and station breakdown |
GET /coverage/points |
- | Individual observation positions |
GET /coverage/digis |
- | Latest known position per callsign |
GET /coverage/rx-stats |
- | All paths aggregated across all squares |
GET /coverage/station/{callsign} |
- | Detailed statistics for one station |
Swagger UI: http://<host>:8085/docs
Implementation notes
AGW monitoring format
Direwolf sends U-frames in monitoring mode with a leading space and full
monitoring header before \r:
1:Fm SM6MJW-9 To UXRT8L Via WIDE1-1,WIDE2-1 <UI pid=F0 Len=17 F=0 >[16:46:13]\r<info>
The via path is extracted with a regex between Via and <UI.
Path reduction
Raw APRS paths like SK6WW,WIDE2-1 are reduced to actual physical digipeaters
by stripping alias hops (WIDE*, RELAY, TRACE, ECHO, GATE). This means
SK6WW,WIDE1 and SK6WW,WIDE2-1 both reduce to SK6WW and are counted together.
Null bytes
APRS frames can contain 0x00 bytes in the info field. These are stripped
before database insert since Postgres UTF-8 rejects null bytes.
Useful queries
-- Latest RF frames
SELECT ts, src_call, lat, lon, path
FROM rf_frames
ORDER BY ts DESC
LIMIT 20;
-- Stations heard in the last 30 minutes
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;
-- Coverage grid
SELECT
ROUND(lat::numeric, 2) AS grid_lat,
ROUND(lon::numeric, 2) AS grid_lon,
COUNT(*) AS hits
FROM rf_frames
WHERE lat IS NOT NULL
GROUP BY grid_lat, grid_lon;
Access the database:
docker exec -it heardlog-db-1 psql -U aprs -d aprs