feat: offset + CTCSS tone BCD codecs (provisional encodings)
This commit is contained in:
@@ -24,6 +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) ---
|
||||||
|
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")
|
Frame = namedtuple("Frame", "to frm cn data")
|
||||||
|
|
||||||
|
|
||||||
@@ -66,6 +77,41 @@ 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):
|
||||||
|
"""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):
|
def build_frame(cmd, data=b"", to=RADIO_ADDR, frm=CTRL_ADDR):
|
||||||
"""Assemble FE FE <to> <from> <cmd> [data] FD."""
|
"""Assemble FE FE <to> <from> <cmd> [data] FD."""
|
||||||
return PREAMBLE + bytes([to, frm, cmd]) + bytes(data) + bytes([EOM])
|
return PREAMBLE + bytes([to, frm, cmd]) + bytes(data) + bytes([EOM])
|
||||||
|
|||||||
+31
@@ -73,5 +73,36 @@ 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):
|
||||||
|
# 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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user