Files
id5100/civ.py
T
Seth 69710e362a 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)
2026-06-14 16:22:19 -04:00

128 lines
4.0 KiB
Python

"""
Pure CI-V codec for the ICOM ID-5100 — no I/O, no dependencies.
CI-V frame: FE FE <to> <from> <cmd> [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 <to> <from> <cmd> [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