chore: baseline existing CI-V suite before RepeaterBook integration
This commit is contained in:
@@ -0,0 +1,172 @@
|
|||||||
|
# Handoff: ID-5100 CI-V control suite (library + CLI + web panel)
|
||||||
|
|
||||||
|
## Session Metadata
|
||||||
|
- Created: 2026-06-14 14:40:31
|
||||||
|
- Project: /home/claude/bin/id5100
|
||||||
|
- Branch: [not a git repo — ~/bin is not under git]
|
||||||
|
- Session duration: ~1 session
|
||||||
|
|
||||||
|
### Recent Commits (for context)
|
||||||
|
- [not a git repo — no commits]
|
||||||
|
|
||||||
|
## Handoff Chain
|
||||||
|
|
||||||
|
- **Continues from**: None (fresh start)
|
||||||
|
- **Supersedes**: None
|
||||||
|
|
||||||
|
> This is the first handoff for this task.
|
||||||
|
|
||||||
|
## Current State Summary
|
||||||
|
|
||||||
|
Built a complete software control layer for Seth's ICOM ID-5100 mobile transceiver
|
||||||
|
over CI-V, from nothing to a working web control panel, in one session. Started by
|
||||||
|
verifying the hardware link (RT Systems USB-29A cable into the radio's [SP2] jack),
|
||||||
|
then built a pure-stdlib codec, a serial transport, a CLI, and an industrial-themed
|
||||||
|
web panel. **Everything is verified against the live radio** — reads, writes, and the
|
||||||
|
UI screenshot. The web server is running now and reachable on the LAN. The project is
|
||||||
|
in a clean, finished-MVP state; remaining items are optional enhancements, not gaps.
|
||||||
|
|
||||||
|
## Codebase Understanding
|
||||||
|
|
||||||
|
### Architecture Overview
|
||||||
|
|
||||||
|
Three clean layers, each a front-end on the one below:
|
||||||
|
- **Codec** (`civ.py`) — pure functions, no I/O: BCD freq/level encode-decode, CI-V
|
||||||
|
frame build/parse. Fully unit-tested (11 tests).
|
||||||
|
- **Transport** (`radio.py`) — `Radio` class over pure-stdlib `termios` (no pyserial).
|
||||||
|
get/set frequency, mode, volume, squelch level, S-meter, squelch status.
|
||||||
|
- **Front-ends** — `cli.py` (argparse) and `server.py` + `panel.html` (web). Both just
|
||||||
|
call `radio.Radio`.
|
||||||
|
|
||||||
|
### Critical Files
|
||||||
|
|
||||||
|
| File | Purpose | Relevance |
|
||||||
|
|------|---------|-----------|
|
||||||
|
| `civ.py` | Pure CI-V codec (BCD, frames) | Core; change here = re-run test_civ.py |
|
||||||
|
| `radio.py` | Serial transport + `Radio` class | All control flows through this |
|
||||||
|
| `cli.py` | CLI front end | `./cli.py status` etc. |
|
||||||
|
| `server.py` | Web panel HTTP server + JSON API | Holds the serial port while running |
|
||||||
|
| `panel.html` | Instrument-panel UI (one self-contained file) | Amber VFD aesthetic |
|
||||||
|
| `test_civ.py` | 11 codec unit tests | `python3 -m unittest test_civ` |
|
||||||
|
| `civ_probe.py` | One-shot link probe | Quick "is the radio alive" check |
|
||||||
|
| `README.md`, `DECISIONS.md` | Docs + decision log | Read first when resuming |
|
||||||
|
|
||||||
|
### Key Patterns Discovered
|
||||||
|
|
||||||
|
- **Verified link params:** `/dev/ttyUSB0`, **9600 baud**, radio CI-V address **0x8C**,
|
||||||
|
controller address 0xE0.
|
||||||
|
- **Bus echo:** the radio echoes your TX frame before its reply; `transact()` filters
|
||||||
|
frames whose `frm != RADIO_ADDR` (0x8C) to skip the echo.
|
||||||
|
- **Two BCD byte orders on the same radio:** frequency is 5-byte **little-endian** BCD;
|
||||||
|
levels/meters are 2-byte **big-endian** BCD (128 -> `01 28`). Pinned by unit tests.
|
||||||
|
- **Mode FM = 0x05** confirmed empirically (radio returned `05 01`).
|
||||||
|
|
||||||
|
## Work Completed
|
||||||
|
|
||||||
|
### Tasks Finished
|
||||||
|
|
||||||
|
- [x] Verified PC<->radio CI-V link (read VFO = 147.300000 MHz)
|
||||||
|
- [x] TDD'd pure codec `civ.py` (11 tests green, grounded in real capture)
|
||||||
|
- [x] Built transport `radio.py`, ground-truthed all reads + write path on live radio
|
||||||
|
- [x] Optimized `transact()` early-return (status poll ~3.6s -> ~0.2s)
|
||||||
|
- [x] Built CLI `cli.py` (status/freq/mode/vol/sql/smeter)
|
||||||
|
- [x] Built web panel `server.py` + `panel.html`, verified via headless screenshot + control POST
|
||||||
|
- [x] Added `ID-5100 CI-V` row to ~/bin/CLAUDE.md projects table (backed up first)
|
||||||
|
- [x] Wrote README.md + DECISIONS.md
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
|
||||||
|
| File | Changes | Rationale |
|
||||||
|
|------|---------|-----------|
|
||||||
|
| /home/claude/bin/CLAUDE.md | +1 projects-table row | Register the project (backup: .backup/CLAUDE.md-1781461768) |
|
||||||
|
| (all id5100/* files) | Created this session | The deliverable |
|
||||||
|
|
||||||
|
### Decisions Made
|
||||||
|
|
||||||
|
| Decision | Options Considered | Rationale |
|
||||||
|
|----------|-------------------|-----------|
|
||||||
|
| Pure stdlib `termios` | pyserial | System Python is PEP-668 locked; --break-system-packages violates escalation-brake rule |
|
||||||
|
| stdlib `http.server` for web | Flask/FastAPI | Keep no-deps promise |
|
||||||
|
| Early-return `transact()` | fixed timeout | Makes ~1 Hz live S-meter polling viable |
|
||||||
|
| Industrial VFD aesthetic | generic dashboard | Matches radio-gear domain + Sethian dark/orange brand |
|
||||||
|
| Reject full headless-head replica | reverse-engineer head bus | Proprietary/undocumented; CI-V covers operating functions |
|
||||||
|
|
||||||
|
(Full list in DECISIONS.md.)
|
||||||
|
|
||||||
|
## Pending Work
|
||||||
|
|
||||||
|
## Immediate Next Steps
|
||||||
|
|
||||||
|
1. **(Optional) systemd user unit for `server.py`** — it's currently a bare background
|
||||||
|
process (won't survive reboot/logout). This is the most likely next ask.
|
||||||
|
2. **(Optional) Vendor the web fonts locally** — `panel.html` pulls DSEG7 (jsdelivr) +
|
||||||
|
Saira/Chivo Mono (Google Fonts) from CDNs; offline it falls back to monospace.
|
||||||
|
3. **(Optional) Verify DV mode (0x17) on the live radio** — currently from the generic
|
||||||
|
ICOM table, flagged not-trusted. Test `./cli.py mode dv` and confirm the radio ACKs.
|
||||||
|
|
||||||
|
### Blockers/Open Questions
|
||||||
|
|
||||||
|
- [ ] None blocking. Open question: does Seth want this behind Caddy with a subdomain
|
||||||
|
(e.g. `radio.sethpc.xyz`) like his other services? Not discussed.
|
||||||
|
|
||||||
|
### Deferred Items
|
||||||
|
|
||||||
|
- Full software control-head replica (proprietary head bus) — rejected, see DECISIONS.md.
|
||||||
|
- RS-MS1A data-jack protocol (D-STAR DR routing / callsign / messaging) — out of scope
|
||||||
|
for this CI-V layer; separate effort if wanted.
|
||||||
|
- HA/MQTT bridge — offered, not chosen this session.
|
||||||
|
|
||||||
|
## Context for Resuming Agent
|
||||||
|
|
||||||
|
## Important Context
|
||||||
|
|
||||||
|
The whole suite is **verified against real hardware**, not just written. If resuming,
|
||||||
|
you can trust the link params and the codec. The radio is a physical device on Seth's
|
||||||
|
desk — **set-commands change real radio state**, so prefer no-op writes (write current
|
||||||
|
value back) when testing the write path, as was done here. The only process that can
|
||||||
|
hold `/dev/ttyUSB0` is one at a time: **stop `server.py` before running `cli.py`** or
|
||||||
|
the probe, and vice versa.
|
||||||
|
|
||||||
|
### Assumptions Made
|
||||||
|
|
||||||
|
- The radio stays on `/dev/ttyUSB0` (single USB-serial device; confirmed by Seth).
|
||||||
|
- CI-V baud stays 9600 (Seth set this in the radio menu this session).
|
||||||
|
- The PC hosting all this is steel141 (192.168.0.141).
|
||||||
|
|
||||||
|
### Potential Gotchas
|
||||||
|
|
||||||
|
- **Don't `--break-system-packages`** — stdlib-only is a deliberate decision.
|
||||||
|
- **Bus echo** will confuse any new raw-serial code; reuse `civ.parse_frames` + the
|
||||||
|
`frm == RADIO_ADDR` filter.
|
||||||
|
- **DV=0x17 is unverified** — don't present `set_mode('dv')` as trusted yet.
|
||||||
|
- The web panel needs internet for the nice fonts (graceful monospace fallback otherwise).
|
||||||
|
|
||||||
|
## Environment State
|
||||||
|
|
||||||
|
### Tools/Services Used
|
||||||
|
|
||||||
|
- RT Systems USB-29A cable (FTDI), enumerates as `/dev/serial/by-id/usb-RT_Systems_CT29A_Radio_Cable_RT90581N-if00-port0` -> `/dev/ttyUSB0`
|
||||||
|
- `claude` user is in `dialout` group (serial access OK)
|
||||||
|
- chromium (headless) used for the verification screenshot
|
||||||
|
- Python 3.13 (system, PEP-668 externally-managed)
|
||||||
|
|
||||||
|
### Active Processes
|
||||||
|
|
||||||
|
- **`server.py --port 8550` is RUNNING** (was PID 380991 this session) at
|
||||||
|
**http://192.168.0.141:8550/** (bound 0.0.0.0, LAN/phone reachable). Log at
|
||||||
|
`id5100/.server.log`. It holds `/dev/ttyUSB0`. Kill with `pkill -f "server.py --port 8550"`.
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
- None specific to this project. (Homelab-wide: `HOMELAB_PASSWORD` exists but unused here.)
|
||||||
|
|
||||||
|
## Related Resources
|
||||||
|
|
||||||
|
- `id5100/README.md` — usage + verified-facts
|
||||||
|
- `id5100/DECISIONS.md` — decision log incl. Deferred/Rejected
|
||||||
|
- `~/bin/CLAUDE.md` — projects table entry
|
||||||
|
- ICOM ID-5100 full manual CI-V section (p.305) — frame format / address 0x8C
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Security Reminder**: Before finalizing, run `validate_handoff.py` to check for accidental secret exposure.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
.backup/
|
||||||
|
.server.log
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# id5100 — Decisions
|
||||||
|
|
||||||
|
Format: `YYYY-MM-DD: <decision> — <why>`
|
||||||
|
|
||||||
|
- 2026-06-14: Pure-stdlib `termios` instead of pyserial — system Python is PEP-668
|
||||||
|
externally-managed; per the escalation-brake rule, `--break-system-packages` for
|
||||||
|
one dependency is the wrong trade. `termios` is zero-install and reusable.
|
||||||
|
- 2026-06-14: Layered as codec (`civ.py`) / transport (`radio.py`) / CLI (`cli.py`)
|
||||||
|
— the codec is pure and unit-testable; web UI / HA-MQTT front ends bolt onto the
|
||||||
|
transport without touching byte plumbing.
|
||||||
|
- 2026-06-14: TDD the codec against the real probe capture (`00 00 30 47 01` ↔
|
||||||
|
147.300000 MHz) — gives a hardware-true vector, not a made-up one.
|
||||||
|
- 2026-06-14: Cable lands on **[SP2]** (= CI-V), 9600 baud, addr 0x8C — verified
|
||||||
|
live, not assumed.
|
||||||
|
|
||||||
|
- 2026-06-14: Web panel = stdlib `http.server` + single `panel.html`, no framework
|
||||||
|
— keeps the no-deps promise; the UI is one self-contained file. One shared `Radio`
|
||||||
|
guarded by a lock (CI-V is half-duplex; concurrent transactions would interleave).
|
||||||
|
- 2026-06-14: `transact()` returns as soon as the radio's reply frame parses instead
|
||||||
|
of always burning the full timeout — dropped a full status poll from ~3.6s to ~0.2s,
|
||||||
|
making a ~1 Hz live S-meter poll viable.
|
||||||
|
- 2026-06-14: Aesthetic = industrial instrument faceplate (amber VFD via DSEG7 font,
|
||||||
|
LED S-meter, illuminated mode buttons) — matches the radio-gear domain and the
|
||||||
|
Sethian dark/orange brand. Verified by headless screenshot.
|
||||||
|
|
||||||
|
## Deferred / Rejected
|
||||||
|
|
||||||
|
- **Full software control-head replica** — rejected: the detachable head talks a
|
||||||
|
proprietary, undocumented bus to the main unit; CI-V exposes operating functions
|
||||||
|
only (no menu/LCD mirror). Not worth reverse-engineering.
|
||||||
|
- **pyserial** — rejected (see above).
|
||||||
|
- **DV mode set** — deferred: 0x17 unverified on this radio; don't ship set_mode('dv')
|
||||||
|
as trusted until confirmed against the manual's CI-V table or the live rig.
|
||||||
|
- **RS-MS1A data-jack protocol (D-STAR DR/callsign/messaging)** — out of scope for
|
||||||
|
this CI-V layer; separate effort if wanted.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# id5100 — CI-V control for the ICOM ID-5100
|
||||||
|
|
||||||
|
Software control layer for the ID-5100 mobile transceiver over CI-V, via the
|
||||||
|
RT Systems USB-29A cable (FTDI) plugged into the radio's **[SP2]** jack.
|
||||||
|
|
||||||
|
- **Verified link:** `/dev/ttyUSB0`, **9600 baud**, radio CI-V address **0x8C**.
|
||||||
|
- **No dependencies** — pure stdlib (`termios`), no pyserial.
|
||||||
|
|
||||||
|
## Layers
|
||||||
|
|
||||||
|
| File | Role | Tested by |
|
||||||
|
|------|------|-----------|
|
||||||
|
| `civ.py` | Pure CI-V codec: BCD freq/level encode-decode, frame build/parse | `test_civ.py` (11 unit tests) |
|
||||||
|
| `radio.py` | Serial transport + high-level `Radio` class (get/set freq, mode, vol, sql, smeter) | live-radio integration |
|
||||||
|
| `cli.py` | Command-line front end | live-radio integration |
|
||||||
|
| `server.py` | Web control-panel server (stdlib `http.server` + JSON API) | live-radio integration |
|
||||||
|
| `panel.html` | Instrument-panel web UI (amber VFD, S-meter, mode/level controls) | headless screenshot |
|
||||||
|
| `civ_probe.py` | One-shot link probe (read VFO frequency) | — |
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./cli.py status # freq, mode, volume, squelch, s-meter at a glance
|
||||||
|
./cli.py freq # read VFO (MHz)
|
||||||
|
./cli.py freq 146.52 # set VFO to 146.520 MHz
|
||||||
|
./cli.py mode fm # set mode: lsb/usb/am/cw/fm/dv
|
||||||
|
./cli.py vol 30 # set AF volume 0-255 (read with no arg)
|
||||||
|
./cli.py sql 60 # set squelch level 0-255
|
||||||
|
./cli.py smeter # read S-meter 0-255
|
||||||
|
# global: --port /dev/ttyUSB0 --baud 9600
|
||||||
|
```
|
||||||
|
|
||||||
|
## Web panel
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 server.py # http://<host>:8550/ (binds 0.0.0.0 -> phone-reachable)
|
||||||
|
# --port 8550 --serial /dev/ttyUSB0 --baud 9600
|
||||||
|
```
|
||||||
|
|
||||||
|
Holds the serial port for its lifetime, so **don't run `cli.py` while the server
|
||||||
|
is up** (one process owns `/dev/ttyUSB0`). The page polls `/api/status` ~1/s for
|
||||||
|
live frequency, mode, S-meter, squelch, and levels; controls POST to
|
||||||
|
`/api/{freq,mode,vol,sql}`.
|
||||||
|
|
||||||
|
## Library
|
||||||
|
|
||||||
|
```python
|
||||||
|
from radio import Radio
|
||||||
|
with Radio("/dev/ttyUSB0", 9600) as r:
|
||||||
|
r.get_frequency() # -> Hz
|
||||||
|
r.set_frequency(146_520_000)
|
||||||
|
r.get_mode() # -> ("FM", 0x05)
|
||||||
|
r.set_volume(30)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m unittest test_civ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gotchas / verified facts
|
||||||
|
|
||||||
|
- **[SP2] *is* the CI-V port** on the ID-5100 (doubles as 2nd speaker jack).
|
||||||
|
- **Bus echo:** the radio echoes your TX frame before replying; `transact()`
|
||||||
|
filters frames where `frm != RADIO_ADDR`.
|
||||||
|
- **Frequency BCD is little-endian; level/meter BCD is big-endian** (`128 -> 01 28`).
|
||||||
|
- **Mode FM = 0x05** confirmed empirically. `DV = 0x17` is from the generic
|
||||||
|
ICOM table and **not yet verified on this radio** — confirm before relying on it.
|
||||||
|
- Scope is CI-V (operate the radio). D-STAR DR routing / callsign entry live in
|
||||||
|
the separate RS-MS1A data-jack protocol, not here.
|
||||||
|
```
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""
|
||||||
|
Pure CI-V codec for the ICOM ID-5100 — no I/O, no dependencies.
|
||||||
|
|
||||||
|
CI-V frame: FE FE <to> <from> <cmd> [sub] [data...] FD
|
||||||
|
All multi-byte numeric fields are BCD. Frequency is 5-byte little-endian BCD;
|
||||||
|
levels/meters are 2-byte big-endian BCD in the range 0000..0255.
|
||||||
|
"""
|
||||||
|
from collections import namedtuple
|
||||||
|
|
||||||
|
RADIO_ADDR = 0x8C # ID-5100 default CI-V address
|
||||||
|
CTRL_ADDR = 0xE0 # this controller's address
|
||||||
|
PREAMBLE = b"\xFE\xFE"
|
||||||
|
EOM = 0xFD
|
||||||
|
|
||||||
|
# Command bytes (generic ICOM CI-V; verified subset for the ID-5100)
|
||||||
|
CMD_READ_FREQ = 0x03
|
||||||
|
CMD_READ_MODE = 0x04
|
||||||
|
CMD_SET_FREQ = 0x05
|
||||||
|
CMD_SET_MODE = 0x06
|
||||||
|
CMD_LEVEL = 0x14 # sub 0x01 = AF (volume), 0x03 = squelch level
|
||||||
|
CMD_METER = 0x15 # sub 0x01 = squelch status, 0x02 = S-meter
|
||||||
|
SUB_AF = 0x01
|
||||||
|
SUB_SQL_LEVEL = 0x03
|
||||||
|
SUB_SQL_STATUS = 0x01
|
||||||
|
SUB_SMETER = 0x02
|
||||||
|
|
||||||
|
Frame = namedtuple("Frame", "to frm cn data")
|
||||||
|
|
||||||
|
|
||||||
|
def freq_to_bcd(hz):
|
||||||
|
"""Hz -> 5-byte little-endian BCD."""
|
||||||
|
digits = f"{hz:010d}" # 10 decimal digits (up to 9.999 GHz)
|
||||||
|
out = bytearray()
|
||||||
|
# pairs from least significant; byte0 holds the lowest two digits
|
||||||
|
for i in range(0, 10, 2):
|
||||||
|
hi = digits[8 - i]
|
||||||
|
lo = digits[9 - i]
|
||||||
|
out.append((int(hi) << 4) | int(lo))
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def bcd_to_freq(data):
|
||||||
|
"""5-byte little-endian BCD -> Hz."""
|
||||||
|
hz = 0
|
||||||
|
for i, byte in enumerate(data[:5]):
|
||||||
|
ones = byte & 0x0F
|
||||||
|
tens = byte >> 4
|
||||||
|
hz += ones * (10 ** (2 * i)) + tens * (10 ** (2 * i + 1))
|
||||||
|
return hz
|
||||||
|
|
||||||
|
|
||||||
|
def level_to_bcd(value):
|
||||||
|
"""0..255 -> 2-byte BCD (e.g. 128 -> 01 28)."""
|
||||||
|
if not 0 <= value <= 255:
|
||||||
|
raise ValueError(f"level {value} out of range 0..255")
|
||||||
|
digits = f"{value:04d}"
|
||||||
|
return bytes([(int(digits[0]) << 4) | int(digits[1]),
|
||||||
|
(int(digits[2]) << 4) | int(digits[3])])
|
||||||
|
|
||||||
|
|
||||||
|
def bcd_to_level(data):
|
||||||
|
"""2-byte BCD -> int 0..255."""
|
||||||
|
value = 0
|
||||||
|
for byte in data[:2]:
|
||||||
|
value = value * 100 + (byte >> 4) * 10 + (byte & 0x0F)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def build_frame(cmd, data=b"", to=RADIO_ADDR, frm=CTRL_ADDR):
|
||||||
|
"""Assemble FE FE <to> <from> <cmd> [data] FD."""
|
||||||
|
return PREAMBLE + bytes([to, frm, cmd]) + bytes(data) + bytes([EOM])
|
||||||
|
|
||||||
|
|
||||||
|
def parse_frames(buf):
|
||||||
|
"""Extract complete CI-V frames from a byte buffer, skipping noise/echo.
|
||||||
|
|
||||||
|
Returns a list of Frame(to, frm, cn, data) where `data` is everything
|
||||||
|
between the command byte and the EOM (so it includes any subcommand byte).
|
||||||
|
"""
|
||||||
|
frames = []
|
||||||
|
i, n = 0, len(buf)
|
||||||
|
while i < n - 1:
|
||||||
|
if buf[i] == 0xFE and buf[i + 1] == 0xFE:
|
||||||
|
j = i + 2
|
||||||
|
while j < n and buf[j] != EOM:
|
||||||
|
j += 1
|
||||||
|
if j >= n:
|
||||||
|
break # incomplete trailing frame
|
||||||
|
inner = buf[i + 2:j]
|
||||||
|
if len(inner) >= 3:
|
||||||
|
frames.append(Frame(inner[0], inner[1], inner[2], bytes(inner[3:])))
|
||||||
|
i = j + 1
|
||||||
|
continue
|
||||||
|
i += 1
|
||||||
|
return frames
|
||||||
Executable
+135
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
ICOM ID-5100 CI-V link probe (pure stdlib, no pyserial).
|
||||||
|
|
||||||
|
Opens the RT Systems USB-29A cable (FTDI, plugged into the radio's [SP2]/CI-V
|
||||||
|
jack), sends a "read operating frequency" CI-V frame, and decodes the reply.
|
||||||
|
A valid reply proves the PC <-> radio control link end-to-end.
|
||||||
|
|
||||||
|
Defaults: /dev/ttyUSB0, 9600 baud, radio CI-V address 0x8C, controller 0xE0.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 civ_probe.py [PORT] [BAUD]
|
||||||
|
python3 civ_probe.py /dev/ttyUSB0 9600
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import termios
|
||||||
|
|
||||||
|
RADIO_ADDR = 0x8C # ID-5100 default CI-V address
|
||||||
|
CTRL_ADDR = 0xE0 # this controller's address
|
||||||
|
PREAMBLE = b"\xFE\xFE"
|
||||||
|
EOM = 0xFD
|
||||||
|
|
||||||
|
BAUD_CONST = {
|
||||||
|
1200: termios.B1200, 4800: termios.B4800, 9600: termios.B9600,
|
||||||
|
19200: termios.B19200, 38400: termios.B38400, 115200: termios.B115200,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def open_port(port, baud):
|
||||||
|
fd = os.open(port, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
|
||||||
|
speed = BAUD_CONST.get(baud)
|
||||||
|
if speed is None:
|
||||||
|
raise ValueError(f"unsupported baud {baud}; choose one of {sorted(BAUD_CONST)}")
|
||||||
|
iflag, oflag, cflag, lflag, ispeed, ospeed, cc = termios.tcgetattr(fd)
|
||||||
|
# raw 8N1, receiver on, ignore modem control lines
|
||||||
|
iflag = 0
|
||||||
|
oflag = 0
|
||||||
|
lflag = 0
|
||||||
|
cflag = termios.CS8 | termios.CREAD | termios.CLOCAL
|
||||||
|
cc = list(cc)
|
||||||
|
cc[termios.VMIN] = 0
|
||||||
|
cc[termios.VTIME] = 0
|
||||||
|
termios.tcsetattr(fd, termios.TCSANOW, [iflag, oflag, cflag, lflag, speed, speed, cc])
|
||||||
|
termios.tcflush(fd, termios.TCIOFLUSH)
|
||||||
|
return fd
|
||||||
|
|
||||||
|
|
||||||
|
def read_for(fd, seconds):
|
||||||
|
"""Collect raw bytes for `seconds`, return bytearray."""
|
||||||
|
buf = bytearray()
|
||||||
|
deadline = time.monotonic() + seconds
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
chunk = os.read(fd, 256)
|
||||||
|
except BlockingIOError:
|
||||||
|
chunk = b""
|
||||||
|
if chunk:
|
||||||
|
buf.extend(chunk)
|
||||||
|
else:
|
||||||
|
time.sleep(0.01)
|
||||||
|
return buf
|
||||||
|
|
||||||
|
|
||||||
|
def split_frames(buf):
|
||||||
|
"""Yield CI-V frames (FE FE ... FD), skipping leading echo/noise."""
|
||||||
|
i = 0
|
||||||
|
n = len(buf)
|
||||||
|
while i < n - 1:
|
||||||
|
if buf[i] == 0xFE and buf[i + 1] == 0xFE:
|
||||||
|
j = i + 2
|
||||||
|
while j < n and buf[j] != EOM:
|
||||||
|
j += 1
|
||||||
|
if j < n:
|
||||||
|
yield bytes(buf[i:j + 1])
|
||||||
|
i = j + 1
|
||||||
|
continue
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
|
||||||
|
def decode_freq(data):
|
||||||
|
"""5-byte little-endian BCD -> Hz."""
|
||||||
|
hz = 0
|
||||||
|
for i, byte in enumerate(data[:5]):
|
||||||
|
ones = byte & 0x0F
|
||||||
|
tens = byte >> 4
|
||||||
|
hz += ones * (10 ** (2 * i)) + tens * (10 ** (2 * i + 1))
|
||||||
|
return hz
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
port = sys.argv[1] if len(sys.argv) > 1 else "/dev/ttyUSB0"
|
||||||
|
baud = int(sys.argv[2]) if len(sys.argv) > 2 else 9600
|
||||||
|
|
||||||
|
print(f"Port {port} @ {baud} baud | radio 0x{RADIO_ADDR:02X} ctrl 0x{CTRL_ADDR:02X}")
|
||||||
|
fd = open_port(port, baud)
|
||||||
|
try:
|
||||||
|
# read operating frequency: FE FE <radio> <ctrl> 03 FD
|
||||||
|
cmd = PREAMBLE + bytes([RADIO_ADDR, CTRL_ADDR, 0x03, EOM])
|
||||||
|
print("TX:", cmd.hex(" "))
|
||||||
|
os.write(fd, cmd)
|
||||||
|
buf = read_for(fd, 1.0)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
if not buf:
|
||||||
|
print("RX: (nothing)")
|
||||||
|
print("\nNo bytes back. Check: SP2 jack seated, radio CI-V baud = "
|
||||||
|
f"{baud}, radio powered on. Single-device port was confirmed as this one.")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
print("RX raw:", buf.hex(" "))
|
||||||
|
got_freq = False
|
||||||
|
for frame in split_frames(buf):
|
||||||
|
# frame: FE FE <to> <from> <cn> [data...] FD
|
||||||
|
if len(frame) < 6:
|
||||||
|
continue
|
||||||
|
to, frm, cn = frame[2], frame[3], frame[4]
|
||||||
|
body = frame[5:-1]
|
||||||
|
tag = "echo" if (to == RADIO_ADDR and frm == CTRL_ADDR) else "radio->us"
|
||||||
|
print(f" frame [{tag}] to=0x{to:02X} from=0x{frm:02X} cn=0x{cn:02X} body={body.hex(' ')}")
|
||||||
|
if frm == RADIO_ADDR and cn == 0x03 and len(body) >= 5:
|
||||||
|
hz = decode_freq(body)
|
||||||
|
print(f"\n*** LINK OK -- VFO frequency: {hz/1e6:.6f} MHz ***")
|
||||||
|
got_freq = True
|
||||||
|
|
||||||
|
if not got_freq:
|
||||||
|
print("\nGot bytes but no frequency frame. Link is alive but reply was "
|
||||||
|
"unexpected -- check baud match and CI-V address.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
id5100 — command-line control for the ICOM ID-5100 over CI-V.
|
||||||
|
|
||||||
|
./cli.py status show everything at a glance
|
||||||
|
./cli.py freq read VFO frequency
|
||||||
|
./cli.py freq 146.52 set VFO to 146.520 MHz
|
||||||
|
./cli.py mode read mode
|
||||||
|
./cli.py mode fm set mode (lsb/usb/am/cw/fm/dv)
|
||||||
|
./cli.py vol [0-255] get/set AF (volume)
|
||||||
|
./cli.py sql [0-255] get/set squelch level
|
||||||
|
./cli.py smeter read S-meter (0-255)
|
||||||
|
|
||||||
|
Global: --port (default /dev/ttyUSB0) --baud (default 9600)
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from radio import Radio, CIVError
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_status(r, _):
|
||||||
|
freq = r.get_frequency()
|
||||||
|
mode, _code = r.get_mode()
|
||||||
|
print(f"freq : {freq/1e6:.5f} MHz")
|
||||||
|
print(f"mode : {mode}")
|
||||||
|
print(f"volume : {r.get_volume()}/255")
|
||||||
|
print(f"squelch: level {r.get_squelch_level()}/255, "
|
||||||
|
f"{'OPEN' if r.squelch_open() else 'closed'}")
|
||||||
|
print(f"s-meter: {r.get_smeter()}/255")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_freq(r, a):
|
||||||
|
if a.value is None:
|
||||||
|
print(f"{r.get_frequency()/1e6:.5f} MHz")
|
||||||
|
else:
|
||||||
|
hz = int(round(float(a.value) * 1e6)) # CLI takes MHz
|
||||||
|
r.set_frequency(hz)
|
||||||
|
print(f"set -> {r.get_frequency()/1e6:.5f} MHz")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_mode(r, a):
|
||||||
|
if a.value is None:
|
||||||
|
print(r.get_mode()[0])
|
||||||
|
else:
|
||||||
|
r.set_mode(a.value)
|
||||||
|
print(f"set -> {r.get_mode()[0]}")
|
||||||
|
|
||||||
|
|
||||||
|
def _level_cmd(getter, setter):
|
||||||
|
def run(r, a):
|
||||||
|
if a.value is None:
|
||||||
|
print(f"{getter(r)}/255")
|
||||||
|
else:
|
||||||
|
setter(r, int(a.value))
|
||||||
|
print(f"set -> {getter(r)}/255")
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_smeter(r, _):
|
||||||
|
print(f"{r.get_smeter()}/255")
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser():
|
||||||
|
p = argparse.ArgumentParser(prog="id5100", description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
p.add_argument("--port", default="/dev/ttyUSB0")
|
||||||
|
p.add_argument("--baud", type=int, default=9600)
|
||||||
|
sub = p.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
sub.add_parser("status").set_defaults(func=cmd_status)
|
||||||
|
sub.add_parser("smeter").set_defaults(func=cmd_smeter)
|
||||||
|
for name, func in (("freq", cmd_freq), ("mode", cmd_mode),
|
||||||
|
("vol", _level_cmd(Radio.get_volume, Radio.set_volume)),
|
||||||
|
("sql", _level_cmd(Radio.get_squelch_level, Radio.set_squelch_level))):
|
||||||
|
sp = sub.add_parser(name)
|
||||||
|
sp.add_argument("value", nargs="?", default=None)
|
||||||
|
sp.set_defaults(func=func)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = build_parser().parse_args()
|
||||||
|
try:
|
||||||
|
with Radio(args.port, args.baud) as r:
|
||||||
|
args.func(r, args)
|
||||||
|
except (CIVError, ValueError, OSError) as e:
|
||||||
|
print(f"error: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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.
|
||||||
|
```
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# RepeaterBook integration for the ID-5100 web panel — design
|
||||||
|
|
||||||
|
- **Date:** 2026-06-14
|
||||||
|
- **Project:** `/home/claude/bin/id5100`
|
||||||
|
- **Status:** Approved (design); ready for implementation plan
|
||||||
|
- **Builds on:** existing `civ → radio → {cli, server+panel}` layering (see `README.md`, `DECISIONS.md`)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let the web panel browse nearby amateur repeaters from RepeaterBook and, on a single
|
||||||
|
tap, configure the radio to operate through one: set **output frequency + duplex
|
||||||
|
offset + CTCSS tone**. Repeater data is sourced from a locally cached, normalized
|
||||||
|
`repeaters.json` so the panel never calls RepeaterBook directly.
|
||||||
|
|
||||||
|
## Decisions that shaped this design
|
||||||
|
|
||||||
|
1. **Full configuration, not freq-only.** Tapping a repeater sets freq + offset + tone
|
||||||
|
(option 2 from brainstorming). This requires new CI-V commands not yet in the codebase.
|
||||||
|
2. **Cached data, not live per-request.** RepeaterBook's terms ask for infrequent bulk
|
||||||
|
pulls. A scheduled fetch writes `repeaters.json`; the server reads the cache.
|
||||||
|
3. **Build radio-side + UI now; wire the live data source in parallel.** The RepeaterBook
|
||||||
|
API is now gated behind an approval-required `X-RB-App-Token` (bare request → `401
|
||||||
|
auth_missing`). We build against a hand-seeded `repeaters.json` immediately and request
|
||||||
|
the token concurrently. (Decision 3+1 from brainstorming.)
|
||||||
|
4. **QTH:** 38.8423 N, 77.2684 W (Annandale/Fairfax, VA). Scope = Virginia (FIPS 51) +
|
||||||
|
DC (11) + Maryland (24), analog FM + D-STAR, sorted nearest-first by haversine distance.
|
||||||
|
5. **Module split (option B):** focused new modules alongside the existing layers, not a
|
||||||
|
monolithic `server.py` and not a separate microservice.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
repeaters.json ──read──▶ repeaters.py ──▶ server.py /api/repeaters ──▶ panel.html card
|
||||||
|
▲ (load/normalize/ │ tap row
|
||||||
|
│ written by distance/search) ▼
|
||||||
|
rb_fetch.py (API client, server.py /api/tune-repeater ─▶ radio.py
|
||||||
|
reads token from .env) (freq→offset→tone, per-step) (CI-V)
|
||||||
|
│
|
||||||
|
civ.py codec
|
||||||
|
```
|
||||||
|
|
||||||
|
### Module layout
|
||||||
|
|
||||||
|
| File | Role | Pure? / Tested by |
|
||||||
|
|------|------|-------------------|
|
||||||
|
| `civ.py` *(extend)* | Command constants + BCD helpers for offset freq and CTCSS tone | pure → `test_civ.py` |
|
||||||
|
| `radio.py` *(extend)* | `set_duplex()`, `set_offset()`, `set_tone()`, `set_tone_on()` + getters | live-radio verified |
|
||||||
|
| `repeaters.py` *(new)* | Load/normalize `repeaters.json`, haversine distance from QTH, search + nearest-first sort | pure → `test_repeaters.py` |
|
||||||
|
| `rb_fetch.py` *(new)* | RepeaterBook API client; reads token, pulls VA+DC+MD, normalizes → `repeaters.json` | network-isolated; inert without token |
|
||||||
|
| `server.py` *(extend)* | `GET /api/repeaters`, `POST /api/tune-repeater` | live |
|
||||||
|
| `panel.html` *(extend)* | "Repeaters" card (search + nearest-first list + per-step tune status) | headless screenshot |
|
||||||
|
| `repeaters.json` | Cached, normalized data (hand-seeded now, API-filled later) | — |
|
||||||
|
| `rb-fetch.service` / `rb-fetch.timer` *(new, inert)* | systemd user units to refresh cache on a cadence once the token exists | — |
|
||||||
|
|
||||||
|
## Data: normalized record schema
|
||||||
|
|
||||||
|
`rb_fetch.py`, any future CSV ingest, and the hand-seed all emit the **same** internal
|
||||||
|
record, so nothing downstream depends on RepeaterBook's field names:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"callsign": "W4ABC",
|
||||||
|
"location": "Fairfax, VA",
|
||||||
|
"output_mhz": 147.300,
|
||||||
|
"offset_mhz": 0.600,
|
||||||
|
"duplex": "+", // "+", "-", or "simplex"
|
||||||
|
"tone_hz": 131.8, // null if none
|
||||||
|
"tone_mode": "ctcss", // "ctcss" | "dcs" | "none"
|
||||||
|
"mode": "FM", // "FM" | "DV"
|
||||||
|
"lat": 38.85,
|
||||||
|
"long": -77.30,
|
||||||
|
"notes": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`distance_mi` is **computed at serve time** in `repeaters.py` from the QTH constant
|
||||||
|
(not stored). QTH lives as a module constant `QTH = (38.8423, -77.2684)`.
|
||||||
|
|
||||||
|
### Normalizer (RepeaterBook → internal)
|
||||||
|
|
||||||
|
`rb_fetch.py` maps RepeaterBook fields (`Frequency`, `Input Freq`, `PL`/`CTCSS Uplink`,
|
||||||
|
`Callsign`, `Lat`/`Long`, `D-Star`, …) to the schema above. Duplex direction is derived
|
||||||
|
from `sign(input - output)`; `offset_mhz = abs(input - output)`. If lat/long are absent
|
||||||
|
in the live API response, records still load but sort by county/city grouping instead of
|
||||||
|
distance (graceful fallback — confirm field presence once the token lands).
|
||||||
|
|
||||||
|
## New CI-V capability (radio.py + civ.py)
|
||||||
|
|
||||||
|
**All command numbers below are candidate values from the generic ICOM CI-V table and are
|
||||||
|
UNTRUSTED until they ACK on the live ID-5100** — same discipline as the existing
|
||||||
|
`DV=0x17` / `FM=0x05` verification.
|
||||||
|
|
||||||
|
| Capability | Candidate command | Encoding |
|
||||||
|
|-----------|-------------------|----------|
|
||||||
|
| Duplex direction | `0x0F` | `0x10` simplex / `0x11` DUP− / `0x12` DUP+ |
|
||||||
|
| Offset amount | `0x0D` | BCD offset frequency (e.g. 0.600 MHz, 5.000 MHz) |
|
||||||
|
| Repeater TX tone freq | `0x1B 0x00` | BCD tone (1318 → 131.8 Hz) |
|
||||||
|
| Tone encode on/off | `0x16 0x42` | `0x00` off / `0x01` on |
|
||||||
|
|
||||||
|
**Verification method (during implementation, live radio):** read the current value,
|
||||||
|
write it back unchanged (no-op), confirm ACK (`0xFB`). Only after a command ACKs is it
|
||||||
|
marked trusted; until then `tune-repeater` reports that step as failed rather than
|
||||||
|
pretending success. The offset-freq BCD order (LE vs BE) and tone BCD order are pinned by
|
||||||
|
unit tests once observed, mirroring the existing freq(LE)/level(BE) split.
|
||||||
|
|
||||||
|
## API endpoints (server.py)
|
||||||
|
|
||||||
|
- `GET /api/repeaters?q=<text>&limit=25`
|
||||||
|
Reads `repeaters.json` via `repeaters.py`, computes distance from QTH, filters by
|
||||||
|
case-insensitive substring match on callsign/location, returns nearest-first.
|
||||||
|
Response: `{"ok": true, "repeaters": [ {…record…, "distance_mi": 4.2}, … ]}`.
|
||||||
|
|
||||||
|
- `POST /api/tune-repeater` (body = one normalized record)
|
||||||
|
Applies steps **in fixed order: freq → offset/duplex → tone**. Returns per-step results
|
||||||
|
so a half-configured radio is visible, not hidden:
|
||||||
|
```json
|
||||||
|
{"ok": true, "steps": {"freq": true, "duplex": true, "offset": true,
|
||||||
|
"tone_freq": false, "tone_on": false},
|
||||||
|
"errors": {"tone_freq": "radio rejected cmd 0x1B"}}
|
||||||
|
```
|
||||||
|
`ok` is true if at least `freq` succeeded; individual failures surface in `steps`.
|
||||||
|
|
||||||
|
Existing endpoints (`/api/status`, `/api/freq`, `/api/mode`, `/api/vol`, `/api/sql`)
|
||||||
|
are unchanged. The serial `lock` in `server.py` continues to serialize all radio access.
|
||||||
|
|
||||||
|
## UI: "Repeaters" card in panel.html
|
||||||
|
|
||||||
|
A new `.card` matching the existing amber/VFD aesthetic, placed after the Levels card:
|
||||||
|
|
||||||
|
- **Search input** (debounced) → `GET /api/repeaters?q=`.
|
||||||
|
- **Nearest-first list**, each row: `W4ABC · Fairfax VA · 147.300 − · 131.8 · 4.2 mi`.
|
||||||
|
DV rows carry a small `DV` tag so the call-routing limitation isn't surprising.
|
||||||
|
- **Tap a row** → `POST /api/tune-repeater` with that record.
|
||||||
|
- **Per-step status** rendered on the row after tuning: `freq ✓ offset ✓ tone ✗`.
|
||||||
|
No single green/red — the operator sees exactly what landed.
|
||||||
|
|
||||||
|
No new fonts or third-party JS; reuses existing styles and the `post()`/`fetch` helpers.
|
||||||
|
|
||||||
|
## Data refresh (path 1, runs in parallel, inert until token)
|
||||||
|
|
||||||
|
- `rb_fetch.py` reads `RB_APP_TOKEN` from `id5100/.env` (gitignored, per homelab
|
||||||
|
convention), sends the required descriptive `User-Agent`
|
||||||
|
(`id5100-panel/1.0 (homelab; seth@sethfreiberg.com)`), pulls VA+DC+MD, writes
|
||||||
|
`repeaters.json`. Honors `429` with backoff.
|
||||||
|
- `rb-fetch.timer` (systemd **user** unit) runs it on a cadence (default: daily). Both
|
||||||
|
units ship now but stay disabled/inert until the token exists.
|
||||||
|
|
||||||
|
## Limitations (on the record)
|
||||||
|
|
||||||
|
1. **Tune is not atomic.** If offset succeeds but tone NAKs, the radio is half-set.
|
||||||
|
Freq-first ordering guarantees the most important step always lands; per-step UI makes
|
||||||
|
the state visible. Acceptable for a manual operating aid.
|
||||||
|
2. **DV repeaters: freq/offset only.** D-STAR call routing (URCALL/RPT1/RPT2) is the
|
||||||
|
RS-MS1A data-jack protocol, explicitly out of scope (see `DECISIONS.md`). DV rows are
|
||||||
|
labeled; tapping parks the radio on the right freq/offset, routing is set on the head.
|
||||||
|
3. **Proximity depends on lat/long surviving in the API response** (docs dropped them
|
||||||
|
from the documented field list). Hand-seed includes them; live-API absence falls back
|
||||||
|
to county/city grouping. Confirmable when the token lands.
|
||||||
|
4. **New CI-V commands unverified** until they ACK on the live radio (see above).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `test_civ.py` *(extend)*: BCD encode/decode for offset freq and tone; round-trips.
|
||||||
|
- `test_repeaters.py` *(new)*: haversine distance correctness, nearest-first sort,
|
||||||
|
substring search, normalizer mapping (RepeaterBook fields → schema), duplex-sign and
|
||||||
|
offset-magnitude derivation, lat/long-absent fallback. Pure, no network, no radio.
|
||||||
|
- Live-radio integration (manual, during implementation): verify each new CI-V command
|
||||||
|
ACKs via read-then-write-back; end-to-end `tune-repeater` against a known local machine.
|
||||||
|
|
||||||
|
## Revision 2026-06-14 (post-CSV pivot)
|
||||||
|
|
||||||
|
Seth supplied a RepeaterBook **CSV proximity export** (`repeaterbook/RB_2606141409.csv`,
|
||||||
|
1258 rows, DC-metro radius). This supersedes the API/token data path:
|
||||||
|
|
||||||
|
- **Decision 3 changed → CSV-only.** The RepeaterBook API client (`rb_fetch.py`) and the
|
||||||
|
systemd refresh timer are **dropped** (YAGNI). Refresh = re-drop a new CSV and re-run a
|
||||||
|
one-line ingest. The token path can be revived later if hands-off refresh is ever wanted.
|
||||||
|
- **No lat/long in the CSV.** Distance/haversine and the `QTH` constant are **dropped**.
|
||||||
|
The export is RepeaterBook's own proximity sort (row 2 = QTH, distance fans outward), so
|
||||||
|
ingest preserves row order as a `rank` integer and `search()` ranks by `rank`. The panel
|
||||||
|
shows location, not a distance figure.
|
||||||
|
- **CSV columns differ from the API** → a CSV normalizer (`normalize_csv`) replaces the
|
||||||
|
API-JSON `normalize_rb`. Columns: `Output Freq, Input Freq, Offset, Uplink Tone,
|
||||||
|
Downlink Tone, Call, Location, County, State, Modes, Digital Access`. Offset magnitude =
|
||||||
|
`abs(Input−Output)`; the `Offset` column gives only the +/− sign. **Uplink Tone** = TX
|
||||||
|
tone we set.
|
||||||
|
- **Mode/band filter:** keep rows whose `Modes` contains `FM` or `DSTAR`, within 2m
|
||||||
|
(144–148) or 70cm (420–450). ~1072 of 1258 rows survive. Mode label: `FM` if `FM`
|
||||||
|
present (operable as analog immediately), else `DV` for pure `DSTAR` (freq/offset only;
|
||||||
|
call routing on the head).
|
||||||
|
- Schema keeps `lat`/`long` as nullable (future-proof, unused now) and adds `rank`.
|
||||||
|
|
||||||
|
The actionable plan (`docs/superpowers/plans/2026-06-14-repeaterbook-integration.md`) was
|
||||||
|
rewritten to match this revision.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- D-STAR DR routing / callsign / messaging (RS-MS1A) — separate effort.
|
||||||
|
- DMR/NXDN/P25/YSF repeater configuration — the ID-5100 is FM + D-STAR only.
|
||||||
|
- Automated authenticated CSV scraping of the RepeaterBook website.
|
||||||
|
- systemd unit for `server.py` itself (tracked separately in the handoff's next-steps).
|
||||||
+279
@@ -0,0 +1,279 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||||
|
<title>ID-5100 · CI-V Remote</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Saira+Condensed:wght@400;600;800&family=Chivo+Mono:wght@400;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
@font-face{
|
||||||
|
font-family:'DSEG7';
|
||||||
|
src:url('https://cdn.jsdelivr.net/npm/dseg@0.46.0/fonts/DSEG7-Classic/DSEG7Classic-Bold.woff2') format('woff2');
|
||||||
|
font-weight:700;font-display:swap;
|
||||||
|
}
|
||||||
|
:root{
|
||||||
|
--bg:#0b0a08; --metal-hi:#34302a; --metal-lo:#17150f; --groove:#0c0b08;
|
||||||
|
--amber:#ffb22e; --amber-soft:#c98a2a; --amber-glow:rgba(255,178,46,.55);
|
||||||
|
--accent:#d35400; --accent-hi:#ff7a1a;
|
||||||
|
--led-on:#54d171; --led-off:#243126; --red:#ff5436;
|
||||||
|
--ink:#cdbfa8; --ink-dim:#7c715c; --glass:#0a0d0a;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
html,body{height:100%}
|
||||||
|
body{
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 90% at 50% -10%, #211d16 0%, var(--bg) 55%, #050403 100%);
|
||||||
|
font-family:'Chivo Mono',monospace; color:var(--ink);
|
||||||
|
min-height:100%; display:grid; place-items:center; padding:26px 14px;
|
||||||
|
-webkit-tap-highlight-color:transparent;
|
||||||
|
}
|
||||||
|
body::after{ /* grain */
|
||||||
|
content:""; position:fixed; inset:0; pointer-events:none; opacity:.05; mix-blend-mode:overlay;
|
||||||
|
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit{
|
||||||
|
width:min(680px,100%);
|
||||||
|
background:linear-gradient(168deg,var(--metal-hi),var(--metal-lo) 60%,#100e09);
|
||||||
|
border-radius:16px; padding:22px;
|
||||||
|
border:1px solid #3c372e;
|
||||||
|
box-shadow:
|
||||||
|
0 1px 0 rgba(255,225,170,.10) inset,
|
||||||
|
0 -22px 50px rgba(0,0,0,.55) inset,
|
||||||
|
0 30px 70px rgba(0,0,0,.65),
|
||||||
|
0 2px 0 #000;
|
||||||
|
position:relative;
|
||||||
|
animation:power 1s cubic-bezier(.2,.8,.2,1) both;
|
||||||
|
}
|
||||||
|
@keyframes power{from{opacity:0;transform:translateY(14px) scale(.985);filter:brightness(.4)}to{opacity:1;transform:none;filter:none}}
|
||||||
|
.unit::before,.unit::after{ /* corner screws */
|
||||||
|
content:""; position:absolute; width:11px; height:11px; border-radius:50%;
|
||||||
|
background:radial-gradient(circle at 35% 30%,#5b5347,#181610 70%);
|
||||||
|
box-shadow:0 1px 1px rgba(0,0,0,.8), 0 0 0 1px #0c0b08;
|
||||||
|
}
|
||||||
|
.unit::before{top:12px;left:12px} .unit::after{top:12px;right:12px}
|
||||||
|
|
||||||
|
.topbar{display:flex;align-items:center;justify-content:center;gap:14px;
|
||||||
|
position:relative; padding:2px 26px 14px; }
|
||||||
|
.brand{font-family:'Saira Condensed',sans-serif;font-weight:800;
|
||||||
|
letter-spacing:.26em; font-size:15px; color:#e8dcc4; text-transform:uppercase}
|
||||||
|
.brand b{color:var(--accent-hi)}
|
||||||
|
.link{position:absolute;right:26px;top:0;display:flex;align-items:center;gap:7px;
|
||||||
|
font-size:10px;letter-spacing:.18em;color:var(--ink-dim);text-transform:uppercase}
|
||||||
|
.dot{width:9px;height:9px;border-radius:50%;background:var(--led-off);
|
||||||
|
box-shadow:0 0 0 1px #000 inset; transition:.3s}
|
||||||
|
.dot.on{background:var(--led-on);box-shadow:0 0 9px var(--led-on),0 0 0 1px #0a3 inset}
|
||||||
|
.dot.off{background:var(--red);box-shadow:0 0 9px var(--red)}
|
||||||
|
|
||||||
|
/* ---- VFD display ---- */
|
||||||
|
.display{
|
||||||
|
margin:6px 4px 16px; padding:18px 22px; border-radius:10px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg,#060806,#0c100c) padding-box,
|
||||||
|
var(--glass);
|
||||||
|
border:1px solid #000;
|
||||||
|
box-shadow:0 0 0 1px #2c2820, 0 14px 26px rgba(0,0,0,.6) inset, 0 2px 0 #2a261e;
|
||||||
|
position:relative; overflow:hidden;
|
||||||
|
}
|
||||||
|
.display::after{ /* scanlines */
|
||||||
|
content:"";position:absolute;inset:0;pointer-events:none;opacity:.35;
|
||||||
|
background:repeating-linear-gradient(180deg,transparent 0 2px,rgba(0,0,0,.5) 2px 3px);
|
||||||
|
}
|
||||||
|
.row1{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
|
||||||
|
.freq{
|
||||||
|
font-family:'DSEG7','Chivo Mono',monospace; font-weight:700;
|
||||||
|
font-size:clamp(38px,11vw,68px); line-height:1; color:var(--amber);
|
||||||
|
text-shadow:0 0 6px var(--amber-glow),0 0 22px rgba(255,150,20,.35);
|
||||||
|
letter-spacing:2px; animation:flick 6s infinite steps(1);
|
||||||
|
}
|
||||||
|
@keyframes flick{0%,97%,100%{opacity:1}98%{opacity:.86}}
|
||||||
|
.unitlbl{font-family:'Saira Condensed',sans-serif;font-weight:600;letter-spacing:.2em;
|
||||||
|
color:var(--amber-soft);font-size:15px;text-shadow:0 0 8px var(--amber-glow)}
|
||||||
|
.row2{display:flex;align-items:center;justify-content:space-between;margin-top:12px}
|
||||||
|
.modebadge{font-family:'Saira Condensed',sans-serif;font-weight:800;letter-spacing:.18em;
|
||||||
|
font-size:20px;color:var(--accent-hi);text-shadow:0 0 10px rgba(255,122,26,.5);min-width:54px}
|
||||||
|
.busy{display:flex;align-items:center;gap:8px;font-size:11px;letter-spacing:.18em;
|
||||||
|
color:var(--ink-dim);text-transform:uppercase}
|
||||||
|
.busy .dot.on{background:var(--accent-hi);box-shadow:0 0 10px var(--accent-hi)}
|
||||||
|
|
||||||
|
/* ---- S meter ---- */
|
||||||
|
.meter{margin:4px 4px 18px;padding:0 2px}
|
||||||
|
.bars{display:flex;gap:3px;height:26px;align-items:stretch}
|
||||||
|
.seg{flex:1;border-radius:2px;background:var(--led-off);
|
||||||
|
box-shadow:0 0 0 1px #000 inset; transition:background .12s, box-shadow .12s}
|
||||||
|
.seg.on{background:var(--amber);box-shadow:0 0 7px var(--amber-glow),0 0 0 1px #000 inset}
|
||||||
|
.seg.on.hot{background:var(--red);box-shadow:0 0 8px var(--red),0 0 0 1px #000 inset}
|
||||||
|
.scale{display:flex;justify-content:space-between;margin-top:6px;
|
||||||
|
font-size:9.5px;letter-spacing:.08em;color:var(--ink-dim)}
|
||||||
|
|
||||||
|
/* ---- controls ---- */
|
||||||
|
.panel{display:grid;gap:16px}
|
||||||
|
.card{background:linear-gradient(180deg,#221f19,#16140f);border:1px solid #332e26;
|
||||||
|
border-radius:10px;padding:14px 14px 16px;
|
||||||
|
box-shadow:0 1px 0 rgba(255,220,160,.06) inset,0 6px 16px rgba(0,0,0,.4)}
|
||||||
|
.label{font-family:'Saira Condensed',sans-serif;font-weight:600;letter-spacing:.22em;
|
||||||
|
text-transform:uppercase;font-size:11px;color:var(--ink-dim);margin-bottom:11px}
|
||||||
|
.freqset{display:flex;gap:8px;flex-wrap:wrap}
|
||||||
|
input[type=number]{flex:1;min-width:120px;background:#0a0907;border:1px solid #3a352b;
|
||||||
|
color:var(--amber);font-family:'Chivo Mono';font-size:18px;padding:9px 12px;border-radius:7px;
|
||||||
|
outline:none}
|
||||||
|
input[type=number]:focus{border-color:var(--accent);box-shadow:0 0 0 2px rgba(211,84,0,.25)}
|
||||||
|
button{font-family:'Saira Condensed',sans-serif;font-weight:800;letter-spacing:.1em;
|
||||||
|
text-transform:uppercase;cursor:pointer;border:none;color:#1a160f;
|
||||||
|
background:linear-gradient(180deg,var(--accent-hi),var(--accent));border-radius:7px;
|
||||||
|
padding:9px 16px;font-size:14px;box-shadow:0 2px 0 #5a2400,0 6px 12px rgba(0,0,0,.4);
|
||||||
|
transition:transform .06s, box-shadow .06s}
|
||||||
|
button:active{transform:translateY(2px);box-shadow:0 0 0 #5a2400}
|
||||||
|
.chips{display:flex;gap:7px;flex-wrap:wrap;margin-top:10px}
|
||||||
|
.chip{background:#0c0b08;border:1px solid #3a352b;color:var(--ink);
|
||||||
|
padding:7px 11px;border-radius:20px;font-size:12px;box-shadow:none}
|
||||||
|
.chip:active{transform:translateY(1px)}
|
||||||
|
.modes{display:grid;grid-template-columns:repeat(3,1fr);gap:9px}
|
||||||
|
.mode{padding:13px 0;border-radius:8px;background:#0d0c09;border:1px solid #34302a;
|
||||||
|
color:var(--ink-dim);font-size:16px;letter-spacing:.16em;box-shadow:0 2px 0 #000 inset}
|
||||||
|
.mode.active{color:#1a160f;background:linear-gradient(180deg,var(--amber),var(--amber-soft));
|
||||||
|
border-color:#000;box-shadow:0 0 16px var(--amber-glow),0 0 0 1px #000}
|
||||||
|
.fader{display:flex;align-items:center;gap:13px}
|
||||||
|
.fader .val{font-family:'DSEG7','Chivo Mono';color:var(--amber);font-size:20px;min-width:54px;
|
||||||
|
text-align:right;text-shadow:0 0 7px var(--amber-glow)}
|
||||||
|
input[type=range]{-webkit-appearance:none;appearance:none;flex:1;height:8px;border-radius:6px;
|
||||||
|
background:linear-gradient(90deg,var(--accent) 0%,var(--accent) var(--p,0%),#0c0b08 var(--p,0%),#0c0b08 100%);
|
||||||
|
border:1px solid #000;outline:none}
|
||||||
|
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:22px;height:26px;border-radius:5px;
|
||||||
|
background:linear-gradient(180deg,#5b5346,#211d16);border:1px solid #000;cursor:grab;
|
||||||
|
box-shadow:0 2px 5px rgba(0,0,0,.6),0 1px 0 rgba(255,230,180,.25) inset}
|
||||||
|
input[type=range]::-moz-range-thumb{width:22px;height:26px;border-radius:5px;
|
||||||
|
background:linear-gradient(180deg,#5b5346,#211d16);border:1px solid #000;cursor:grab}
|
||||||
|
.stack{display:grid;gap:13px}
|
||||||
|
.err{color:var(--red);font-size:11px;letter-spacing:.05em;min-height:14px;text-align:center;
|
||||||
|
text-transform:uppercase}
|
||||||
|
.reveal{animation:rise .7s both}
|
||||||
|
@keyframes rise{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="unit">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="brand">ICOM <b>ID-5100</b></div>
|
||||||
|
<div class="link"><span id="led" class="dot off"></span><span id="linktxt">link…</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="display reveal" style="animation-delay:.05s">
|
||||||
|
<div class="row1">
|
||||||
|
<div id="freq" class="freq">---.-----</div>
|
||||||
|
<div class="unitlbl">MHz</div>
|
||||||
|
</div>
|
||||||
|
<div class="row2">
|
||||||
|
<div id="mode" class="modebadge">--</div>
|
||||||
|
<div class="busy"><span id="busyled" class="dot"></span><span id="busytxt">SQ CLOSED</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="meter reveal" style="animation-delay:.12s">
|
||||||
|
<div id="bars" class="bars"></div>
|
||||||
|
<div class="scale"><span>S1</span><span>3</span><span>5</span><span>7</span><span>9</span><span>+20</span><span>+40</span><span>+60</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="card reveal" style="animation-delay:.19s">
|
||||||
|
<div class="label">Frequency · MHz</div>
|
||||||
|
<div class="freqset">
|
||||||
|
<input id="finp" type="number" step="0.005" inputmode="decimal" placeholder="146.520">
|
||||||
|
<button id="fset">Set</button>
|
||||||
|
</div>
|
||||||
|
<div class="chips" id="chips"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card reveal" style="animation-delay:.26s">
|
||||||
|
<div class="label">Mode</div>
|
||||||
|
<div class="modes" id="modes"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card reveal" style="animation-delay:.33s">
|
||||||
|
<div class="label">Levels</div>
|
||||||
|
<div class="stack">
|
||||||
|
<div class="fader"><input id="vol" type="range" min="0" max="255"><span id="volv" class="val">--</span></div>
|
||||||
|
<div class="fader"><input id="sql" type="range" min="0" max="255"><span id="sqlv" class="val">--</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="err" class="err"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $=s=>document.querySelector(s);
|
||||||
|
const SEGS=32, MODES=['FM','AM','DV'];
|
||||||
|
const CHIPS=[['2m call','146.520'],['70cm call','446.000'],['Nat. simplex','147.300'],['70cm','446.500']];
|
||||||
|
|
||||||
|
// build segments
|
||||||
|
const bars=$('#bars');
|
||||||
|
for(let i=0;i<SEGS;i++){const d=document.createElement('div');d.className='seg';bars.appendChild(d);}
|
||||||
|
// build mode buttons
|
||||||
|
const modesEl=$('#modes');
|
||||||
|
MODES.forEach(m=>{const b=document.createElement('div');b.className='mode';b.textContent=m;
|
||||||
|
b.onclick=()=>post('/api/mode',{mode:m});modesEl.appendChild(b);});
|
||||||
|
// build chips
|
||||||
|
const chipsEl=$('#chips');
|
||||||
|
CHIPS.forEach(([lbl,mhz])=>{const c=document.createElement('button');c.className='chip';
|
||||||
|
c.textContent=lbl+' · '+mhz;c.onclick=()=>post('/api/freq',{mhz:+mhz});chipsEl.appendChild(c);});
|
||||||
|
|
||||||
|
let dragging=null;
|
||||||
|
function err(m){$('#err').textContent=m||'';}
|
||||||
|
|
||||||
|
async function post(path,body){
|
||||||
|
try{const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||||
|
const j=await r.json(); if(!j.ok){err(j.error||'rejected');} else {err('');} refresh();
|
||||||
|
}catch(e){err('send failed');}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtFreq(mhz){return Number(mhz).toFixed(5);}
|
||||||
|
|
||||||
|
function render(s){
|
||||||
|
$('#led').className='dot on'; $('#linktxt').textContent='online';
|
||||||
|
$('#freq').textContent=fmtFreq(s.freq_mhz);
|
||||||
|
$('#mode').textContent=s.mode;
|
||||||
|
// s-meter
|
||||||
|
const lit=Math.round(s.smeter/255*SEGS);
|
||||||
|
bars.querySelectorAll('.seg').forEach((seg,i)=>{
|
||||||
|
const on=i<lit; seg.className='seg'+(on?' on':'')+(on&&i>=SEGS*0.72?' hot':'');});
|
||||||
|
// squelch
|
||||||
|
const busy=s.squelch_open;
|
||||||
|
$('#busyled').className='dot'+(busy?' on':'');
|
||||||
|
$('#busytxt').innerHTML=busy?'SQ OPEN':'SQ CLOSED';
|
||||||
|
// modes active
|
||||||
|
modesEl.querySelectorAll('.mode').forEach(b=>b.classList.toggle('active',b.textContent===s.mode));
|
||||||
|
// faders (don't fight the user mid-drag)
|
||||||
|
if(dragging!=='vol'){const v=$('#vol');v.value=s.volume;v.style.setProperty('--p',(s.volume/255*100)+'%');$('#volv').textContent=s.volume;}
|
||||||
|
if(dragging!=='sql'){const v=$('#sql');v.value=s.squelch_level;v.style.setProperty('--p',(s.squelch_level/255*100)+'%');$('#sqlv').textContent=s.squelch_level;}
|
||||||
|
}
|
||||||
|
|
||||||
|
function offline(msg){$('#led').className='dot off';$('#linktxt').textContent='offline';err(msg||'no radio');}
|
||||||
|
|
||||||
|
async function refresh(){
|
||||||
|
try{const r=await fetch('/api/status');const s=await r.json();
|
||||||
|
if(s.ok){render(s);} else {offline(s.error);}
|
||||||
|
}catch(e){offline('server unreachable');}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fader wiring with debounce
|
||||||
|
function wireFader(id,path,valEl){
|
||||||
|
const el=$('#'+id);
|
||||||
|
el.addEventListener('input',()=>{dragging=id;el.style.setProperty('--p',(el.value/255*100)+'%');$(valEl).textContent=el.value;});
|
||||||
|
let t;
|
||||||
|
const commit=()=>{clearTimeout(t);t=setTimeout(()=>{post(path,{value:+el.value});dragging=null;},120);};
|
||||||
|
el.addEventListener('change',commit);
|
||||||
|
el.addEventListener('pointerup',commit);
|
||||||
|
}
|
||||||
|
wireFader('vol','/api/vol','#volv');
|
||||||
|
wireFader('sql','/api/sql','#sqlv');
|
||||||
|
|
||||||
|
$('#fset').onclick=()=>{const v=parseFloat($('#finp').value);if(!isNaN(v))post('/api/freq',{mhz:v});};
|
||||||
|
$('#finp').addEventListener('keydown',e=>{if(e.key==='Enter')$('#fset').click();});
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
setInterval(refresh,900);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""
|
||||||
|
Serial transport + high-level control for the ICOM ID-5100 over CI-V.
|
||||||
|
|
||||||
|
Talks to the RT Systems USB-29A cable (FTDI) plugged into the radio's [SP2]
|
||||||
|
jack. Uses pure-stdlib termios — no pyserial. Pairs with civ.py (the codec).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
from radio import Radio
|
||||||
|
with Radio("/dev/ttyUSB0", 9600) as r:
|
||||||
|
print(r.get_frequency()) # Hz
|
||||||
|
r.set_frequency(146_520_000)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import termios
|
||||||
|
|
||||||
|
import civ
|
||||||
|
|
||||||
|
BAUD_CONST = {
|
||||||
|
1200: termios.B1200, 4800: termios.B4800, 9600: termios.B9600,
|
||||||
|
19200: termios.B19200, 38400: termios.B38400, 115200: termios.B115200,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mode byte -> name. Analog codes are standard ICOM; confirm DV against the
|
||||||
|
# manual's CI-V table (overridden empirically where the live radio disagrees).
|
||||||
|
MODE_NAMES = {0x00: "LSB", 0x01: "USB", 0x02: "AM", 0x03: "CW",
|
||||||
|
0x05: "FM", 0x17: "DV"}
|
||||||
|
MODE_CODES = {v: k for k, v in MODE_NAMES.items()}
|
||||||
|
|
||||||
|
ACK_OK = 0xFB
|
||||||
|
ACK_NG = 0xFA
|
||||||
|
|
||||||
|
|
||||||
|
class CIVError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Radio:
|
||||||
|
def __init__(self, port="/dev/ttyUSB0", baud=9600, timeout=0.6):
|
||||||
|
self.port = port
|
||||||
|
self.baud = baud
|
||||||
|
self.timeout = timeout
|
||||||
|
self.fd = None
|
||||||
|
|
||||||
|
# ---- lifecycle -------------------------------------------------------
|
||||||
|
def open(self):
|
||||||
|
speed = BAUD_CONST.get(self.baud)
|
||||||
|
if speed is None:
|
||||||
|
raise ValueError(f"unsupported baud {self.baud}")
|
||||||
|
fd = os.open(self.port, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
|
||||||
|
iflag, oflag, cflag, lflag, ispeed, ospeed, cc = termios.tcgetattr(fd)
|
||||||
|
cflag = termios.CS8 | termios.CREAD | termios.CLOCAL
|
||||||
|
cc = list(cc)
|
||||||
|
cc[termios.VMIN] = 0
|
||||||
|
cc[termios.VTIME] = 0
|
||||||
|
termios.tcsetattr(fd, termios.TCSANOW, [0, 0, cflag, 0, speed, speed, cc])
|
||||||
|
termios.tcflush(fd, termios.TCIOFLUSH)
|
||||||
|
self.fd = fd
|
||||||
|
return self
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self.fd is not None:
|
||||||
|
os.close(self.fd)
|
||||||
|
self.fd = None
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self.open()
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
# ---- raw exchange ----------------------------------------------------
|
||||||
|
def _read_for(self, seconds):
|
||||||
|
buf = bytearray()
|
||||||
|
deadline = time.monotonic() + seconds
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
chunk = os.read(self.fd, 256)
|
||||||
|
except BlockingIOError:
|
||||||
|
chunk = b""
|
||||||
|
if chunk:
|
||||||
|
buf.extend(chunk)
|
||||||
|
else:
|
||||||
|
time.sleep(0.005)
|
||||||
|
return bytes(buf)
|
||||||
|
|
||||||
|
def transact(self, cmd, data=b""):
|
||||||
|
"""Send a command, return the radio's reply frame. Returns as soon as a
|
||||||
|
frame from the radio (not our own bus echo) is parsed, so it doesn't
|
||||||
|
burn the full timeout on every call."""
|
||||||
|
if self.fd is None:
|
||||||
|
raise CIVError("port not open")
|
||||||
|
termios.tcflush(self.fd, termios.TCIFLUSH)
|
||||||
|
os.write(self.fd, civ.build_frame(cmd, data))
|
||||||
|
buf = bytearray()
|
||||||
|
deadline = time.monotonic() + self.timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
chunk = os.read(self.fd, 256)
|
||||||
|
except BlockingIOError:
|
||||||
|
chunk = b""
|
||||||
|
if chunk:
|
||||||
|
buf.extend(chunk)
|
||||||
|
for fr in civ.parse_frames(bytes(buf)):
|
||||||
|
if fr.frm == civ.RADIO_ADDR: # radio's reply, not our echo
|
||||||
|
return fr
|
||||||
|
else:
|
||||||
|
time.sleep(0.005)
|
||||||
|
raise CIVError(f"no reply to cmd 0x{cmd:02X} (got {bytes(buf).hex(' ') or 'nothing'})")
|
||||||
|
|
||||||
|
def _command(self, cmd, data):
|
||||||
|
"""Send a set-command and require an OK ack."""
|
||||||
|
fr = self.transact(cmd, data)
|
||||||
|
if fr.cn == ACK_NG:
|
||||||
|
raise CIVError(f"radio rejected cmd 0x{cmd:02X}")
|
||||||
|
if fr.cn not in (ACK_OK, cmd):
|
||||||
|
raise CIVError(f"unexpected ack 0x{fr.cn:02X} for cmd 0x{cmd:02X}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ---- frequency -------------------------------------------------------
|
||||||
|
def get_frequency(self):
|
||||||
|
return civ.bcd_to_freq(self.transact(civ.CMD_READ_FREQ).data)
|
||||||
|
|
||||||
|
def set_frequency(self, hz):
|
||||||
|
return self._command(civ.CMD_SET_FREQ, civ.freq_to_bcd(hz))
|
||||||
|
|
||||||
|
# ---- mode ------------------------------------------------------------
|
||||||
|
def get_mode(self):
|
||||||
|
data = self.transact(civ.CMD_READ_MODE).data
|
||||||
|
code = data[0] if data else None
|
||||||
|
return MODE_NAMES.get(code, f"0x{code:02X}" if code is not None else "?"), code
|
||||||
|
|
||||||
|
def set_mode(self, name):
|
||||||
|
code = MODE_CODES.get(name.upper())
|
||||||
|
if code is None:
|
||||||
|
raise ValueError(f"unknown mode {name}; known: {sorted(MODE_CODES)}")
|
||||||
|
return self._command(civ.CMD_SET_MODE, bytes([code]))
|
||||||
|
|
||||||
|
# ---- levels ----------------------------------------------------------
|
||||||
|
def get_volume(self):
|
||||||
|
return civ.bcd_to_level(self.transact(civ.CMD_LEVEL, bytes([civ.SUB_AF])).data[1:])
|
||||||
|
|
||||||
|
def set_volume(self, value):
|
||||||
|
return self._command(civ.CMD_LEVEL, bytes([civ.SUB_AF]) + civ.level_to_bcd(value))
|
||||||
|
|
||||||
|
def get_squelch_level(self):
|
||||||
|
return civ.bcd_to_level(self.transact(civ.CMD_LEVEL, bytes([civ.SUB_SQL_LEVEL])).data[1:])
|
||||||
|
|
||||||
|
def set_squelch_level(self, value):
|
||||||
|
return self._command(civ.CMD_LEVEL, bytes([civ.SUB_SQL_LEVEL]) + civ.level_to_bcd(value))
|
||||||
|
|
||||||
|
# ---- meters ----------------------------------------------------------
|
||||||
|
def get_smeter(self):
|
||||||
|
return civ.bcd_to_level(self.transact(civ.CMD_METER, bytes([civ.SUB_SMETER])).data[1:])
|
||||||
|
|
||||||
|
def squelch_open(self):
|
||||||
|
data = self.transact(civ.CMD_METER, bytes([civ.SUB_SQL_STATUS])).data
|
||||||
|
return bool(data[1]) if len(data) > 1 else False
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Web control panel server for the ICOM ID-5100.
|
||||||
|
|
||||||
|
Holds one CI-V serial connection (so don't run cli.py at the same time),
|
||||||
|
serves the instrument-panel UI, and exposes a tiny JSON API on top of radio.py.
|
||||||
|
|
||||||
|
python3 server.py [--port 8550] [--serial /dev/ttyUSB0] [--baud 9600]
|
||||||
|
|
||||||
|
Then browse to http://<host>:8550/ (binds 0.0.0.0 -> reachable from phone).
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
from radio import Radio, CIVError
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
PANEL = os.path.join(HERE, "panel.html")
|
||||||
|
|
||||||
|
radio = None
|
||||||
|
lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def read_status():
|
||||||
|
with lock:
|
||||||
|
freq = radio.get_frequency()
|
||||||
|
mode, code = radio.get_mode()
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"freq_mhz": round(freq / 1e6, 5),
|
||||||
|
"mode": mode,
|
||||||
|
"mode_code": code,
|
||||||
|
"volume": radio.get_volume(),
|
||||||
|
"squelch_level": radio.get_squelch_level(),
|
||||||
|
"squelch_open": radio.squelch_open(),
|
||||||
|
"smeter": radio.get_smeter(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_control(path, body):
|
||||||
|
with lock:
|
||||||
|
if path == "/api/freq":
|
||||||
|
radio.set_frequency(int(round(float(body["mhz"]) * 1e6)))
|
||||||
|
elif path == "/api/mode":
|
||||||
|
radio.set_mode(str(body["mode"]))
|
||||||
|
elif path == "/api/vol":
|
||||||
|
radio.set_volume(int(body["value"]))
|
||||||
|
elif path == "/api/sql":
|
||||||
|
radio.set_squelch_level(int(body["value"]))
|
||||||
|
else:
|
||||||
|
raise CIVError("unknown control")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass # quiet
|
||||||
|
|
||||||
|
def _json(self, obj, code=200):
|
||||||
|
payload = json.dumps(obj).encode()
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(payload)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(payload)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path == "/" or self.path == "/index.html":
|
||||||
|
with open(PANEL, "rb") as f:
|
||||||
|
body = f.read()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
elif self.path == "/api/status":
|
||||||
|
try:
|
||||||
|
self._json(read_status())
|
||||||
|
except (CIVError, OSError) as e:
|
||||||
|
self._json({"ok": False, "error": str(e)}, 200)
|
||||||
|
else:
|
||||||
|
self._json({"ok": False, "error": "not found"}, 404)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
|
try:
|
||||||
|
body = json.loads(self.rfile.read(length) or b"{}")
|
||||||
|
self._json(apply_control(self.path, body))
|
||||||
|
except (CIVError, OSError, ValueError, KeyError) as e:
|
||||||
|
self._json({"ok": False, "error": str(e)}, 200)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
global radio
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--port", type=int, default=8550)
|
||||||
|
ap.add_argument("--serial", default="/dev/ttyUSB0")
|
||||||
|
ap.add_argument("--baud", type=int, default=9600)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
radio = Radio(args.serial, args.baud).open()
|
||||||
|
httpd = ThreadingHTTPServer(("0.0.0.0", args.port), Handler)
|
||||||
|
print(f"ID-5100 panel on http://0.0.0.0:{args.port}/ "
|
||||||
|
f"(serial {args.serial} @ {args.baud})")
|
||||||
|
try:
|
||||||
|
httpd.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
radio.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Unit tests for the pure CI-V codec (civ.py). Stdlib only: `python3 -m unittest`."""
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import civ
|
||||||
|
|
||||||
|
|
||||||
|
class TestFreqCodec(unittest.TestCase):
|
||||||
|
# Real capture from the radio: VFO was 147.300000 MHz.
|
||||||
|
REAL_BCD = bytes.fromhex("0000304701")
|
||||||
|
REAL_HZ = 147_300_000
|
||||||
|
|
||||||
|
def test_bcd_to_freq_decodes_real_capture(self):
|
||||||
|
self.assertEqual(civ.bcd_to_freq(self.REAL_BCD), self.REAL_HZ)
|
||||||
|
|
||||||
|
def test_freq_to_bcd_matches_real_capture(self):
|
||||||
|
self.assertEqual(civ.freq_to_bcd(self.REAL_HZ), self.REAL_BCD)
|
||||||
|
|
||||||
|
def test_freq_roundtrip(self):
|
||||||
|
for hz in (146_520_000, 446_000_000, 147_300_000, 1_240_000_000):
|
||||||
|
self.assertEqual(civ.bcd_to_freq(civ.freq_to_bcd(hz)), hz)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLevelCodec(unittest.TestCase):
|
||||||
|
# ICOM levels are 0..255 sent as 2-byte BCD "0000".."0255".
|
||||||
|
def test_level_to_bcd_known(self):
|
||||||
|
self.assertEqual(civ.level_to_bcd(0), b"\x00\x00")
|
||||||
|
self.assertEqual(civ.level_to_bcd(128), b"\x01\x28")
|
||||||
|
self.assertEqual(civ.level_to_bcd(255), b"\x02\x55")
|
||||||
|
|
||||||
|
def test_bcd_to_level_known(self):
|
||||||
|
self.assertEqual(civ.bcd_to_level(b"\x00\x00"), 0)
|
||||||
|
self.assertEqual(civ.bcd_to_level(b"\x01\x28"), 128)
|
||||||
|
self.assertEqual(civ.bcd_to_level(b"\x02\x55"), 255)
|
||||||
|
|
||||||
|
def test_level_rejects_out_of_range(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
civ.level_to_bcd(256)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
civ.level_to_bcd(-1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildFrame(unittest.TestCase):
|
||||||
|
def test_read_freq_frame(self):
|
||||||
|
# FE FE <radio=8C> <ctrl=E0> 03 FD
|
||||||
|
self.assertEqual(civ.build_frame(0x03), bytes.fromhex("fefe8ce003fd"))
|
||||||
|
|
||||||
|
def test_frame_with_data(self):
|
||||||
|
# set freq 147.3 MHz: FE FE 8C E0 05 <5 BCD> FD
|
||||||
|
frame = civ.build_frame(0x05, civ.freq_to_bcd(147_300_000))
|
||||||
|
self.assertEqual(frame, bytes.fromhex("fefe8ce005" + "0000304701" + "fd"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseFrames(unittest.TestCase):
|
||||||
|
# Exact bytes the radio returned during the probe (echo + reply).
|
||||||
|
REAL_RX = bytes.fromhex("fefe8ce003fd" + "fefee08c030000304701fd")
|
||||||
|
|
||||||
|
def test_splits_echo_and_reply(self):
|
||||||
|
frames = civ.parse_frames(self.REAL_RX)
|
||||||
|
self.assertEqual(len(frames), 2)
|
||||||
|
echo, reply = frames
|
||||||
|
self.assertEqual((echo.to, echo.frm, echo.cn), (0x8C, 0xE0, 0x03))
|
||||||
|
self.assertEqual(echo.data, b"")
|
||||||
|
self.assertEqual((reply.to, reply.frm, reply.cn), (0xE0, 0x8C, 0x03))
|
||||||
|
self.assertEqual(reply.data, bytes.fromhex("0000304701"))
|
||||||
|
|
||||||
|
def test_ignores_leading_noise_and_partial_tail(self):
|
||||||
|
noisy = b"\x00\xff" + self.REAL_RX + b"\xfe\xfe\x99" # partial frame at end
|
||||||
|
frames = civ.parse_frames(noisy)
|
||||||
|
self.assertEqual(len(frames), 2)
|
||||||
|
|
||||||
|
def test_empty_buffer(self):
|
||||||
|
self.assertEqual(civ.parse_frames(b""), [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user