# 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](https://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 ```bash git clone 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) ```bash pip3 install requests cat > heardlog/agw-forwarder/.env << 'EOF' AGW_HOST=localhost AGW_PORT=8000 COLLECTOR_URL=http://:8085 API_KEY= STATION_CALL=SA6ANW-1 LOG_LEVEL=INFO EOF # Test manually first cd heardlog/agw-forwarder python3 agw_forwarder.py ``` ### Run as systemd services (shack) ```bash 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://: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 [16:46:13]\r ``` The via path is extracted with a regex between `Via ` and ` 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: ```bash docker exec -it heardlog-db-1 psql -U aprs -d aprs ```