Compare commits
3 Commits
66b368fb87
...
5d48ad0bfe
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d48ad0bfe | |||
| e28dae2ba4 | |||
| 69710e362a |
@@ -22,6 +22,23 @@ Format: `YYYY-MM-DD: <decision> — <why>`
|
|||||||
- 2026-06-14: Aesthetic = industrial instrument faceplate (amber VFD via DSEG7 font,
|
- 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
|
LED S-meter, illuminated mode buttons) — matches the radio-gear domain and the
|
||||||
Sethian dark/orange brand. Verified by headless screenshot.
|
Sethian dark/orange brand. Verified by headless screenshot.
|
||||||
|
- 2026-06-14: RepeaterBook integration — local `repeaters.json` + a proximity-ordered
|
||||||
|
panel "Repeaters" card; tapping a row tunes the radio (freq + duplex + 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).
|
||||||
|
RepeaterBook's API is now auth-gated (`401 auth_missing`, manual app-token approval);
|
||||||
|
Seth supplied a CSV proximity export instead, so manual re-ingest is enough (YAGNI).
|
||||||
|
- 2026-06-14: No lat/long in the CSV → rank by file order (RepeaterBook's own proximity
|
||||||
|
sort) instead of haversine. The export is already nearest-first; geocoding is overkill.
|
||||||
|
- 2026-06-14: Mixed FM+DSTAR repeaters labeled FM (operable as analog now); pure DSTAR
|
||||||
|
labeled DV (freq/offset only — call routing is set on the head).
|
||||||
|
- 2026-06-14: **Live CI-V verification of repeater commands.** Verified on the rig:
|
||||||
|
duplex direction `0x0F` (read → 0x10 simplex), repeater tone `0x1B/00`, tone-switch
|
||||||
|
`0x16/42`. The tone field is 3 bytes on the wire (`00` pad + 2-byte BCD, e.g.
|
||||||
|
`00 08 85` = 88.5 Hz) — radio.py adds/strips the pad. A full `tune_repeater` to a live
|
||||||
|
repeater ACKed every step. Offset *amount* cmd `0x0D` is **rejected (NG)** by the
|
||||||
|
ID-5100 → removed; the radio applies its configured auto-offset when a duplex
|
||||||
|
direction is selected, so standard (0.6/5 MHz) repeaters work from duplex alone.
|
||||||
|
|
||||||
## Deferred / Rejected
|
## Deferred / Rejected
|
||||||
|
|
||||||
@@ -33,3 +50,10 @@ Format: `YYYY-MM-DD: <decision> — <why>`
|
|||||||
as trusted until confirmed against the manual's CI-V table or the live rig.
|
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
|
- **RS-MS1A data-jack protocol (D-STAR DR/callsign/messaging)** — out of scope for
|
||||||
this CI-V layer; separate effort if wanted.
|
this CI-V layer; separate effort if wanted.
|
||||||
|
- **RepeaterBook API client + daily systemd refresh** — deferred (CSV-only chosen);
|
||||||
|
revive if hands-off refresh is wanted. The token-request form text is in the design
|
||||||
|
spec under `docs/superpowers/specs/`.
|
||||||
|
- **CI-V offset-amount control** — rejected: the ID-5100 NGs cmd `0x0D`. Offset amount
|
||||||
|
is a radio-side config (auto-offset); only direction (DUP±) is settable over CI-V.
|
||||||
|
- **Distance/haversine ranking** — rejected for the CSV path: the export has no
|
||||||
|
lat/long and is already proximity-sorted, so file order is the rank.
|
||||||
|
|||||||
@@ -42,6 +42,28 @@ 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
|
live frequency, mode, S-meter, squelch, and levels; controls POST to
|
||||||
`/api/{freq,mode,vol,sql}`.
|
`/api/{freq,mode,vol,sql}`.
|
||||||
|
|
||||||
|
## Repeaters (RepeaterBook CSV)
|
||||||
|
|
||||||
|
The panel's **Repeaters** card lists nearby repeaters from a local `repeaters.json`,
|
||||||
|
in RepeaterBook's proximity order, and tunes the radio (freq + duplex + tone) on tap.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# refresh the data: drop a new CSV into repeaterbook/, then re-ingest
|
||||||
|
python3 repeaters.py ingest repeaterbook/RB_2606141409.csv repeaters.json
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Data source:** a RepeaterBook CSV proximity export (download from your account for
|
||||||
|
your area). It has no lat/long, but it's sorted nearest-first, so row order is the rank.
|
||||||
|
- **Filter:** keeps FM + D-STAR on 2m/70cm (drops DMR/P25/NXDN-only). ~1073 of 1258 rows.
|
||||||
|
- **API:** `GET /api/repeaters?q=&limit=` (search + nearest-first list);
|
||||||
|
`POST /api/tune-repeater` (body = one record) → per-step result
|
||||||
|
`{ok, steps:{freq,duplex,tone_freq,tone_on}, errors}`.
|
||||||
|
- **Offset:** the offset *amount* is **not** set over CI-V (the ID-5100 rejects it);
|
||||||
|
only the duplex *direction* (DUP±) is sent, and the radio applies its own configured
|
||||||
|
offset (standard 0.6/5 MHz). Non-standard offsets are set on the radio head.
|
||||||
|
- **D-STAR:** DV rows tune freq/direction only — call routing (URCALL/RPT1/RPT2) is the
|
||||||
|
separate RS-MS1A protocol, set on the head.
|
||||||
|
|
||||||
## Library
|
## Library
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -67,6 +89,10 @@ python3 -m unittest test_civ -v
|
|||||||
- **Frequency BCD is little-endian; level/meter BCD is big-endian** (`128 -> 01 28`).
|
- **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
|
- **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.
|
ICOM table and **not yet verified on this radio** — confirm before relying on it.
|
||||||
|
- **Repeater commands (verified live 2026-06-14):** duplex direction `0x0F`
|
||||||
|
(0x10 simplex / 0x11 DUP− / 0x12 DUP+), repeater tone `0x1B/0x00`, tone on/off
|
||||||
|
`0x16/0x42`. The tone wire field is **3 bytes** (`00` pad + 2-byte BCD; `00 08 85`
|
||||||
|
= 88.5 Hz). **Offset-amount `0x0D` is rejected (NG)** by the ID-5100 — not supported.
|
||||||
- Scope is CI-V (operate the radio). D-STAR DR routing / callsign entry live in
|
- Scope is CI-V (operate the radio). D-STAR DR routing / callsign entry live in
|
||||||
the separate RS-MS1A data-jack protocol, not here.
|
the separate RS-MS1A data-jack protocol, not here.
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -24,15 +24,17 @@ SUB_SQL_LEVEL = 0x03
|
|||||||
SUB_SQL_STATUS = 0x01
|
SUB_SQL_STATUS = 0x01
|
||||||
SUB_SMETER = 0x02
|
SUB_SMETER = 0x02
|
||||||
|
|
||||||
# --- repeater config (candidate commands; verify on live radio) ---
|
# --- repeater config (verified on live ID-5100 2026-06-14) ---
|
||||||
CMD_OFFSET = 0x0D # set duplex offset frequency
|
# NOTE: offset-amount cmd 0x0D is REJECTED (NG) by the ID-5100 — the offset
|
||||||
CMD_DUPLEX = 0x0F # set duplex direction
|
# amount is not CI-V-settable; the radio applies its configured auto-offset
|
||||||
|
# (default 0.6 MHz / 5 MHz) when a duplex direction is selected via 0x0F.
|
||||||
|
CMD_DUPLEX = 0x0F # set/read duplex direction (verified: read -> 0x10 simplex)
|
||||||
DUP_SIMPLEX = 0x10
|
DUP_SIMPLEX = 0x10
|
||||||
DUP_MINUS = 0x11
|
DUP_MINUS = 0x11
|
||||||
DUP_PLUS = 0x12
|
DUP_PLUS = 0x12
|
||||||
CMD_TONE_PARAM = 0x1B # sub 0x00 = repeater (TX) tone frequency
|
CMD_TONE_PARAM = 0x1B # sub 0x00 = repeater (TX) tone freq (verified)
|
||||||
SUB_RPT_TONE = 0x00
|
SUB_RPT_TONE = 0x00
|
||||||
CMD_TONE_SWITCH = 0x16 # sub 0x42 = tone encode on/off
|
CMD_TONE_SWITCH = 0x16 # sub 0x42 = tone encode on/off (verified)
|
||||||
SUB_TONE_ENC = 0x42
|
SUB_TONE_ENC = 0x42
|
||||||
|
|
||||||
Frame = namedtuple("Frame", "to frm cn data")
|
Frame = namedtuple("Frame", "to frm cn data")
|
||||||
@@ -77,28 +79,12 @@ def bcd_to_level(data):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def tone_to_bcd(hz):
|
def tone_to_bcd(hz):
|
||||||
"""CTCSS tone Hz -> 2-byte BCD of tone*10 (88.5 -> 08 85)."""
|
"""CTCSS tone Hz -> 2-byte BCD of tone*10 (88.5 -> 08 85).
|
||||||
|
|
||||||
|
The ID-5100 wire format for cmd 0x1B/0x00 prepends a 0x00 pad byte before
|
||||||
|
these two (so 88.5 Hz reads/writes as `00 08 85`); radio.py adds/strips it.
|
||||||
|
"""
|
||||||
digits = f"{round(hz * 10):04d}"
|
digits = f"{round(hz * 10):04d}"
|
||||||
return bytes([(int(digits[0]) << 4) | int(digits[1]),
|
return bytes([(int(digits[0]) << 4) | int(digits[1]),
|
||||||
(int(digits[2]) << 4) | int(digits[3])])
|
(int(digits[2]) << 4) | int(digits[3])])
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 515 KiB |
@@ -157,49 +157,50 @@ class Radio:
|
|||||||
data = self.transact(civ.CMD_METER, bytes([civ.SUB_SQL_STATUS])).data
|
data = self.transact(civ.CMD_METER, bytes([civ.SUB_SQL_STATUS])).data
|
||||||
return bool(data[1]) if len(data) > 1 else False
|
return bool(data[1]) if len(data) > 1 else False
|
||||||
|
|
||||||
# ---- repeater config (candidate commands; verify live) ----------------
|
# ---- repeater config (verified on live ID-5100 2026-06-14) ------------
|
||||||
|
# Offset *amount* is not CI-V-settable on this radio (cmd 0x0D -> NG); the
|
||||||
|
# radio applies its configured auto-offset when a duplex direction is set.
|
||||||
def set_duplex(self, direction):
|
def set_duplex(self, direction):
|
||||||
"""direction: '+', '-', or 'simplex'."""
|
"""direction: '+', '-', or 'simplex'. Radio uses its configured offset."""
|
||||||
code = {"+": civ.DUP_PLUS, "-": civ.DUP_MINUS,
|
code = {"+": civ.DUP_PLUS, "-": civ.DUP_MINUS,
|
||||||
"simplex": civ.DUP_SIMPLEX}.get(direction)
|
"simplex": civ.DUP_SIMPLEX}.get(direction)
|
||||||
if code is None:
|
if code is None:
|
||||||
raise ValueError(f"bad duplex {direction!r}")
|
raise ValueError(f"bad duplex {direction!r}")
|
||||||
return self._command(civ.CMD_DUPLEX, bytes([code]))
|
return self._command(civ.CMD_DUPLEX, bytes([code]))
|
||||||
|
|
||||||
def set_offset(self, hz):
|
def get_duplex(self):
|
||||||
return self._command(civ.CMD_OFFSET, civ.offset_to_bcd(hz))
|
code = self.transact(civ.CMD_DUPLEX).data[0]
|
||||||
|
return {civ.DUP_PLUS: "+", civ.DUP_MINUS: "-",
|
||||||
def get_offset(self):
|
civ.DUP_SIMPLEX: "simplex"}.get(code, f"0x{code:02X}")
|
||||||
return civ.bcd_to_offset(self.transact(civ.CMD_OFFSET).data)
|
|
||||||
|
|
||||||
def set_tone(self, hz):
|
def set_tone(self, hz):
|
||||||
|
# wire format: sub(0x00) + 0x00 pad + 2-byte BCD (00 00 08 85 = 88.5 Hz)
|
||||||
return self._command(civ.CMD_TONE_PARAM,
|
return self._command(civ.CMD_TONE_PARAM,
|
||||||
bytes([civ.SUB_RPT_TONE]) + civ.tone_to_bcd(hz))
|
bytes([civ.SUB_RPT_TONE, 0x00]) + civ.tone_to_bcd(hz))
|
||||||
|
|
||||||
def get_tone(self):
|
def get_tone(self):
|
||||||
|
# reply data = sub + pad + 2-byte BCD; take the last two bytes
|
||||||
return civ.bcd_to_tone(self.transact(civ.CMD_TONE_PARAM,
|
return civ.bcd_to_tone(self.transact(civ.CMD_TONE_PARAM,
|
||||||
bytes([civ.SUB_RPT_TONE])).data[1:])
|
bytes([civ.SUB_RPT_TONE])).data[2:])
|
||||||
|
|
||||||
def set_tone_on(self, on):
|
def set_tone_on(self, on):
|
||||||
return self._command(civ.CMD_TONE_SWITCH,
|
return self._command(civ.CMD_TONE_SWITCH,
|
||||||
bytes([civ.SUB_TONE_ENC, 0x01 if on else 0x00]))
|
bytes([civ.SUB_TONE_ENC, 0x01 if on else 0x00]))
|
||||||
|
|
||||||
def tune_repeater(self, rec):
|
def tune_repeater(self, rec):
|
||||||
"""Apply a normalized record: freq -> duplex/offset -> tone.
|
"""Apply a normalized record: freq -> duplex direction -> tone.
|
||||||
|
Offset amount comes from the radio's own config (not CI-V-settable).
|
||||||
Returns (steps, errors): each step True/False; errors keyed by step."""
|
Returns (steps, errors): each step True/False; errors keyed by step."""
|
||||||
steps, errors = {}, {}
|
steps, errors = {}, {}
|
||||||
|
|
||||||
def attempt(name, fn):
|
def attempt(name, fn):
|
||||||
try:
|
try:
|
||||||
fn(); steps[name] = True
|
fn(); steps[name] = True
|
||||||
except (CIVError, ValueError, OSError) as e:
|
except (CIVError, ValueError, TypeError, OSError) as e:
|
||||||
steps[name] = False; errors[name] = str(e)
|
steps[name] = False; errors[name] = str(e)
|
||||||
|
|
||||||
attempt("freq", lambda: self.set_frequency(int(round(rec["output_mhz"] * 1e6))))
|
attempt("freq", lambda: self.set_frequency(int(round(rec["output_mhz"] * 1e6))))
|
||||||
duplex = rec.get("duplex", "simplex")
|
attempt("duplex", lambda: self.set_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"):
|
if rec.get("tone_mode") == "ctcss" and rec.get("tone_hz"):
|
||||||
attempt("tone_freq", lambda: self.set_tone(rec["tone_hz"]))
|
attempt("tone_freq", lambda: self.set_tone(rec["tone_hz"]))
|
||||||
attempt("tone_on", lambda: self.set_tone_on(True))
|
attempt("tone_on", lambda: self.set_tone_on(True))
|
||||||
|
|||||||
+1
-1
@@ -72,7 +72,7 @@ def load(path):
|
|||||||
try:
|
try:
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
except FileNotFoundError:
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
return []
|
return []
|
||||||
return data if isinstance(data, list) else data.get("repeaters", [])
|
return data if isinstance(data, list) else data.get("repeaters", [])
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,10 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
from urllib.parse import urlparse, parse_qs
|
from urllib.parse import urlparse, parse_qs
|
||||||
qs = parse_qs(urlparse(self.path).query)
|
qs = parse_qs(urlparse(self.path).query)
|
||||||
q = qs.get("q", [""])[0]
|
q = qs.get("q", [""])[0]
|
||||||
|
try:
|
||||||
limit = int(qs.get("limit", ["25"])[0])
|
limit = int(qs.get("limit", ["25"])[0])
|
||||||
|
except ValueError:
|
||||||
|
limit = 25
|
||||||
recs = repeaters.search(repeaters.load(REPEATERS_JSON), q, limit)
|
recs = repeaters.search(repeaters.load(REPEATERS_JSON), q, limit)
|
||||||
self._json({"ok": True, "repeaters": recs})
|
self._json({"ok": True, "repeaters": recs})
|
||||||
else:
|
else:
|
||||||
@@ -101,7 +104,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
try:
|
try:
|
||||||
body = json.loads(self.rfile.read(length) or b"{}")
|
body = json.loads(self.rfile.read(length) or b"{}")
|
||||||
self._json(apply_control(self.path, body))
|
self._json(apply_control(self.path, body))
|
||||||
except (CIVError, OSError, ValueError, KeyError) as e:
|
except (CIVError, OSError, ValueError, TypeError, KeyError) as e:
|
||||||
self._json({"ok": False, "error": str(e)}, 200)
|
self._json({"ok": False, "error": str(e)}, 200)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-15
@@ -73,23 +73,10 @@ class TestParseFrames(unittest.TestCase):
|
|||||||
self.assertEqual(civ.parse_frames(b""), [])
|
self.assertEqual(civ.parse_frames(b""), [])
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class TestToneCodec(unittest.TestCase):
|
class TestToneCodec(unittest.TestCase):
|
||||||
# ICOM convention: CTCSS tone as 2-byte BCD of tone*10. 131.8 -> "13 18".
|
# ICOM convention: CTCSS tone as 2-byte BCD of tone*10. 131.8 -> "13 18".
|
||||||
|
# Verified on the live ID-5100: cmd 0x1B/0x00 returned `00 08 85` for 88.5 Hz
|
||||||
|
# (a 0x00 pad byte precedes these two; radio.py handles the pad).
|
||||||
def test_tone_to_bcd_known(self):
|
def test_tone_to_bcd_known(self):
|
||||||
self.assertEqual(civ.tone_to_bcd(88.5), b"\x08\x85")
|
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(131.8), b"\x13\x18")
|
||||||
|
|||||||
Reference in New Issue
Block a user