diff --git a/civ.py b/civ.py index 984b2da..61aa117 100644 --- a/civ.py +++ b/civ.py @@ -24,6 +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 +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 + Frame = namedtuple("Frame", "to frm cn data") @@ -66,6 +77,41 @@ 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).""" + 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 + + def build_frame(cmd, data=b"", to=RADIO_ADDR, frm=CTRL_ADDR): """Assemble FE FE [data] FD.""" return PREAMBLE + bytes([to, frm, cmd]) + bytes(data) + bytes([EOM]) diff --git a/test_civ.py b/test_civ.py index e07df5d..bf48bc7 100644 --- a/test_civ.py +++ b/test_civ.py @@ -73,5 +73,36 @@ 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". + 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) + + if __name__ == "__main__": unittest.main()