207 lines
7.6 KiB
Python
207 lines
7.6 KiB
Python
"""
|
|
Serial transport + high-level control for the ICOM ID-5100 over CI-V.
|
|
|
|
Talks to the RT Systems USB-29A cable (FTDI) plugged into the radio's [SP2]
|
|
jack. Uses pure-stdlib termios — no pyserial. Pairs with civ.py (the codec).
|
|
|
|
Example:
|
|
from radio import Radio
|
|
with Radio("/dev/ttyUSB0", 9600) as r:
|
|
print(r.get_frequency()) # Hz
|
|
r.set_frequency(146_520_000)
|
|
"""
|
|
import os
|
|
import time
|
|
import termios
|
|
|
|
import civ
|
|
|
|
BAUD_CONST = {
|
|
1200: termios.B1200, 4800: termios.B4800, 9600: termios.B9600,
|
|
19200: termios.B19200, 38400: termios.B38400, 115200: termios.B115200,
|
|
}
|
|
|
|
# Mode byte -> name. Analog codes are standard ICOM; confirm DV against the
|
|
# manual's CI-V table (overridden empirically where the live radio disagrees).
|
|
MODE_NAMES = {0x00: "LSB", 0x01: "USB", 0x02: "AM", 0x03: "CW",
|
|
0x05: "FM", 0x17: "DV"}
|
|
MODE_CODES = {v: k for k, v in MODE_NAMES.items()}
|
|
|
|
ACK_OK = 0xFB
|
|
ACK_NG = 0xFA
|
|
|
|
|
|
class CIVError(Exception):
|
|
pass
|
|
|
|
|
|
class Radio:
|
|
def __init__(self, port="/dev/ttyUSB0", baud=9600, timeout=0.6):
|
|
self.port = port
|
|
self.baud = baud
|
|
self.timeout = timeout
|
|
self.fd = None
|
|
|
|
# ---- lifecycle -------------------------------------------------------
|
|
def open(self):
|
|
speed = BAUD_CONST.get(self.baud)
|
|
if speed is None:
|
|
raise ValueError(f"unsupported baud {self.baud}")
|
|
fd = os.open(self.port, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
|
|
iflag, oflag, cflag, lflag, ispeed, ospeed, cc = termios.tcgetattr(fd)
|
|
cflag = termios.CS8 | termios.CREAD | termios.CLOCAL
|
|
cc = list(cc)
|
|
cc[termios.VMIN] = 0
|
|
cc[termios.VTIME] = 0
|
|
termios.tcsetattr(fd, termios.TCSANOW, [0, 0, cflag, 0, speed, speed, cc])
|
|
termios.tcflush(fd, termios.TCIOFLUSH)
|
|
self.fd = fd
|
|
return self
|
|
|
|
def close(self):
|
|
if self.fd is not None:
|
|
os.close(self.fd)
|
|
self.fd = None
|
|
|
|
def __enter__(self):
|
|
return self.open()
|
|
|
|
def __exit__(self, *exc):
|
|
self.close()
|
|
|
|
# ---- raw exchange ----------------------------------------------------
|
|
def _read_for(self, seconds):
|
|
buf = bytearray()
|
|
deadline = time.monotonic() + seconds
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
chunk = os.read(self.fd, 256)
|
|
except BlockingIOError:
|
|
chunk = b""
|
|
if chunk:
|
|
buf.extend(chunk)
|
|
else:
|
|
time.sleep(0.005)
|
|
return bytes(buf)
|
|
|
|
def transact(self, cmd, data=b""):
|
|
"""Send a command, return the radio's reply frame. Returns as soon as a
|
|
frame from the radio (not our own bus echo) is parsed, so it doesn't
|
|
burn the full timeout on every call."""
|
|
if self.fd is None:
|
|
raise CIVError("port not open")
|
|
termios.tcflush(self.fd, termios.TCIFLUSH)
|
|
os.write(self.fd, civ.build_frame(cmd, data))
|
|
buf = bytearray()
|
|
deadline = time.monotonic() + self.timeout
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
chunk = os.read(self.fd, 256)
|
|
except BlockingIOError:
|
|
chunk = b""
|
|
if chunk:
|
|
buf.extend(chunk)
|
|
for fr in civ.parse_frames(bytes(buf)):
|
|
if fr.frm == civ.RADIO_ADDR: # radio's reply, not our echo
|
|
return fr
|
|
else:
|
|
time.sleep(0.005)
|
|
raise CIVError(f"no reply to cmd 0x{cmd:02X} (got {bytes(buf).hex(' ') or 'nothing'})")
|
|
|
|
def _command(self, cmd, data):
|
|
"""Send a set-command and require an OK ack."""
|
|
fr = self.transact(cmd, data)
|
|
if fr.cn == ACK_NG:
|
|
raise CIVError(f"radio rejected cmd 0x{cmd:02X}")
|
|
if fr.cn not in (ACK_OK, cmd):
|
|
raise CIVError(f"unexpected ack 0x{fr.cn:02X} for cmd 0x{cmd:02X}")
|
|
return True
|
|
|
|
# ---- frequency -------------------------------------------------------
|
|
def get_frequency(self):
|
|
return civ.bcd_to_freq(self.transact(civ.CMD_READ_FREQ).data)
|
|
|
|
def set_frequency(self, hz):
|
|
return self._command(civ.CMD_SET_FREQ, civ.freq_to_bcd(hz))
|
|
|
|
# ---- mode ------------------------------------------------------------
|
|
def get_mode(self):
|
|
data = self.transact(civ.CMD_READ_MODE).data
|
|
code = data[0] if data else None
|
|
return MODE_NAMES.get(code, f"0x{code:02X}" if code is not None else "?"), code
|
|
|
|
def set_mode(self, name):
|
|
code = MODE_CODES.get(name.upper())
|
|
if code is None:
|
|
raise ValueError(f"unknown mode {name}; known: {sorted(MODE_CODES)}")
|
|
return self._command(civ.CMD_SET_MODE, bytes([code]))
|
|
|
|
# ---- levels ----------------------------------------------------------
|
|
def get_volume(self):
|
|
return civ.bcd_to_level(self.transact(civ.CMD_LEVEL, bytes([civ.SUB_AF])).data[1:])
|
|
|
|
def set_volume(self, value):
|
|
return self._command(civ.CMD_LEVEL, bytes([civ.SUB_AF]) + civ.level_to_bcd(value))
|
|
|
|
def get_squelch_level(self):
|
|
return civ.bcd_to_level(self.transact(civ.CMD_LEVEL, bytes([civ.SUB_SQL_LEVEL])).data[1:])
|
|
|
|
def set_squelch_level(self, value):
|
|
return self._command(civ.CMD_LEVEL, bytes([civ.SUB_SQL_LEVEL]) + civ.level_to_bcd(value))
|
|
|
|
# ---- meters ----------------------------------------------------------
|
|
def get_smeter(self):
|
|
return civ.bcd_to_level(self.transact(civ.CMD_METER, bytes([civ.SUB_SMETER])).data[1:])
|
|
|
|
def squelch_open(self):
|
|
data = self.transact(civ.CMD_METER, bytes([civ.SUB_SQL_STATUS])).data
|
|
return bool(data[1]) if len(data) > 1 else False
|
|
|
|
# ---- repeater config (candidate commands; verify live) ----------------
|
|
def set_duplex(self, direction):
|
|
"""direction: '+', '-', or 'simplex'."""
|
|
code = {"+": civ.DUP_PLUS, "-": civ.DUP_MINUS,
|
|
"simplex": civ.DUP_SIMPLEX}.get(direction)
|
|
if code is None:
|
|
raise ValueError(f"bad duplex {direction!r}")
|
|
return self._command(civ.CMD_DUPLEX, bytes([code]))
|
|
|
|
def set_offset(self, hz):
|
|
return self._command(civ.CMD_OFFSET, civ.offset_to_bcd(hz))
|
|
|
|
def get_offset(self):
|
|
return civ.bcd_to_offset(self.transact(civ.CMD_OFFSET).data)
|
|
|
|
def set_tone(self, hz):
|
|
return self._command(civ.CMD_TONE_PARAM,
|
|
bytes([civ.SUB_RPT_TONE]) + civ.tone_to_bcd(hz))
|
|
|
|
def get_tone(self):
|
|
return civ.bcd_to_tone(self.transact(civ.CMD_TONE_PARAM,
|
|
bytes([civ.SUB_RPT_TONE])).data[1:])
|
|
|
|
def set_tone_on(self, on):
|
|
return self._command(civ.CMD_TONE_SWITCH,
|
|
bytes([civ.SUB_TONE_ENC, 0x01 if on else 0x00]))
|
|
|
|
def tune_repeater(self, rec):
|
|
"""Apply a normalized record: freq -> duplex/offset -> tone.
|
|
Returns (steps, errors): each step True/False; errors keyed by step."""
|
|
steps, errors = {}, {}
|
|
|
|
def attempt(name, fn):
|
|
try:
|
|
fn(); steps[name] = True
|
|
except (CIVError, ValueError, OSError) as e:
|
|
steps[name] = False; errors[name] = str(e)
|
|
|
|
attempt("freq", lambda: self.set_frequency(int(round(rec["output_mhz"] * 1e6))))
|
|
duplex = rec.get("duplex", "simplex")
|
|
attempt("duplex", lambda: self.set_duplex(duplex))
|
|
if duplex != "simplex":
|
|
attempt("offset", lambda: self.set_offset(int(round(rec.get("offset_mhz", 0) * 1e6))))
|
|
if rec.get("tone_mode") == "ctcss" and rec.get("tone_hz"):
|
|
attempt("tone_freq", lambda: self.set_tone(rec["tone_hz"]))
|
|
attempt("tone_on", lambda: self.set_tone_on(True))
|
|
return steps, errors
|