""" Pure CI-V codec for the ICOM ID-5100 — no I/O, no dependencies. CI-V frame: FE FE [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 # --- 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 freq (verified) SUB_RPT_TONE = 0x00 CMD_TONE_SWITCH = 0x16 # sub 0x42 = tone encode on/off (verified) SUB_TONE_ENC = 0x42 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 tone_to_bcd(hz): """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])]) 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]) 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