11 KiB
RepeaterBook integration for the ID-5100 web panel — design
- Date: 2026-06-14
- Project:
/home/claude/bin/id5100 - Status: Approved (design); ready for implementation plan
- Builds on: existing
civ → radio → {cli, server+panel}layering (seeREADME.md,DECISIONS.md)
Goal
Let the web panel browse nearby amateur repeaters from RepeaterBook and, on a single
tap, configure the radio to operate through one: set output frequency + duplex
offset + CTCSS tone. Repeater data is sourced from a locally cached, normalized
repeaters.json so the panel never calls RepeaterBook directly.
Decisions that shaped this design
- Full configuration, not freq-only. Tapping a repeater sets freq + offset + tone (option 2 from brainstorming). This requires new CI-V commands not yet in the codebase.
- Cached data, not live per-request. RepeaterBook's terms ask for infrequent bulk
pulls. A scheduled fetch writes
repeaters.json; the server reads the cache. - Build radio-side + UI now; wire the live data source in parallel. The RepeaterBook
API is now gated behind an approval-required
X-RB-App-Token(bare request →401 auth_missing). We build against a hand-seededrepeaters.jsonimmediately and request the token concurrently. (Decision 3+1 from brainstorming.) - QTH: 38.8423 N, 77.2684 W (Annandale/Fairfax, VA). Scope = Virginia (FIPS 51) + DC (11) + Maryland (24), analog FM + D-STAR, sorted nearest-first by haversine distance.
- Module split (option B): focused new modules alongside the existing layers, not a
monolithic
server.pyand not a separate microservice.
Architecture
repeaters.json ──read──▶ repeaters.py ──▶ server.py /api/repeaters ──▶ panel.html card
▲ (load/normalize/ │ tap row
│ written by distance/search) ▼
rb_fetch.py (API client, server.py /api/tune-repeater ─▶ radio.py
reads token from .env) (freq→offset→tone, per-step) (CI-V)
│
civ.py codec
Module layout
| File | Role | Pure? / Tested by |
|---|---|---|
civ.py (extend) |
Command constants + BCD helpers for offset freq and CTCSS tone | pure → test_civ.py |
radio.py (extend) |
set_duplex(), set_offset(), set_tone(), set_tone_on() + getters |
live-radio verified |
repeaters.py (new) |
Load/normalize repeaters.json, haversine distance from QTH, search + nearest-first sort |
pure → test_repeaters.py |
rb_fetch.py (new) |
RepeaterBook API client; reads token, pulls VA+DC+MD, normalizes → repeaters.json |
network-isolated; inert without token |
server.py (extend) |
GET /api/repeaters, POST /api/tune-repeater |
live |
panel.html (extend) |
"Repeaters" card (search + nearest-first list + per-step tune status) | headless screenshot |
repeaters.json |
Cached, normalized data (hand-seeded now, API-filled later) | — |
rb-fetch.service / rb-fetch.timer (new, inert) |
systemd user units to refresh cache on a cadence once the token exists | — |
Data: normalized record schema
rb_fetch.py, any future CSV ingest, and the hand-seed all emit the same internal
record, so nothing downstream depends on RepeaterBook's field names:
{
"callsign": "W4ABC",
"location": "Fairfax, VA",
"output_mhz": 147.300,
"offset_mhz": 0.600,
"duplex": "+", // "+", "-", or "simplex"
"tone_hz": 131.8, // null if none
"tone_mode": "ctcss", // "ctcss" | "dcs" | "none"
"mode": "FM", // "FM" | "DV"
"lat": 38.85,
"long": -77.30,
"notes": ""
}
distance_mi is computed at serve time in repeaters.py from the QTH constant
(not stored). QTH lives as a module constant QTH = (38.8423, -77.2684).
Normalizer (RepeaterBook → internal)
rb_fetch.py maps RepeaterBook fields (Frequency, Input Freq, PL/CTCSS Uplink,
Callsign, Lat/Long, D-Star, …) to the schema above. Duplex direction is derived
from sign(input - output); offset_mhz = abs(input - output). If lat/long are absent
in the live API response, records still load but sort by county/city grouping instead of
distance (graceful fallback — confirm field presence once the token lands).
New CI-V capability (radio.py + civ.py)
All command numbers below are candidate values from the generic ICOM CI-V table and are
UNTRUSTED until they ACK on the live ID-5100 — same discipline as the existing
DV=0x17 / FM=0x05 verification.
| Capability | Candidate command | Encoding |
|---|---|---|
| Duplex direction | 0x0F |
0x10 simplex / 0x11 DUP− / 0x12 DUP+ |
| Offset amount | 0x0D |
BCD offset frequency (e.g. 0.600 MHz, 5.000 MHz) |
| Repeater TX tone freq | 0x1B 0x00 |
BCD tone (1318 → 131.8 Hz) |
| Tone encode on/off | 0x16 0x42 |
0x00 off / 0x01 on |
Verification method (during implementation, live radio): read the current value,
write it back unchanged (no-op), confirm ACK (0xFB). Only after a command ACKs is it
marked trusted; until then tune-repeater reports that step as failed rather than
pretending success. The offset-freq BCD order (LE vs BE) and tone BCD order are pinned by
unit tests once observed, mirroring the existing freq(LE)/level(BE) split.
API endpoints (server.py)
-
GET /api/repeaters?q=<text>&limit=25Readsrepeaters.jsonviarepeaters.py, computes distance from QTH, filters by case-insensitive substring match on callsign/location, returns nearest-first. Response:{"ok": true, "repeaters": [ {…record…, "distance_mi": 4.2}, … ]}. -
POST /api/tune-repeater(body = one normalized record) Applies steps in fixed order: freq → offset/duplex → tone. Returns per-step results so a half-configured radio is visible, not hidden:{"ok": true, "steps": {"freq": true, "duplex": true, "offset": true, "tone_freq": false, "tone_on": false}, "errors": {"tone_freq": "radio rejected cmd 0x1B"}}okis true if at leastfreqsucceeded; individual failures surface insteps.
Existing endpoints (/api/status, /api/freq, /api/mode, /api/vol, /api/sql)
are unchanged. The serial lock in server.py continues to serialize all radio access.
UI: "Repeaters" card in panel.html
A new .card matching the existing amber/VFD aesthetic, placed after the Levels card:
- Search input (debounced) →
GET /api/repeaters?q=. - Nearest-first list, each row:
W4ABC · Fairfax VA · 147.300 − · 131.8 · 4.2 mi. DV rows carry a smallDVtag so the call-routing limitation isn't surprising. - Tap a row →
POST /api/tune-repeaterwith that record. - Per-step status rendered on the row after tuning:
freq ✓ offset ✓ tone ✗. No single green/red — the operator sees exactly what landed.
No new fonts or third-party JS; reuses existing styles and the post()/fetch helpers.
Data refresh (path 1, runs in parallel, inert until token)
rb_fetch.pyreadsRB_APP_TOKENfromid5100/.env(gitignored, per homelab convention), sends the required descriptiveUser-Agent(id5100-panel/1.0 (homelab; seth@sethfreiberg.com)), pulls VA+DC+MD, writesrepeaters.json. Honors429with backoff.rb-fetch.timer(systemd user unit) runs it on a cadence (default: daily). Both units ship now but stay disabled/inert until the token exists.
Limitations (on the record)
- Tune is not atomic. If offset succeeds but tone NAKs, the radio is half-set. Freq-first ordering guarantees the most important step always lands; per-step UI makes the state visible. Acceptable for a manual operating aid.
- DV repeaters: freq/offset only. D-STAR call routing (URCALL/RPT1/RPT2) is the
RS-MS1A data-jack protocol, explicitly out of scope (see
DECISIONS.md). DV rows are labeled; tapping parks the radio on the right freq/offset, routing is set on the head. - Proximity depends on lat/long surviving in the API response (docs dropped them from the documented field list). Hand-seed includes them; live-API absence falls back to county/city grouping. Confirmable when the token lands.
- New CI-V commands unverified until they ACK on the live radio (see above).
Testing
test_civ.py(extend): BCD encode/decode for offset freq and tone; round-trips.test_repeaters.py(new): haversine distance correctness, nearest-first sort, substring search, normalizer mapping (RepeaterBook fields → schema), duplex-sign and offset-magnitude derivation, lat/long-absent fallback. Pure, no network, no radio.- Live-radio integration (manual, during implementation): verify each new CI-V command
ACKs via read-then-write-back; end-to-end
tune-repeateragainst a known local machine.
Revision 2026-06-14 (post-CSV pivot)
Seth supplied a RepeaterBook CSV proximity export (repeaterbook/RB_2606141409.csv,
1258 rows, DC-metro radius). This supersedes the API/token data path:
- Decision 3 changed → CSV-only. The RepeaterBook API client (
rb_fetch.py) and the systemd refresh timer are dropped (YAGNI). Refresh = re-drop a new CSV and re-run a one-line ingest. The token path can be revived later if hands-off refresh is ever wanted. - No lat/long in the CSV. Distance/haversine and the
QTHconstant are dropped. The export is RepeaterBook's own proximity sort (row 2 = QTH, distance fans outward), so ingest preserves row order as arankinteger andsearch()ranks byrank. The panel shows location, not a distance figure. - CSV columns differ from the API → a CSV normalizer (
normalize_csv) replaces the API-JSONnormalize_rb. Columns:Output Freq, Input Freq, Offset, Uplink Tone, Downlink Tone, Call, Location, County, State, Modes, Digital Access. Offset magnitude =abs(Input−Output); theOffsetcolumn gives only the +/− sign. Uplink Tone = TX tone we set. - Mode/band filter: keep rows whose
ModescontainsFMorDSTAR, within 2m (144–148) or 70cm (420–450). ~1072 of 1258 rows survive. Mode label:FMifFMpresent (operable as analog immediately), elseDVfor pureDSTAR(freq/offset only; call routing on the head). - Schema keeps
lat/longas nullable (future-proof, unused now) and addsrank.
The actionable plan (docs/superpowers/plans/2026-06-14-repeaterbook-integration.md) was
rewritten to match this revision.
Out of scope
- D-STAR DR routing / callsign / messaging (RS-MS1A) — separate effort.
- DMR/NXDN/P25/YSF repeater configuration — the ID-5100 is FM + D-STAR only.
- Automated authenticated CSV scraping of the RepeaterBook website.
- systemd unit for
server.pyitself (tracked separately in the handoff's next-steps).