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

31 KiB
Raw Blame History

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__:
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 (add ingest_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 (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
    # ---- 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) 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:

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 the elif chain, before the final else)
        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 final refresh();:
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_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.