237 lines
6.5 KiB
Markdown
237 lines
6.5 KiB
Markdown
# 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 <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)
|
|
|
|
```bash
|
|
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)
|
|
|
|
```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://<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
|
|
|
|
```sql
|
|
-- 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:
|
|
```bash
|
|
docker exec -it heardlog-db-1 psql -U aprs -d aprs
|
|
```
|