""" 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 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 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