chore: baseline existing CI-V suite before RepeaterBook integration
This commit is contained in:
@@ -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 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 <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.
|
||||
```
|
||||
Reference in New Issue
Block a user