31 KiB
RepeaterBook Integration Implementation Plan (CSV-only)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a "Repeaters" feature to the ID-5100 web panel that lists nearby repeaters (from a RepeaterBook CSV export, proximity-ordered) and, on one tap, configures the radio's frequency, duplex offset, and CTCSS tone over CI-V.
Architecture: Extend the existing civ → radio → {server, panel} layering with one focused new pure module, repeaters.py, that normalizes a RepeaterBook CSV into repeaters.json and serves rank-ordered, text-filtered results. New CI-V offset/tone commands are added to civ.py/radio.py and ground-truthed against the live radio. The panel reads only the local cache.
Tech Stack: Python 3 stdlib only (termios, http.server, csv, json, unittest). No third-party packages. Vanilla JS/CSS in panel.html.
Data source: repeaterbook/RB_2606141409.csv (1258 rows, DC-metro, RepeaterBook proximity sort). Columns: Output Freq, Input Freq, Offset, Uplink Tone, Downlink Tone, Call, Location, County, State, Modes, Digital Access. No lat/long → file order is the distance ranking.
Conventions for this plan
- Not under version control yet.
~/binis not a git repo. Thegit initdecision is pending; until then treat each Commit step as a checkpoint (stop, confirm tests green, move on). Ifgit initis run insideid5100/, the commands work as written. - Back up before modifying existing files (global safety rule). Each task that modifies an existing file starts with a backup step to
id5100/.backup/. - Run tests from the project root:
cd /home/claude/bin/id5100. - One process owns
/dev/ttyUSB0. Stopserver.pybefore any live-radio test (pkill -f "server.py --port 8550"), restart after. - Provisional encodings. Offset/tone byte encodings (Tasks 1–2) are candidates; round-trip tests validate them now, Task 6 captures real bytes from the radio and corrects them + pins a real-capture test if they differ — same as how
freq_to_bcdwas pinned toREAL_BCD.
File structure
| File | Create/Modify | Responsibility |
|---|---|---|
civ.py |
Modify | Offset/tone command constants + BCD encoders (pure) |
test_civ.py |
Modify | Round-trip + structure tests for new encoders |
repeaters.py |
Create | CSV normalize, ingest→json, load, rank-ordered search (pure + small file I/O) |
test_repeaters.py |
Create | normalize_csv mapping + search (rank/filter/limit) tests |
repeaters.json |
Create (generated) | Normalized data, produced by ingesting the CSV |
radio.py |
Modify | set_duplex/set_offset/set_tone/set_tone_on + getters + tune_repeater |
server.py |
Modify | GET /api/repeaters, POST /api/tune-repeater |
panel.html |
Modify | "Repeaters" card: search + rank-ordered list + per-step status |
README.md |
Modify | Document the feature (CSV refresh workflow) |
DECISIONS.md |
Modify | Record decisions/limitations |
Task 1: Offset BCD codec in civ.py
Files:
-
Modify:
civ.py(constants after line 25; functions afterbcd_to_level) -
Test:
test_civ.py -
Step 1: Back up civ.py
Run: mkdir -p .backup && cp civ.py .backup/civ.py-$(date +%s)
- Step 2: Write the failing test — add to
test_civ.pybeforeif __name__:
class TestOffsetCodec(unittest.TestCase):
# PROVISIONAL: 3-byte little-endian BCD in units of 100 Hz.
# Round-trip validated until pinned to a real capture (plan Task 6).
def test_offset_roundtrip(self):
for hz in (600_000, 5_000_000, 1_600_000, 0):
self.assertEqual(civ.bcd_to_offset(civ.offset_to_bcd(hz)), hz)
def test_offset_is_three_bytes(self):
self.assertEqual(len(civ.offset_to_bcd(600_000)), 3)
def test_offset_rejects_negative(self):
with self.assertRaises(ValueError):
civ.offset_to_bcd(-1)
- Step 3: Run test to verify it fails
Run: python3 -m unittest test_civ.TestOffsetCodec -v
Expected: FAIL — AttributeError: module 'civ' has no attribute 'offset_to_bcd'
- Step 4: Add constants + implementation to civ.py
Constants (after line 25, the SUB_SMETER line):
# --- repeater config (candidate commands; verify on live radio) ---
CMD_OFFSET = 0x0D # set duplex offset frequency
CMD_DUPLEX = 0x0F # set duplex direction
DUP_SIMPLEX = 0x10
DUP_MINUS = 0x11
DUP_PLUS = 0x12
CMD_TONE_PARAM = 0x1B # sub 0x00 = repeater (TX) tone frequency
SUB_RPT_TONE = 0x00
CMD_TONE_SWITCH = 0x16 # sub 0x42 = tone encode on/off
SUB_TONE_ENC = 0x42
Functions (after bcd_to_level):
def offset_to_bcd(hz):
"""Duplex offset Hz -> 3-byte little-endian BCD in 100 Hz units (PROVISIONAL)."""
if hz < 0:
raise ValueError(f"offset {hz} must be >= 0")
units = round(hz / 100)
digits = f"{units:06d}"
out = bytearray()
for i in range(0, 6, 2):
out.append((int(digits[4 - i]) << 4) | int(digits[5 - i]))
return bytes(out)
def bcd_to_offset(data):
"""3-byte little-endian BCD (100 Hz units) -> Hz (PROVISIONAL)."""
units = 0
for i, byte in enumerate(data[:3]):
units += (byte & 0x0F) * (10 ** (2 * i)) + (byte >> 4) * (10 ** (2 * i + 1))
return units * 100
- Step 5: Run test to verify it passes
Run: python3 -m unittest test_civ.TestOffsetCodec -v
Expected: PASS (3 tests)
- Step 6: Commit
git add civ.py test_civ.py
git commit -m "feat: offset BCD codec (provisional encoding)"
Task 2: Tone BCD codec in civ.py
Files:
-
Modify:
civ.py -
Test:
test_civ.py -
Step 1: Back up civ.py
Run: cp civ.py .backup/civ.py-$(date +%s)
- Step 2: Write the failing test — add to
test_civ.py:
class TestToneCodec(unittest.TestCase):
# ICOM convention: CTCSS tone as 2-byte BCD of tone*10. 131.8 -> "13 18".
def test_tone_to_bcd_known(self):
self.assertEqual(civ.tone_to_bcd(88.5), b"\x08\x85")
self.assertEqual(civ.tone_to_bcd(131.8), b"\x13\x18")
self.assertEqual(civ.tone_to_bcd(100.0), b"\x10\x00")
def test_bcd_to_tone_known(self):
self.assertAlmostEqual(civ.bcd_to_tone(b"\x13\x18"), 131.8)
self.assertAlmostEqual(civ.bcd_to_tone(b"\x08\x85"), 88.5)
def test_tone_roundtrip(self):
for hz in (67.0, 88.5, 131.8, 146.2, 203.5):
self.assertAlmostEqual(civ.bcd_to_tone(civ.tone_to_bcd(hz)), hz)
- Step 3: Run test to verify it fails
Run: python3 -m unittest test_civ.TestToneCodec -v
Expected: FAIL — AttributeError: module 'civ' has no attribute 'tone_to_bcd'
- Step 4: Implement in civ.py (after the offset functions)
def tone_to_bcd(hz):
"""CTCSS tone Hz -> 2-byte BCD of tone*10 (88.5 -> 08 85)."""
digits = f"{round(hz * 10):04d}"
return bytes([(int(digits[0]) << 4) | int(digits[1]),
(int(digits[2]) << 4) | int(digits[3])])
def bcd_to_tone(data):
"""2-byte BCD of tone*10 -> CTCSS tone Hz."""
t = 0
for byte in data[:2]:
t = t * 100 + (byte >> 4) * 10 + (byte & 0x0F)
return t / 10.0
- Step 5: Run the whole codec suite
Run: python3 -m unittest test_civ -v
Expected: PASS (17 tests: 11 original + 3 offset + 3 tone)
- Step 6: Commit
git add civ.py test_civ.py
git commit -m "feat: CTCSS tone BCD codec"
Task 3: CSV normalizer in repeaters.py
Files:
-
Create:
repeaters.py -
Test:
test_repeaters.py -
Step 1: Write the failing test — create
test_repeaters.py:
#!/usr/bin/env python3
"""Unit tests for repeaters.py (pure: no network, no radio). `python3 -m unittest`."""
import unittest
import repeaters
class TestNormalizeCsv(unittest.TestCase):
ROW = {
"Output Freq": "147.300000", "Input Freq": "147.90000", "Offset": "+",
"Uplink Tone": "131.8", "Downlink Tone": "131.8", "Call": "W4ABC",
"Location": "Fairfax", "County": "Fairfax", "State": "Virginia",
"Modes": "FM ", "Digital Access": "",
}
def test_core_fields(self):
r = repeaters.normalize_csv(self.ROW, 7)
self.assertEqual(r["callsign"], "W4ABC")
self.assertAlmostEqual(r["output_mhz"], 147.300)
self.assertEqual(r["rank"], 7)
self.assertEqual(r["location"], "Fairfax, VA")
def test_offset_sign_and_magnitude(self):
r = repeaters.normalize_csv(self.ROW, 0)
self.assertEqual(r["duplex"], "+")
self.assertAlmostEqual(r["offset_mhz"], 0.600, places=4)
def test_negative_offset(self):
row = dict(self.ROW, **{"Input Freq": "146.700000", "Offset": "-"})
self.assertEqual(repeaters.normalize_csv(row, 0)["duplex"], "-")
def test_simplex(self):
row = dict(self.ROW, **{"Input Freq": "147.300000", "Offset": ""})
self.assertEqual(repeaters.normalize_csv(row, 0)["duplex"], "simplex")
def test_tone(self):
r = repeaters.normalize_csv(self.ROW, 0)
self.assertAlmostEqual(r["tone_hz"], 131.8)
self.assertEqual(r["tone_mode"], "ctcss")
def test_no_tone(self):
row = dict(self.ROW); row["Uplink Tone"] = ""
r = repeaters.normalize_csv(row, 0)
self.assertIsNone(r["tone_hz"])
self.assertEqual(r["tone_mode"], "none")
def test_pure_dstar_is_dv(self):
row = dict(self.ROW, **{"Modes": "DSTAR "})
self.assertEqual(repeaters.normalize_csv(row, 0)["mode"], "DV")
def test_fm_dstar_is_fm(self):
row = dict(self.ROW, **{"Modes": "FM DSTAR "})
self.assertEqual(repeaters.normalize_csv(row, 0)["mode"], "FM")
- Step 2: Run test to verify it fails
Run: python3 -m unittest test_repeaters.TestNormalizeCsv -v
Expected: FAIL — ModuleNotFoundError: No module named 'repeaters'
- Step 3: Create repeaters.py
"""
Repeater directory: ingest a RepeaterBook CSV export into a normalized
repeaters.json, then load / text-search / rank it. Pure logic + small file I/O;
no radio, no network.
CSV is RepeaterBook's proximity export (nearest-first), so file order IS the
distance ranking -> each record carries an integer `rank`.
Normalized record schema:
callsign, location, output_mhz, offset_mhz, duplex ("+"/"-"/"simplex"),
tone_hz (or None), tone_mode ("ctcss"/"none"), mode ("FM"/"DV"),
rank (int), notes
Usage:
python3 repeaters.py ingest repeaterbook/RB_xxxx.csv [repeaters.json]
"""
import csv
import json
import sys
# US state -> USPS abbreviation (only the ones this export touches; extend freely)
_STATE_ABBR = {
"Virginia": "VA", "Maryland": "MD", "District of Columbia": "DC",
"Pennsylvania": "PA", "West Virginia": "WV", "Delaware": "DE",
"New Jersey": "NJ", "North Carolina": "NC",
}
def _f(val):
"""Parse a possibly-empty numeric string to float, else None."""
try:
return float(val)
except (TypeError, ValueError):
return None
def normalize_csv(row, rank):
"""Map a RepeaterBook CSV row (dict) + its file rank to the internal schema."""
out = _f(row.get("Output Freq"))
inp = _f(row.get("Input Freq"))
sign = (row.get("Offset") or "").strip()
if out is not None and inp is not None and sign in ("+", "-"):
duplex = sign
offset = round(abs(inp - out), 5)
else:
duplex, offset = "simplex", 0.0
tone = _f(row.get("Uplink Tone"))
city = (row.get("Location") or "").strip()
state = (row.get("State") or "").strip()
location = ", ".join(p for p in (city, _STATE_ABBR.get(state, state)) if p)
modes = (row.get("Modes") or "").upper()
mode = "FM" if "FM" in modes else ("DV" if "DSTAR" in modes else "FM")
return {
"callsign": (row.get("Call") or "").strip(),
"location": location,
"output_mhz": out,
"offset_mhz": offset,
"duplex": duplex,
"tone_hz": tone,
"tone_mode": "ctcss" if tone else "none",
"mode": mode,
"rank": rank,
"notes": "",
}
- Step 4: Run test to verify it passes
Run: python3 -m unittest test_repeaters.TestNormalizeCsv -v
Expected: PASS (8 tests)
- Step 5: Commit
git add repeaters.py test_repeaters.py
git commit -m "feat: RepeaterBook CSV row normalizer"
Task 4: load + search in repeaters.py
Files:
-
Modify:
repeaters.py -
Test:
test_repeaters.py -
Step 1: Write the failing test — add to
test_repeaters.py:
class TestSearch(unittest.TestCase):
RECS = [
{"callsign": "NEAR", "location": "Fairfax, VA", "mode": "FM", "rank": 0},
{"callsign": "MID", "location": "Bethesda, MD", "mode": "DV", "rank": 5},
{"callsign": "FAR", "location": "Norfolk, VA", "mode": "FM", "rank": 50},
]
def test_rank_order_preserved(self):
out = repeaters.search(self.RECS, "", 10)
self.assertEqual([r["callsign"] for r in out], ["NEAR", "MID", "FAR"])
def test_text_filter_callsign_and_location(self):
self.assertEqual(len(repeaters.search(self.RECS, "norfolk", 10)), 1)
self.assertEqual(repeaters.search(self.RECS, "far", 10)[0]["callsign"], "FAR")
def test_filter_is_case_insensitive(self):
self.assertEqual(repeaters.search(self.RECS, "BETHESDA", 10)[0]["callsign"], "MID")
def test_limit(self):
self.assertEqual(len(repeaters.search(self.RECS, "", 2)), 2)
class TestLoad(unittest.TestCase):
def test_missing_file_returns_empty(self):
self.assertEqual(repeaters.load("/no/such/file.json"), [])
- Step 2: Run test to verify it fails
Run: python3 -m unittest test_repeaters.TestSearch -v
Expected: FAIL — AttributeError: module 'repeaters' has no attribute 'search'
- Step 3: Add load + search to repeaters.py (after
normalize_csv)
def load(path):
"""Read a normalized repeaters.json (list of records). Missing file -> []."""
try:
with open(path) as f:
data = json.load(f)
except FileNotFoundError:
return []
return data if isinstance(data, list) else data.get("repeaters", [])
def search(records, q, limit=25):
"""Filter by case-insensitive substring (callsign/location), keep rank order."""
q = (q or "").strip().lower()
out = [r for r in records
if not q or q in f"{r.get('callsign','')} {r.get('location','')}".lower()]
out.sort(key=lambda r: r.get("rank", 1_000_000))
return out[:limit]
- Step 4: Run test to verify it passes
Run: python3 -m unittest test_repeaters -v
Expected: PASS (8 normalize + 4 search + 1 load = 13 tests)
- Step 5: Commit
git add repeaters.py test_repeaters.py
git commit -m "feat: repeater load + rank-ordered search"
Task 5: CSV ingest CLI + generate repeaters.json
Files:
-
Modify:
repeaters.py(addingest_csv+__main__) -
Create:
repeaters.json(generated output) -
Step 1: Add ingest_csv + main to repeaters.py (end of file)
def ingest_csv(csv_path, out_path):
"""Read a RepeaterBook CSV, filter to FM/DSTAR on 2m/70cm, write repeaters.json.
Returns the number of records written. File order is preserved as `rank`."""
recs = []
with open(csv_path, newline="") as f:
for i, row in enumerate(csv.DictReader(f)):
rec = normalize_csv(row, i)
f_mhz = rec["output_mhz"]
if f_mhz is None:
continue
modes = (row.get("Modes") or "").upper()
if "FM" not in modes and "DSTAR" not in modes:
continue
if not (144 <= f_mhz < 148 or 420 <= f_mhz < 450):
continue
recs.append(rec)
with open(out_path, "w") as f:
json.dump(recs, f, indent=1)
return len(recs)
if __name__ == "__main__":
if len(sys.argv) >= 3 and sys.argv[1] == "ingest":
out = sys.argv[3] if len(sys.argv) > 3 else "repeaters.json"
n = ingest_csv(sys.argv[2], out)
print(f"wrote {n} repeaters -> {out}")
else:
print("usage: python3 repeaters.py ingest <csv> [out.json]", file=sys.stderr)
sys.exit(2)
- Step 2: Generate repeaters.json from the real CSV
Run: python3 repeaters.py ingest repeaterbook/RB_2606141409.csv repeaters.json
Expected: wrote 1072 repeaters -> repeaters.json (±a few)
- Step 3: Sanity-check the output
Run: python3 -c "import repeaters as R; d=R.load('repeaters.json'); print('count', len(d)); print('first', d[0]['callsign'], d[0]['output_mhz'], d[0]['duplex'], d[0]['tone_hz'], d[0]['mode']); print('top5 search FM', [r['callsign'] for r in R.search(d,'',5)])"
Expected: count ~1072; first record is the Annandale machine (rank 0); a 5-item list.
- Step 4: Commit
git add repeaters.py repeaters.json
git commit -m "feat: CSV ingest CLI + generated repeaters.json"
Task 6: Radio control methods + live verification
Files:
- Modify:
radio.py(aftersquelch_open, ~line 158)
No unit tests (live serial I/O). The provisional offset/tone encodings get ground-truthed here. Requires the radio on
/dev/ttyUSB0; stop the server first.
- Step 1: Back up radio.py
Run: cp radio.py .backup/radio.py-$(date +%s)
- Step 2: Add methods to the
Radioclass
# ---- repeater config (candidate commands; verify live) ----------------
def set_duplex(self, direction):
"""direction: '+', '-', or 'simplex'."""
code = {"+": civ.DUP_PLUS, "-": civ.DUP_MINUS,
"simplex": civ.DUP_SIMPLEX}.get(direction)
if code is None:
raise ValueError(f"bad duplex {direction!r}")
return self._command(civ.CMD_DUPLEX, bytes([code]))
def set_offset(self, hz):
return self._command(civ.CMD_OFFSET, civ.offset_to_bcd(hz))
def get_offset(self):
return civ.bcd_to_offset(self.transact(civ.CMD_OFFSET).data)
def set_tone(self, hz):
return self._command(civ.CMD_TONE_PARAM,
bytes([civ.SUB_RPT_TONE]) + civ.tone_to_bcd(hz))
def get_tone(self):
return civ.bcd_to_tone(self.transact(civ.CMD_TONE_PARAM,
bytes([civ.SUB_RPT_TONE])).data[1:])
def set_tone_on(self, on):
return self._command(civ.CMD_TONE_SWITCH,
bytes([civ.SUB_TONE_ENC, 0x01 if on else 0x00]))
def tune_repeater(self, rec):
"""Apply a normalized record: freq -> duplex/offset -> tone.
Returns (steps, errors): each step True/False; errors keyed by step."""
steps, errors = {}, {}
def attempt(name, fn):
try:
fn(); steps[name] = True
except (CIVError, ValueError, OSError) as e:
steps[name] = False; errors[name] = str(e)
attempt("freq", lambda: self.set_frequency(int(round(rec["output_mhz"] * 1e6))))
duplex = rec.get("duplex", "simplex")
attempt("duplex", lambda: self.set_duplex(duplex))
if duplex != "simplex":
attempt("offset", lambda: self.set_offset(int(round(rec.get("offset_mhz", 0) * 1e6))))
if rec.get("tone_mode") == "ctcss" and rec.get("tone_hz"):
attempt("tone_freq", lambda: self.set_tone(rec["tone_hz"]))
attempt("tone_on", lambda: self.set_tone_on(True))
return steps, errors
- Step 3: Syntax check
Run: python3 -c "import radio; print('ok', hasattr(radio.Radio, 'tune_repeater'))"
Expected: ok True
- Step 4: Capture current radio state for each new command (radio attached, server stopped)
Run:
pkill -f "server.py --port 8550" 2>/dev/null
python3 -c "
from radio import Radio
import civ
with Radio('/dev/ttyUSB0', 9600) as r:
for cmd, sub, label in [(civ.CMD_OFFSET, b'', 'offset'),
(civ.CMD_TONE_PARAM, bytes([civ.SUB_RPT_TONE]), 'tone')]:
try:
print(label, 'raw:', r.transact(cmd, sub).data.hex(' '))
except Exception as e:
print(label, 'ERR', e)
"
Expected: hex bytes for offset and tone (or an error → wrong command number).
-
Step 5: Reconcile encoding with observed bytes
-
Command errors/NAKs → wrong command number; consult the ID-5100 manual CI-V table, fix the constant in
civ.py, repeat Step 4. -
Returns bytes → decode with the current encoder; compare to the radio's displayed offset/tone. If mismatch, adjust
offset_to_bcd/bcd_to_offset(byte count/order/units) ortone_to_bcd, and add a real-capture test totest_civ.pypinning the observed bytes (likeREAL_BCD). -
Step 6: No-op write-back (proves write path ACKs without changing state)
Run:
python3 -c "
from radio import Radio
with Radio('/dev/ttyUSB0', 9600) as r:
print('offset', r.get_offset()); r.set_offset(r.get_offset()); print('offset OK')
print('tone', r.get_tone()); r.set_tone(r.get_tone()); print('tone OK')
"
Expected: prints values then OK per trusted command. Any exception = that command stays untrusted; note it (the UI will show that step failing — intended).
- Step 7: Commit (record verification result in DECISIONS.md)
git add radio.py civ.py test_civ.py DECISIONS.md
git commit -m "feat: radio duplex/offset/tone control + live-verified encodings"
Task 7: GET /api/repeaters in server.py
Files:
-
Modify:
server.py -
Step 1: Back up server.py
Run: cp server.py .backup/server.py-$(date +%s)
- Step 2: Add import + path constant (after existing imports, near line 21)
import repeaters
REPEATERS_JSON = os.path.join(HERE, "repeaters.json")
- Step 3: Add the route to
do_GET(in theelifchain, before the finalelse)
elif self.path.startswith("/api/repeaters"):
from urllib.parse import urlparse, parse_qs
qs = parse_qs(urlparse(self.path).query)
q = qs.get("q", [""])[0]
limit = int(qs.get("limit", ["25"])[0])
recs = repeaters.search(repeaters.load(REPEATERS_JSON), q, limit)
self._json({"ok": True, "repeaters": recs})
- Step 4: Verify (radio attached; use port 8551 to avoid the live panel)
Run:
pkill -f "server.py --port 8550" 2>/dev/null
python3 server.py --port 8551 &
sleep 1
curl -s 'http://127.0.0.1:8551/api/repeaters?limit=3' | python3 -m json.tool
curl -s 'http://127.0.0.1:8551/api/repeaters?q=fairfax&limit=3' | python3 -m json.tool
pkill -f "server.py --port 8551"
Expected: {"ok": true, "repeaters": [...3 rank-ordered records...]}, and the filtered query returns Fairfax matches.
- Step 5: Commit
git add server.py
git commit -m "feat: GET /api/repeaters endpoint"
Task 8: POST /api/tune-repeater in server.py
Files:
-
Modify:
server.py -
Step 1: Back up server.py
Run: cp server.py .backup/server.py-$(date +%s)
- Step 2: Extend
apply_control— change its tail so the new path returns a per-step result:
elif path == "/api/tune-repeater":
steps, errors = radio.tune_repeater(body)
return {"ok": bool(steps.get("freq")), "steps": steps, "errors": errors}
else:
raise CIVError("unknown control")
return {"ok": True}
- Step 3: Verify (radio attached)
Run:
pkill -f "server.py --port 8550" 2>/dev/null
python3 server.py --port 8551 &
sleep 1
curl -s -X POST http://127.0.0.1:8551/api/tune-repeater \
-H 'Content-Type: application/json' \
-d '{"output_mhz":147.30,"duplex":"+","offset_mhz":0.6,"tone_mode":"ctcss","tone_hz":167.9}' \
| python3 -m json.tool
pkill -f "server.py --port 8551"
Expected: {"ok": true, "steps": {"freq": true, "duplex": ..., "offset": ..., "tone_freq": ..., "tone_on": ...}, "errors": {...}}. Some steps may be false if Task 6 left commands untrusted — intended, visible behavior.
- Step 4: Commit
git add server.py
git commit -m "feat: POST /api/tune-repeater (per-step result)"
Task 9: Repeaters card in panel.html
Files:
-
Modify:
panel.html -
Step 1: Back up panel.html
Run: cp panel.html .backup/panel.html-$(date +%s)
- Step 2: Add the card markup — insert after the Levels card's closing
</div>(after line 200, before the</div>that closes.panel):
<div class="card reveal" style="animation-delay:.40s">
<div class="label">Repeaters · nearest first</div>
<div class="freqset">
<input id="rbq" type="text" inputmode="search" placeholder="search callsign / city">
</div>
<div id="rblist" class="rblist"></div>
</div>
- Step 3: Add CSS — inside
<style>, before</style>:
.rblist{display:flex;flex-direction:column;gap:6px;margin-top:10px;max-height:300px;overflow-y:auto}
.rbrow{display:flex;flex-direction:column;gap:3px;
background:#0c0b08;border:1px solid #3a352b;border-radius:8px;padding:9px 11px;cursor:pointer}
.rbrow:active{transform:translateY(1px)}
.rbrow .cs{font-family:'Saira Condensed',sans-serif;font-weight:800;color:var(--ink);letter-spacing:.06em}
.rbrow .dv{color:var(--accent-hi);border:1px solid var(--accent);border-radius:10px;
padding:1px 6px;font-size:9px;letter-spacing:.1em;margin-left:6px}
.rbrow .meta{color:var(--ink-dim);font-size:11px}
.rbrow .steps{font-family:'Chivo Mono';font-size:10px;letter-spacing:.04em;color:var(--ink-dim);min-height:12px}
.rbrow .steps .ok{color:var(--led-on)} .rbrow .steps .no{color:var(--red)}
- Step 4: Add JS — inside
<script>, before the finalrefresh();:
const rbq=$('#rbq'), rblist=$('#rblist');
function stepHTML(steps){
if(!steps) return '';
return Object.entries(steps).map(([k,v])=>
`<span class="${v?'ok':'no'}">${k} ${v?'✓':'✗'}</span>`).join(' ');
}
async function loadRepeaters(){
try{
const r=await fetch('/api/repeaters?limit=40&q='+encodeURIComponent(rbq.value||''));
const j=await r.json(); if(!j.ok) return;
rblist.innerHTML='';
j.repeaters.forEach(rep=>{
const row=document.createElement('div'); row.className='rbrow';
const dup=rep.duplex==='simplex'?'':(' '+rep.duplex);
const tone=rep.tone_hz?(' · '+rep.tone_hz):'';
row.innerHTML=
`<div><span class="cs">${rep.callsign||'—'}</span>`+
`${rep.mode==='DV'?'<span class="dv">DV</span>':''}</div>`+
`<div class="meta">${rep.location||''} · ${(rep.output_mhz||0).toFixed(3)}${dup}${tone}</div>`+
`<div class="steps"></div>`;
row.onclick=()=>tuneRepeater(rep,row);
rblist.appendChild(row);
});
}catch(e){/* offline: leave list as-is */}
}
async function tuneRepeater(rep,row){
const s=row.querySelector('.steps'); s.textContent='tuning…';
try{
const r=await fetch('/api/tune-repeater',{method:'POST',
headers:{'Content-Type':'application/json'},body:JSON.stringify(rep)});
const j=await r.json(); s.innerHTML=stepHTML(j.steps); refresh();
}catch(e){ s.innerHTML='<span class="no">send failed</span>'; }
}
let rbt; rbq.addEventListener('input',()=>{clearTimeout(rbt);rbt=setTimeout(loadRepeaters,250);});
loadRepeaters();
- Step 5: Visual verification (claude-display, per homelab convention)
Start the server on 8551 (radio attached), screenshot http://127.0.0.1:8551/ via claude-display, confirm the Repeaters card renders rank-ordered with the Annandale machine on top. Tap a row (or POST via curl) and confirm per-step status (freq ✓ offset ✓ tone ✗) appears on that row.
- Step 6: Commit
git add panel.html
git commit -m "feat: Repeaters card with per-step tune status"
Task 10: Docs
Files:
-
Modify:
README.md,DECISIONS.md -
Step 1: Back up both
Run: cp README.md .backup/README.md-$(date +%s); cp DECISIONS.md .backup/DECISIONS.md-$(date +%s)
- Step 2: Add a "Repeaters" section to README.md (after the Web panel section)
## Repeaters (RepeaterBook CSV)
The panel lists nearby repeaters from a local `repeaters.json`, in RepeaterBook's
proximity order, and tunes the radio (freq + offset + tone) on tap.
- **Data source:** a RepeaterBook CSV proximity export (Account → download CSV for your
area). The export has no lat/long, but it's sorted nearest-first, so row order is the
ranking.
- **Refresh:** download a fresh CSV into `repeaterbook/`, then
`python3 repeaters.py ingest repeaterbook/<file>.csv repeaters.json`.
- **Filter:** keeps FM + D-STAR on 2m/70cm (drops DMR/P25/NXDN-only).
- **Limitations:** tuning is not atomic (per-step status shown); D-STAR machines get
freq/offset only — call routing (URCALL/RPT1/RPT2) is set on the radio head.
- Step 3: Append to DECISIONS.md
2026-06-14: RepeaterBook integration — local repeaters.json + proximity-ordered panel card; full tune (freq+offset+tone) over CI-V. — Operating convenience without per-request API calls.
2026-06-14: CSV-only data path (dropped the token-gated API client + systemd timer). — Seth supplied a RepeaterBook CSV proximity export; manual re-ingest is enough, YAGNI on automation.
2026-06-14: No lat/long in the CSV; rank by file order (RepeaterBook's proximity sort) instead of haversine. — Export is already nearest-first; geocoding would be overkill.
2026-06-14: Mixed FM+DSTAR repeaters labeled FM (operable as analog); pure DSTAR labeled DV (freq/offset only). — FM works immediately without call routing.
2026-06-14: New CI-V offset/tone commands untrusted until they ACK on the live radio; per-step tune result surfaces partial success. — Same discipline as DV=0x17.
### Deferred / Rejected
2026-06-14: RepeaterBook API client + daily systemd refresh — deferred (CSV-only chosen); revive if hands-off refresh is wanted. Token request form text is in the spec.
2026-06-14: D-STAR DR call routing in the tune flow — rejected (RS-MS1A data-jack protocol, out of CI-V scope).
- Step 4: Run the full test suite one last time
Run: python3 -m unittest test_civ test_repeaters -v
Expected: all PASS (17 + 13 = 30 tests; more if Task 6 added real-capture tests).
- Step 5: Commit
git add README.md DECISIONS.md
git commit -m "docs: document RepeaterBook CSV integration"
Self-review notes
- Spec coverage (post-CSV revision): CSV normalizer (Task 3), load/search rank-ordered (Task 4), ingest + real data (Task 5), CI-V offset/tone + live verification (Tasks 1,2,6), endpoints (Tasks 7,8), UI card with per-step status (Task 9), CSV refresh + limitations documented (Task 10). API client/systemd intentionally dropped per the revision.
- Type consistency:
normalize_csv(row, rank),load(path),search(records, q, limit),ingest_csv(csv, out)consistent across repeaters.py, server.py, and the ingest CLI.tune_repeaterreturns(steps, errors); server wraps to{ok, steps, errors}; panel readsj.steps.offset_to_bcd/bcd_to_offset/tone_to_bcd/bcd_to_toneconsistent civ↔radio.searchtakes no QTH (no distance); panel shows location, not miles. - Provisional-encoding risk contained to Tasks 1,2 and resolved in Task 6 before the UI is trusted.