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

853 lines
31 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.** `~/bin` is not a git repo. The `git init` decision is pending; until then treat each **Commit** step as a checkpoint (stop, confirm tests green, move on). If `git init` is run inside `id5100/`, 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`.** Stop `server.py` before any live-radio test (`pkill -f "server.py --port 8550"`), restart after.
- **Provisional encodings.** Offset/tone byte encodings (Tasks 12) 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_bcd` was pinned to `REAL_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 after `bcd_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.py` before `if __name__`:
```python
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):
```python
# --- 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`):
```python
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**
```bash
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`:
```python
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)
```python
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**
```bash
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`:
```python
#!/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**
```python
"""
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**
```bash
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`:
```python
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`)
```python
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**
```bash
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` (add `ingest_csv` + `__main__`)
- Create: `repeaters.json` (generated output)
- [ ] **Step 1: Add ingest_csv + __main__ to repeaters.py** (end of file)
```python
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**
```bash
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` (after `squelch_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 `Radio` class**
```python
# ---- 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:
```bash
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) or `tone_to_bcd`, and **add a real-capture test** to `test_civ.py` pinning the observed bytes (like `REAL_BCD`).
- [ ] **Step 6: No-op write-back (proves write path ACKs without changing state)**
Run:
```bash
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)
```bash
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)
```python
import repeaters
REPEATERS_JSON = os.path.join(HERE, "repeaters.json")
```
- [ ] **Step 3: Add the route to `do_GET`** (in the `elif` chain, before the final `else`)
```python
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:
```bash
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**
```bash
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:
```python
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:
```bash
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**
```bash
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`):
```html
<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>`:
```css
.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 final `refresh();`:
```javascript
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**
```bash
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)
```markdown
## 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**
```markdown
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**
```bash
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_repeater` returns `(steps, errors)`; server wraps to `{ok, steps, errors}`; panel
reads `j.steps`. `offset_to_bcd/bcd_to_offset/tone_to_bcd/bcd_to_tone` consistent civ↔radio.
`search` takes 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.
```