Files
id5100/docs/superpowers/specs/2026-06-14-repeaterbook-integration-design.md

11 KiB
Raw Permalink Blame History

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 (see README.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

  1. 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.
  2. 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.
  3. 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-seeded repeaters.json immediately and request the token concurrently. (Decision 3+1 from brainstorming.)
  4. 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.
  5. Module split (option B): focused new modules alongside the existing layers, not a monolithic server.py and 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=25 Reads repeaters.json via repeaters.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"}}
    

    ok is true if at least freq succeeded; individual failures surface in steps.

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 small DV tag so the call-routing limitation isn't surprising.
  • Tap a rowPOST /api/tune-repeater with 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.py reads RB_APP_TOKEN from id5100/.env (gitignored, per homelab convention), sends the required descriptive User-Agent (id5100-panel/1.0 (homelab; seth@sethfreiberg.com)), pulls VA+DC+MD, writes repeaters.json. Honors 429 with 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)

  1. 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.
  2. 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.
  3. 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.
  4. 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-repeater against 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 QTH constant are dropped. The export is RepeaterBook's own proximity sort (row 2 = QTH, distance fans outward), so ingest preserves row order as a rank integer and search() ranks by rank. The panel shows location, not a distance figure.
  • CSV columns differ from the API → a CSV normalizer (normalize_csv) replaces the API-JSON normalize_rb. Columns: Output Freq, Input Freq, Offset, Uplink Tone, Downlink Tone, Call, Location, County, State, Modes, Digital Access. Offset magnitude = abs(InputOutput); the Offset column gives only the +/ sign. Uplink Tone = TX tone we set.
  • Mode/band filter: keep rows whose Modes contains FM or DSTAR, within 2m (144148) or 70cm (420450). ~1072 of 1258 rows survive. Mode label: FM if FM present (operable as analog immediately), else DV for pure DSTAR (freq/offset only; call routing on the head).
  • Schema keeps lat/long as nullable (future-proof, unused now) and adds rank.

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.py itself (tracked separately in the handoff's next-steps).