chore: baseline existing CI-V suite before RepeaterBook integration

This commit is contained in:
2026-06-14 16:02:36 -04:00
commit 03000708b6
14 changed files with 3550 additions and 0 deletions
@@ -0,0 +1,852 @@
# 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.
```