#!/usr/bin/env python3 """Live receive-only TCI acceptance test. Uses only Python's standard library. The test never sends START/STOP, TRX with a value, TUNE with a value, TX audio, CW text, or any other command capable of keying the transmitter. """ import argparse import math import os import select import socket import struct import sys import time HDR = struct.Struct("<16I") STREAM_NAMES = {0: "IQ", 1: "RX_AUDIO", 2: "TX_AUDIO", 3: "TX_CHRONO", 4: "LINEOUT"} SAMPLE_BYTES = {0: 2, 1: 3, 2: 4, 3: 4} def split_commands(text): return [part.strip() + ";" for part in text.split(";") if part.strip()] def parse_command(command): body = command.rstrip(";") if ":" not in body: return body.lower(), [] name, args = body.split(":", 1) return name.lower(), args.split(",") class WS: def __init__(self, host, port): self.sock = socket.create_connection((host, port), timeout=4) self.sock.settimeout(None) key = "dGhlIHNhbXBsZSBub25jZQ==" req = (f"GET / HTTP/1.1\r\nHost: {host}:{port}\r\n" "Upgrade: websocket\r\nConnection: Upgrade\r\n" f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n") self.sock.sendall(req.encode("ascii")) raw = b"" while b"\r\n\r\n" not in raw: raw += self.sock.recv(4096) head, self.buf = raw.split(b"\r\n\r\n", 1) if b" 101 " not in head.split(b"\r\n", 1)[0]: raise RuntimeError(head.decode("latin1", "replace")) def close(self): # Finish the WebSocket protocol explicitly. A bare TCP EOF is also # legal, but the server's EOF path is tested separately and must not # make every acceptance run consume a leaked client slot. try: self.send_frame(8, b"") ready, _, _ = select.select([self.sock], [], [], 0.25) if ready: self.sock.recv(4096) except OSError: pass try: self.sock.shutdown(socket.SHUT_RDWR) except OSError: pass self.sock.close() def send_frame(self, opcode, payload=b""): mask = os.urandom(4) n = len(payload) if n <= 125: head = bytes((0x80 | opcode, 0x80 | n)) elif n <= 65535: head = bytes((0x80 | opcode, 0xFE)) + struct.pack("!H", n) else: head = bytes((0x80 | opcode, 0xFF)) + struct.pack("!Q", n) masked = bytes(v ^ mask[i & 3] for i, v in enumerate(payload)) self.sock.sendall(head + mask + masked) def send_text(self, command): self.send_frame(1, command.encode("utf-8")) def _need(self, n, deadline): while len(self.buf) < n: left = deadline - time.monotonic() if left <= 0: return False ready, _, _ = select.select([self.sock], [], [], left) if not ready: return False block = self.sock.recv(65536) if not block: raise ConnectionError("TCI connection closed") self.buf += block return True def recv_frame(self, timeout=1.0): deadline = time.monotonic() + timeout if not self._need(2, deadline): return None b0, b1 = self.buf[0], self.buf[1] pos, n = 2, b1 & 0x7f if n == 126: if not self._need(4, deadline): return None n, pos = struct.unpack("!H", self.buf[2:4])[0], 4 elif n == 127: if not self._need(10, deadline): return None n, pos = struct.unpack("!Q", self.buf[2:10])[0], 10 if not self._need(pos + n, deadline): return None payload = self.buf[pos:pos + n] self.buf = self.buf[pos + n:] opcode = b0 & 0x0f if opcode == 9: self.send_frame(10, payload) return self.recv_frame(max(0.0, deadline - time.monotonic())) return opcode, payload class LiveTest: def __init__(self, ws, seconds): self.ws = ws self.seconds = seconds self.passed = 0 self.failed = 0 self.warned = 0 self.text = [] self.binary = [] self.initial = [] def ok(self, label, condition, detail=""): if condition: self.passed += 1 print(f" ok {label}" + (f" — {detail}" if detail else "")) else: self.failed += 1 print(f" FAIL {label}" + (f" — {detail}" if detail else "")) def warn(self, label, detail=""): self.warned += 1 print(f" WARN {label}" + (f" — {detail}" if detail else "")) def pump(self, seconds): deadline = time.monotonic() + seconds while time.monotonic() < deadline: frame = self.ws.recv_frame(min(0.2, deadline - time.monotonic())) if frame is None: continue opcode, payload = frame if opcode == 1: self.text.extend(split_commands(payload.decode("utf-8", "replace"))) elif opcode == 2: self.binary.append(payload) def wait_text(self, name, seconds=1.5): deadline = time.monotonic() + seconds while time.monotonic() < deadline: for i, command in enumerate(self.text): got, args = parse_command(command) if got == name: self.text.pop(i) return command, args self.pump(min(0.1, deadline - time.monotonic())) return None, [] def init(self): deadline = time.monotonic() + 5 while time.monotonic() < deadline: self.pump(0.2) ready = next((x for x in self.text if parse_command(x)[0] == "ready"), None) if ready: self.initial = list(self.text) self.text.clear() return raise RuntimeError("READY not received") def initial_by_name(self, name): return [parse_command(x)[1] for x in self.initial if parse_command(x)[0] == name] def initial_checks(self): names = [parse_command(x)[0] for x in self.initial] required = { "protocol", "device", "receive_only", "trx_count", "channel_count", "vfo_limits", "if_limits", "modulations_list", "dds", "vfo", "if", "modulation", "rx_filter_band", "agc_mode", "tx_enable", "trx", "tune", "drive", "volume", "mute", "iq_samplerate", "audio_samplerate", "tx_frequency", "app_focus", "ready", } missing = sorted(required.difference(names)) self.ok("initial state contains required commands", not missing, "missing=" + ",".join(missing) if missing else f"commands={len(names)}") self.ok("READY is last initialization command", bool(names) and names[-1] == "ready", names[-1] if names else "empty") def query(self, command, response, echo_same=False): self.text.clear() self.ws.send_text(command) got, args = self.wait_text(response) self.ok(command.rstrip(";"), got is not None, got or "no response") if echo_same and got is not None: self.text.clear() self.ws.send_text(got) echoed, _ = self.wait_text(response) self.ok("SET same " + command.rstrip(";"), echoed is not None, echoed or "no confirmation") return args def command_checks(self, receivers, channels): print("Commands: receive-only queries") for rx in receivers: for name in ("dds", "modulation", "rx_filter_band", "agc_mode", "agc_gain", "lock", "sql_enable", "sql_level", "rx_mute", "rx_nr_enable", "rx_nb_enable", "rx_anf_enable", "rx_nb_param", "rx_bin_enable", "rx_anc_enable", "rx_apf_enable", "rx_dse_enable", "rx_nf_enable", "rit_enable", "rit_offset", "xit_enable", "xit_offset"): self.query(f"{name}:{rx};", name, echo_same=True) for ch in channels.get(rx, []): for name in ("vfo", "if", "rx_volume", "rx_balance"): self.query(f"{name}:{rx},{ch};", name, echo_same=True) # Stub today, but its wire contract still has to be valid. self.query(f"rx_channel_enable:{rx},1;", "rx_channel_enable", echo_same=True) for command, response in (("trx:0;", "trx"), ("tune:0;", "tune"), ("drive:0;", "drive"), ("tune_drive:0;", "tune_drive"), ("split_enable:0;", "split_enable"), ("volume;", "volume"), ("mute;", "mute"), ("mon_volume;", "mon_volume"), ("mon_enable;", "mon_enable"), ("cw_macros_speed;", "cw_macros_speed"), ("cw_macros_delay;", "cw_macros_delay"), ("digl_offset;", "digl_offset"), ("digu_offset;", "digu_offset")): self.query(command, response, echo_same=response not in ("trx", "tune")) # Exercise safe one-way commands without changing persistent radio state. self.ws.send_text("rx_sensors_enable:true,100;") got, _ = self.wait_text("rx_sensors", 2) self.ok("RX_SENSORS_ENABLE", got is not None, got or "no sensor report") self.ws.send_text("rx_sensors_enable:false;") self.ws.send_text("tx_sensors_enable:true,100;") got, _ = self.wait_text("tx_sensors", 2) self.ok("TX_SENSORS_ENABLE while RX", got is not None, got or "no sensor report") self.ws.send_text("tx_sensors_enable:false;") self.query("cw_keyer_speed;", "cw_macros_speed") self.ws.send_text("spot:ZZ0TCITEST,usb,14074000,4294967295,TCI RX test;") self.ws.send_text("spot_delete:ZZ0TCITEST;") self.ok("SPOT/SPOT_DELETE", True, "temporary marker removed") # Negative TX safety guard: malformed values must not key anything. self.ws.send_text("trx:0,not-a-bool,tci;") _, trx = self.wait_text("trx") self.ok("TRX malformed value rejected", len(trx) >= 2 and trx[1].lower() == "false", ",".join(trx)) self.ws.send_text("tune:0,not-a-bool;") _, tune = self.wait_text("tune") self.ok("TUNE malformed value rejected", len(tune) >= 2 and tune[1].lower() == "false", ",".join(tune)) def visible_change(self, label, response, changed, restored, dwell): """Apply one RX-only change, leave it visible, then restore it.""" self.text.clear() self.ws.send_text(changed) got, args = self.wait_text(response, 2) self.ok(label + " changed", got is not None, got or "no confirmation") time.sleep(dwell) self.text.clear() self.ws.send_text(restored) got, args = self.wait_text(response, 2) self.ok(label + " restored", got is not None, got or "no confirmation") time.sleep(0.3) def visible_checks(self, receivers, channels, dwell): print(f"Visible UI sweep: dwell={dwell:.1f}s; every value is restored") for rx in receivers: dds = self.query(f"dds:{rx};", "dds") if len(dds) >= 2: old = int(float(dds[1])) self.visible_change(f"DDS rx={rx}", "dds", f"dds:{rx},{old + 5000};", f"dds:{rx},{old};", dwell) mode = self.query(f"modulation:{rx};", "modulation") filt = self.query(f"rx_filter_band:{rx};", "rx_filter_band") if len(mode) >= 2: old_mode = mode[1].lower() alternate = {"nfm": "am", "digu": "usb", "digl": "lsb", "fmraw": "nfm", "dmr": "nfm"}.get(old_mode, "am") if alternate == old_mode: alternate = "usb" self.visible_change(f"MODULATION rx={rx}", "modulation", f"modulation:{rx},{alternate};", f"modulation:{rx},{old_mode};", dwell) if len(filt) >= 3: lo, hi = int(filt[1]), int(filt[2]) if hi - lo > 400: self.visible_change(f"FILTER rx={rx}", "rx_filter_band", f"rx_filter_band:{rx},{lo + 100},{hi - 100};", f"rx_filter_band:{rx},{lo},{hi};", dwell) agc = self.query(f"agc_mode:{rx};", "agc_mode") if len(agc) >= 2: old_agc = agc[1].lower() new_agc = "fast" if old_agc != "fast" else "normal" self.visible_change(f"AGC rx={rx}", "agc_mode", f"agc_mode:{rx},{new_agc};", f"agc_mode:{rx},{old_agc};", dwell) nr = self.query(f"rx_nr_enable:{rx};", "rx_nr_enable") if len(nr) >= 2: old_nr = nr[1].lower() new_nr = "false" if old_nr == "true" else "true" self.visible_change(f"NR rx={rx}", "rx_nr_enable", f"rx_nr_enable:{rx},{new_nr};", f"rx_nr_enable:{rx},{old_nr};", dwell) sql = self.query(f"sql_enable:{rx};", "sql_enable") if len(sql) >= 2: old_sql = sql[1].lower() new_sql = "false" if old_sql == "true" else "true" self.visible_change(f"SQL rx={rx}", "sql_enable", f"sql_enable:{rx},{new_sql};", f"sql_enable:{rx},{old_sql};", dwell) for ch in channels.get(rx, []): vfo = self.query(f"vfo:{rx},{ch};", "vfo") if len(vfo) >= 3: old_vfo = int(float(vfo[2])) self.visible_change(f"VFO rx={rx} ch={ch}", "vfo", f"vfo:{rx},{ch},{old_vfo + 2000};", f"vfo:{rx},{ch},{old_vfo};", dwell) vol = self.query(f"rx_volume:{rx},{ch};", "rx_volume") if len(vol) >= 3: old_vol = int(float(vol[2])) new_vol = old_vol - 12 if old_vol > -49 else old_vol + 12 new_vol = max(-60, min(0, new_vol)) self.visible_change(f"VOLUME rx={rx} ch={ch}", "rx_volume", f"rx_volume:{rx},{ch},{new_vol};", f"rx_volume:{rx},{ch},{old_vol};", dwell) def visible_vfo_b(self, channels, dwell): """Move hardware VFO-B; this is not a UI slice on the main pan.""" rx, ch = 0, 1 self.ok("TCI VFO-B channel is present", ch in channels.get(rx, []), str(channels)) if ch not in channels.get(rx, []): return print(f"VFO-B only: +15 kHz for {dwell:.1f}s, then restore") vfo = self.query(f"vfo:{rx},{ch};", "vfo") if len(vfo) >= 3: old_vfo = int(float(vfo[2])) self.visible_change("VFO-B", "vfo", f"vfo:{rx},{ch},{old_vfo + 15000};", f"vfo:{rx},{ch},{old_vfo};", dwell) def sample_stats(self, payload, fmt, length): data = payload[HDR.size:] count = min(length, 4096) vals = [] if fmt == 3: vals = struct.unpack_from(f"<{count}f", data) elif fmt == 0: vals = [v / 32768.0 for v in struct.unpack_from(f"<{count}h", data)] elif fmt == 1: vals = [] for i in range(min(count, len(data) // 3)): v = data[i * 3] | (data[i * 3 + 1] << 8) | (data[i * 3 + 2] << 16) if v & 0x800000: v -= 1 << 24 vals.append(v / 8388608.0) elif fmt == 2: vals = [v / 2147483648.0 for v in struct.unpack_from(f"<{count}i", data)] finite = [v for v in vals if math.isfinite(v)] if not finite: return 0.0, 0.0, False rms = math.sqrt(sum(v * v for v in finite) / len(finite)) return rms, max(abs(v) for v in finite), len(finite) == len(vals) def stream(self, rx, start, stop, wanted_type, requested_length=None, expected_rate=None): self.binary.clear() self.text.clear() self.ws.send_text(f"{start}:{rx};") self.pump(self.seconds) self.ws.send_text(f"{stop}:{rx};") self.pump(0.25) errors = [x for x in self.text if parse_command(x)[0] == "tci_error"] blocks = [] for payload in self.binary: if len(payload) < HDR.size: continue h = HDR.unpack_from(payload) if h[6] == wanted_type and h[0] == rx: blocks.append((payload, h)) label = f"{STREAM_NAMES[wanted_type]} rx={rx}" self.ok(label + " blocks", bool(blocks), errors[0] if errors else f"blocks={len(blocks)}") if not blocks: return bad = 0 rms_values = [] rates = set() for payload, h in blocks: receiver, rate, fmt, codec, crc, length, kind, chans = h[:8] rates.add(rate) # IQ length counts real values across I/Q; audio length is samples # per channel, so its payload additionally includes every channel. payload_samples = length if kind == 0 else length * chans expected = HDR.size + payload_samples * SAMPLE_BYTES.get(fmt, 0) if codec != 0 or crc != 0 or chans not in (1, 2) or expected != len(payload): bad += 1 if requested_length is not None and length != requested_length: bad += 1 rms, peak, finite = self.sample_stats(payload, fmt, length) if not finite: bad += 1 rms_values.append(rms) if expected_rate is not None and rates != {expected_rate}: bad += 1 self.ok(label + " headers", bad == 0, f"blocks={len(blocks)}, bad={bad}, rates={sorted(rates)}") rms = max(rms_values) if rms_values else 0.0 if rms > 1e-7: self.ok(label + " signal", True, f"max RMS={rms:.6g}") else: self.warn(label + " signal is silent", "check mute/squelch and tuned signal") def stream_checks(self, receivers): print("Streams: IQ, demod audio and line-out") self.ws.send_text("iq_samplerate:48000;") self.wait_text("iq_samplerate") self.ws.send_text("audio_samplerate:12000;") self.wait_text("audio_samplerate") self.ws.send_text("audio_stream_channels:2;") self.ws.send_text("audio_stream_sample_type:float32;") self.ws.send_text("audio_stream_samples:512;") self.pump(0.2) for rx in receivers: self.stream(rx, "iq_start", "iq_stop", 0) self.stream(rx, "audio_start", "audio_stop", 1, 512) self.stream(rx, "line_out_start", "line_out_stop", 4, 512) def format_matrix(self, receivers): """Exercise every negotiated audio representation on one live RX.""" rx = receivers[1] if len(receivers) > 1 else receivers[0] old_seconds = self.seconds self.seconds = 0.7 print(f"Audio format/rate matrix on rx={rx}") try: self.ws.send_text("audio_samplerate:12000;") self.wait_text("audio_samplerate") self.ws.send_text("audio_stream_samples:128;") for channels in (1, 2): self.ws.send_text(f"audio_stream_channels:{channels};") for sample_type in ("int16", "int24", "int32", "float32"): self.ws.send_text(f"audio_stream_sample_type:{sample_type};") self.pump(0.05) print(f" config channels={channels}, type={sample_type}") self.stream(rx, "audio_start", "audio_stop", 1, 128) self.stream(rx, "line_out_start", "line_out_stop", 4, 128) self.ws.send_text("audio_stream_channels:2;") self.ws.send_text("audio_stream_sample_type:float32;") for rate in (8000, 12000, 24000, 48000): self.ws.send_text(f"audio_samplerate:{rate};") self.wait_text("audio_samplerate") self.ws.send_text("audio_stream_samples:128;") print(f" config rate={rate}") self.stream(rx, "audio_start", "audio_stop", 1, 128) finally: self.seconds = old_seconds def iq_rate_matrix(self, receivers): """Compare negotiated IQ rate with every live block header.""" rx = receivers[0] old_seconds = self.seconds self.seconds = 0.8 print(f"IQ rate matrix on rx={rx}") try: for requested in (48000, 96000, 192000, 384000): self.text.clear() self.ws.send_text(f"iq_samplerate:{requested};") got, args = self.wait_text("iq_samplerate") effective = int(args[0]) if got and args else 0 self.ok(f"IQ rate request {requested}", effective > 0, got or "no response") if effective > 0: self.stream(rx, "iq_start", "iq_stop", 0, expected_rate=effective) finally: self.ws.send_text("iq_samplerate:48000;") self.wait_text("iq_samplerate") self.seconds = old_seconds def main(): ap = argparse.ArgumentParser() ap.add_argument("--host", default="127.0.0.1") ap.add_argument("--port", type=int, default=40001) ap.add_argument("--seconds", type=float, default=2.0, help="capture time for each stream and receiver") ap.add_argument("--commands-only", action="store_true", help="skip IQ/audio capture") ap.add_argument("--streams-only", action="store_true", help="skip command matrix and capture only IQ/audio") ap.add_argument("--format-matrix", action="store_true", help="test every audio sample type/channel count and rate") ap.add_argument("--iq-rate-matrix", action="store_true", help="test negotiated IQ rates against binary headers") ap.add_argument("--visible", action="store_true", help="visibly change RX parameters and restore every value") ap.add_argument("--vfo-b-visible", action="store_true", help="move hardware VFO-B by 15 kHz and restore it") ap.add_argument("--dwell", type=float, default=1.5, help="seconds to leave each visible test value active") ap.add_argument("--expect-channels", type=int, default=0, help="require this exact total TCI channel count") args = ap.parse_args() print(f"Connecting to ws://{args.host}:{args.port} (RX-only safety mode)") ws = WS(args.host, args.port) test = LiveTest(ws, args.seconds) try: test.init() test.initial_checks() protocol = test.initial_by_name("protocol") test.ok("READY/init", bool(protocol), str(protocol)) trx = test.initial_by_name("trx") if trx and len(trx[-1]) >= 2 and trx[-1][1].lower() == "true": raise RuntimeError("radio is already transmitting; aborting RX-only test") channels = {} for row in test.initial_by_name("vfo"): if len(row) >= 3: channels.setdefault(int(row[0]), set()).add(int(row[1])) channels = {rx: sorted(value) for rx, value in channels.items()} receivers = sorted({int(row[0]) for row in test.initial_by_name("dds") if row}) print("Discovered receivers/channels:", receivers, channels) test.ok("live receivers found", bool(receivers), str(receivers)) slice_count = sum(map(len, channels.values())) if args.expect_channels > 0: test.ok(f"expected {args.expect_channels} TCI channels visible", slice_count == args.expect_channels, str(channels)) else: test.ok("at least one slice/channel visible", slice_count > 0, str(channels)) if args.vfo_b_visible: test.visible_vfo_b(channels, args.dwell) elif args.iq_rate_matrix: test.iq_rate_matrix(receivers) elif args.format_matrix: test.format_matrix(receivers) elif args.visible: test.visible_checks(receivers, channels, args.dwell) elif not args.streams_only: test.command_checks(receivers, channels) if (not args.commands_only and not args.visible and not args.vfo_b_visible and not args.format_matrix and not args.iq_rate_matrix): test.stream_checks(receivers) print("Skipped by RF-safety policy: START, STOP, TRX/TUNE setters, TX audio, " "CW text/keying, SPOT_CLEAR and SET_IN_FOCUS") finally: # Idempotent receive-side cleanup only. Never touch TRX/TUNE. for rx in range(8): for name in ("iq_stop", "audio_stop", "line_out_stop"): try: ws.send_text(f"{name}:{rx};") except OSError: pass try: ws.send_text("rx_sensors_enable:false;") except OSError: pass ws.close() print(f"\nResult: {test.passed + test.failed} checks, " f"failed={test.failed}, warnings={test.warned}") return 1 if test.failed else 0 if __name__ == "__main__": sys.exit(main())