From 69710e362a0797b8dc398927031024a5d1c58db3 Mon Sep 17 00:00:00 2001 From: Seth Freiberg Date: Sun, 14 Jun 2026 16:22:19 -0400 Subject: [PATCH] fix: ground-truth CI-V offset/tone on live ID-5100 - offset amount (0x0D) is REJECTED (NG) by the radio -> removed the offset codec + set_offset/get_offset; tune_repeater now sets only duplex direction (radio applies its configured auto-offset) - repeater tone (0x1B/00) uses a 3-byte field (00 + 2-byte BCD); set_tone prepends the pad, get_tone strips it - duplex (0x0F) and tone-switch (0x16/42) verified working - added get_duplex; verified full tune_repeater on a live repeater (all steps OK) --- civ.py | 38 ++++++++++++-------------------------- radio.py | 29 +++++++++++++++-------------- test_civ.py | 17 ++--------------- 3 files changed, 29 insertions(+), 55 deletions(-) diff --git a/civ.py b/civ.py index 61aa117..513e2cf 100644 --- a/civ.py +++ b/civ.py @@ -24,15 +24,17 @@ SUB_SQL_LEVEL = 0x03 SUB_SQL_STATUS = 0x01 SUB_SMETER = 0x02 -# --- repeater config (candidate commands; verify on live radio) --- -CMD_OFFSET = 0x0D # set duplex offset frequency -CMD_DUPLEX = 0x0F # set duplex direction +# --- repeater config (verified on live ID-5100 2026-06-14) --- +# NOTE: offset-amount cmd 0x0D is REJECTED (NG) by the ID-5100 — the offset +# 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_MINUS = 0x11 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 -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 Frame = namedtuple("Frame", "to frm cn data") @@ -77,28 +79,12 @@ def bcd_to_level(data): 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): - """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}" return bytes([(int(digits[0]) << 4) | int(digits[1]), (int(digits[2]) << 4) | int(digits[3])]) diff --git a/radio.py b/radio.py index 080af07..ad4bcdf 100644 --- a/radio.py +++ b/radio.py @@ -157,35 +157,39 @@ class Radio: data = self.transact(civ.CMD_METER, bytes([civ.SUB_SQL_STATUS])).data 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): - """direction: '+', '-', or 'simplex'.""" + """direction: '+', '-', or 'simplex'. Radio uses its configured offset.""" 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 get_duplex(self): + code = self.transact(civ.CMD_DUPLEX).data[0] + return {civ.DUP_PLUS: "+", civ.DUP_MINUS: "-", + civ.DUP_SIMPLEX: "simplex"}.get(code, f"0x{code:02X}") 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, - 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): + # reply data = sub + pad + 2-byte BCD; take the last two bytes 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): 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. + """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.""" steps, errors = {}, {} @@ -196,10 +200,7 @@ class Radio: 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)))) + attempt("duplex", lambda: self.set_duplex(rec.get("duplex", "simplex"))) 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)) diff --git a/test_civ.py b/test_civ.py index bf48bc7..dbd6ca6 100644 --- a/test_civ.py +++ b/test_civ.py @@ -73,23 +73,10 @@ class TestParseFrames(unittest.TestCase): 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): # 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): self.assertEqual(civ.tone_to_bcd(88.5), b"\x08\x85") self.assertEqual(civ.tone_to_bcd(131.8), b"\x13\x18")