# 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 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_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 [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 `` (after line 200, before the `` that closes `.panel`): ```html
Repeaters · nearest first
``` - [ ] **Step 3: Add CSS** — inside ``: ```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 `