2026-05-03 06:39:58 +00:00
2026-04-26 17:20:58 +02:00
2026-05-03 06:39:58 +00:00
2026-05-03 06:39:58 +00:00
2026-04-26 17:20:58 +02:00
2026-04-26 17:20:58 +02:00
2026-05-02 18:16:56 +00:00
N/A
2026-05-02 18:37:10 +00:00

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
Description
No description provided
Readme 84 KiB
Languages
Python 54.9%
HTML 44.8%
Dockerfile 0.3%