diff options
| author | info@mode42.com <info@mode42.com> | 2026-08-07 18:25:13 +0000 |
|---|---|---|
| committer | info@mode42.com <info@mode42.com> | 2026-08-07 18:25:13 +0000 |
| commit | 04d965d67a7264a1c7c211494aebda1953df7603 (patch) | |
| tree | 0ebd700a6e219f84a26a656f4bee8778bc75da7b /stacks/tncs/tnc2c-autotest.py | |
Initial push
Diffstat (limited to 'stacks/tncs/tnc2c-autotest.py')
| -rwxr-xr-x | stacks/tncs/tnc2c-autotest.py | 925 |
1 files changed, 925 insertions, 0 deletions
diff --git a/stacks/tncs/tnc2c-autotest.py b/stacks/tncs/tnc2c-autotest.py new file mode 100755 index 0000000..4470695 --- /dev/null +++ b/stacks/tncs/tnc2c-autotest.py @@ -0,0 +1,925 @@ +#!/usr/bin/env python3 +""" +tnc2c-autotest - safe, exhaustive Landolt TNC2C serial discovery. + +Tests baud/parity combinations, modem lines, host vs echo vs garbage, +firmware banner, and (optionally) KISS without RF frames. + +Does NOT send KISS UI frames or key PTT unless --tx is passed. + +Usage: + ./tnc2c-autotest.sh + ./tnc2c-autotest.sh /dev/ttyS4 + ./tnc2c-autotest.sh --quick # likely bauds only (~45s) + ./tnc2c-autotest.sh --write-env # update tnc2c-serial.env if confident + ./tnc2c-autotest.sh --json out.json + ./tnc2c-autotest.sh --host-check # after power reset: 19200 8N1 only (~6s) + ./tnc2c-autotest.sh --host-check --write-env +""" + +from __future__ import annotations + +import argparse +import json +import os +import struct +import subprocess +import sys +import time +import termios +import fcntl +from dataclasses import asdict, dataclass, field +from typing import Optional + +BAUD_MAP: dict[int, int] = { + 300: termios.B300, + 600: termios.B600, + 1200: termios.B1200, + 2400: termios.B2400, + 4800: termios.B4800, + 9600: termios.B9600, + 19200: termios.B19200, +} + +BAUDS_FULL = (300, 600, 1200, 2400, 4800, 9600, 19200) +BAUDS_QUICK = (19200, 2400, 4800, 9600) +HOST_CHECK_LINES = ("8N1",) + +LINE_FORMATS = ( + ("8N1", 8, "none", 1), + ("7E1", 7, "even", 1), + ("8E1", 8, "even", 1), +) + +LINE_PREFERENCE = {"8N1": 3, "7E1": 2, "8E1": 1} + +FIRMWARE_MARKERS = ( + b"TheFirmware", + b"NORD", + b"Version 2.7", + b"Checksum", + b"Copyright", + b"DAMA", + b"SMACK", +) + +HOST_MARKERS = ( + b"cmd:", + b"CMD:", + b"help", + b"HELP", + b"MYCALL", + b"KISS was", + b"KISS is", + b"KISS now", + b"Invalid", + b"Unknown", +) + + +@dataclass +class ProfileResult: + name: str + baud: int + line: str + rts: bool = False + cts: bool = False + dsr: bool = False + dcd: bool = False + passive_bytes: int = 0 + boot_bytes: int = 0 + info_reply: bytes = b"" + help_reply: bytes = b"" + kiss_reply: bytes = b"" + combined_reply: bytes = b"" + printable_ratio: float = 0.0 + host_score: int = 0 + echo_only: bool = False + garbage: bool = False + has_cmd: bool = False + has_firmware: bool = False + notes: list[str] = field(default_factory=list) + + def classify(self) -> str: + if ( + self.has_firmware + or self.has_cmd + or has_firmware_banner(self.info_reply) + ): + return "HOST" + if self.garbage: + return "GARBAGE" + if self.echo_only or ( + is_echo(b"INFO\r", self.info_reply) and is_echo(b"HELP\r", self.help_reply) + ): + return "ECHO" + if self.combined_reply: + return "PARTIAL" + return "SILENT" + + +def has_firmware_banner(data: bytes) -> bool: + if not data: + return False + lower = data.lower() + return any(m.lower() in lower for m in FIRMWARE_MARKERS) + + +def order_bauds(bauds: tuple[int, ...]) -> tuple[int, ...]: + """Test 19200 first so wrong bauds don't disturb a working host session.""" + rest = tuple(b for b in bauds if b != 19200) + return (19200, *rest) if 19200 in bauds else bauds + + +def line_format(line: str) -> tuple[int, str, int]: + for name, data_bits, parity, stop_bits in LINE_FORMATS: + if name == line: + return data_bits, parity, stop_bits + raise ValueError(f"unknown line format {line}") + + +def timings_for(baud: int, gentle: bool) -> tuple[float, float, float]: + """Returns listen_s, cmd_wait, info_read.""" + if gentle or baud == 19200: + return (2.0 if gentle else 1.5, 0.9 if gentle else 0.7, 3.0 if gentle else 3.0) + return (1.2, 0.55, 1.6) + + +def finalize_profile( + res: ProfileResult, + info_cmd: bytes, + info: bytes, + help_cmd: bytes, + help: bytes, + kiss_on: bytes, + combined: bytes, +) -> None: + res.info_reply = info + res.help_reply = help + res.kiss_reply = kiss_on + res.combined_reply = combined + res.printable_ratio = printable_ratio(combined) + + s1, e1, c1, f1 = score_replies(info_cmd, info) + s2, e2, c2, f2 = score_replies(help_cmd, help) + s3, _, c3, f3 = score_replies(b"kiss on\r", kiss_on) + res.host_score = max(s1, s2, s3) + res.has_cmd = c1 or c2 or c3 + res.has_firmware = ( + f1 or f2 or f3 + or has_firmware_banner(info) + or has_firmware_banner(help) + ) + if res.has_firmware: + res.host_score = max(res.host_score, 55) + res.echo_only = False + elif is_echo(info_cmd, info) and (not help or is_echo(help_cmd, help)): + res.echo_only = True + else: + res.echo_only = (e1 and e2) and res.host_score < 15 + res.garbage = ( + bool(combined) + and res.printable_ratio < 0.55 + and not res.has_firmware + and res.host_score < 20 + ) + if res.passive_bytes and res.has_firmware: + res.notes.append("boot_banner") + if kiss_on and not is_echo(b"kiss on\r", kiss_on): + res.notes.append("kiss_ack") + + +def rank_key(res: ProfileResult) -> tuple: + """Higher is better. Prefer real host, then 8N1/7E1 over 8E1.""" + return ( + int(res.has_firmware), + int(res.has_cmd), + int(not res.echo_only), + res.host_score, + res.printable_ratio, + LINE_PREFERENCE.get(res.line, 0), + len(res.combined_reply), + ) + + +def load_env(path: str) -> dict[str, str]: + env: dict[str, str] = {} + if not os.path.isfile(path): + return env + with open(path, encoding="utf-8") as f: + for raw in f: + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + env[k.strip()] = v.strip() + return env + + +def printable_ratio(data: bytes) -> float: + if not data: + return 0.0 + good = sum(1 for b in data if b in (9, 10, 13) or 32 <= b < 127) + return good / len(data) + + +def open_serial( + dev: str, + baud: int, + data_bits: int, + parity: str, + stop_bits: int, + rts: bool = True, + dtr: bool = True, +) -> int: + if baud not in BAUD_MAP: + raise ValueError(f"unsupported baud {baud}") + fd = os.open(dev, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK) + t = termios.tcgetattr(fd) + t[0] = t[1] = 0 + cflag = termios.CLOCAL | termios.CREAD + if data_bits == 7: + cflag |= termios.CS7 + else: + cflag |= termios.CS8 + if parity == "even": + cflag |= termios.PARENB + elif parity == "odd": + cflag |= termios.PARENB | termios.PARODD + t[2] = cflag + speed = BAUD_MAP[baud] + t[3] = t[4] = t[5] = speed + t[6][termios.VMIN] = 0 + t[6][termios.VTIME] = 5 + termios.tcsetattr(fd, termios.TCSANOW, t) + termios.tcflush(fd, termios.TCIOFLUSH) + flags = struct.unpack("I", fcntl.ioctl(fd, 0x5415, struct.pack("I", 0)))[0] + if rts: + flags |= 0x004 + else: + flags &= ~0x004 + if dtr: + flags |= 0x002 + else: + flags &= ~0x002 + fcntl.ioctl(fd, 0x5416, struct.pack("I", flags)) + return fd + + +def modem_lines(fd: int) -> tuple[bool, bool, bool, bool]: + flags = struct.unpack("I", fcntl.ioctl(fd, 0x5415, struct.pack("I", 0)))[0] + return ( + bool(flags & 0x004), + bool(flags & 0x008), + bool(flags & 0x100), + bool(flags & 0x080), + ) + + +def read_for(fd: int, seconds: float) -> bytes: + end = time.time() + seconds + chunks: list[bytes] = [] + while time.time() < end: + try: + b = os.read(fd, 4096) + if b: + chunks.append(b) + except BlockingIOError: + time.sleep(0.02) + return b"".join(chunks) + + +def write_flush(fd: int, data: bytes) -> None: + os.write(fd, data) + termios.tcdrain(fd) + + +def is_echo(cmd: bytes, reply: bytes) -> bool: + if not reply: + return False + c = cmd.strip(b"\r\n") + r = reply.strip() + return r in (c, c + b"\r", c + b"\n", c + b"\r\n") + + +def score_replies(cmd: bytes, *chunks: bytes) -> tuple[int, bool, bool, bool]: + reply = b"".join(chunks) + if not reply: + return 0, False, False, False + + score = 0 + lower = reply.lower() + has_cmd = b"cmd:" in lower + has_fw = any(m.lower() in lower for m in FIRMWARE_MARKERS) + + if has_cmd: + score += 60 + if has_fw: + score += 50 + for m in HOST_MARKERS: + if m.lower() in lower: + score += 8 + if len(reply) > len(cmd) + 8 and not is_echo(cmd, reply): + score += 10 + if b"\n" in reply and len(reply) > 30: + score += 5 + + echo = is_echo(cmd, reply) and score < 20 + garbage = printable_ratio(reply) < 0.55 and len(reply) >= 4 + return score, echo, has_cmd, has_fw + + +def profile_name(baud: int, line: str) -> str: + return f"{baud}-{line}" + + +def test_profile( + dev: str, + baud: int, + line: str, + data_bits: int, + parity: str, + stop_bits: int, + gentle: bool = False, +) -> ProfileResult: + listen_s, cmd_wait, info_read = timings_for(baud, gentle) + res = ProfileResult(name=profile_name(baud, line), baud=baud, line=line) + fd = open_serial(dev, baud, data_bits, parity, stop_bits) + res.rts, res.cts, res.dsr, res.dcd = modem_lines(fd) + + passive = read_for(fd, listen_s) + res.passive_bytes = len(passive) + + if not gentle: + write_flush(fd, b"\x03\x1a") + time.sleep(0.15) + read_for(fd, 0.2) + + write_flush(fd, b"\r\r") + time.sleep(cmd_wait) + wake = read_for(fd, listen_s) + + write_flush(fd, b"kiss off\r") + time.sleep(cmd_wait) + kiss_off = read_for(fd, listen_s) + + info_cmd = b"INFO\r" + write_flush(fd, info_cmd) + time.sleep(cmd_wait) + info = read_for(fd, info_read) + + help_cmd = b"HELP\r" + write_flush(fd, help_cmd) + time.sleep(cmd_wait) + help = read_for(fd, listen_s + 0.3) + + write_flush(fd, b"kiss on\r") + time.sleep(cmd_wait) + kiss_on = read_for(fd, listen_s) + + write_flush(fd, b"kiss off\r") + time.sleep(0.2) + read_for(fd, 0.2) + + finalize_profile( + res, info_cmd, info, help_cmd, help, kiss_on, + passive + wake + kiss_off + info + help + kiss_on, + ) + os.close(fd) + return res + + +def test_host_check( + dev: str, + baud: int, + line: str, + dtr_cycle: bool = False, + no_kiss: bool = False, +) -> ProfileResult: + """Gentle single-profile check - DTR high, optional KISS-reset, kiss off, INFO.""" + data_bits, parity, stop_bits = line_format(line) + res = ProfileResult(name=profile_name(baud, line), baud=baud, line=line) + + prelude = b"" + if dtr_cycle: + boot = boot_listen(dev, baud, line, data_bits, parity, stop_bits) + res.boot_bytes = len(boot) + prelude = boot + if has_firmware_banner(boot): + res.has_firmware = True + res.notes.append("dtr_boot_banner") + + fd = open_serial(dev, baud, data_bits, parity, stop_bits) + res.rts, res.cts, res.dsr, res.dcd = modem_lines(fd) + + passive = read_for(fd, 1.0) + res.passive_bytes = len(passive) + prelude += passive + if has_firmware_banner(passive): + res.has_firmware = True + res.notes.append("passive_boot_banner") + + if not no_kiss: + write_flush(fd, b"\xc0\xff\xc0") + time.sleep(0.8) + prelude += read_for(fd, 0.8) + res.notes.append("kiss_reset") + + write_flush(fd, b"kiss off\r") + time.sleep(0.4) + kiss_off = read_for(fd, 0.8) + + info_cmd = b"INFO\r" + write_flush(fd, info_cmd) + time.sleep(0.3) + info = read_for(fd, 3.5) + + finalize_profile(res, info_cmd, info, b"HELP\r", b"", b"", prelude + kiss_off + info) + os.close(fd) + return res + + +def boot_listen(dev: str, baud: int, line: str, data_bits: int, parity: str, stop_bits: int) -> bytes: + fd = open_serial(dev, baud, data_bits, parity, stop_bits) + flags = struct.unpack("I", fcntl.ioctl(fd, 0x5415, struct.pack("I", 0)))[0] + flags &= ~0x002 + fcntl.ioctl(fd, 0x5416, struct.pack("I", flags)) + time.sleep(0.8) + flags |= 0x002 | 0x004 + fcntl.ioctl(fd, 0x5416, struct.pack("I", flags)) + data = read_for(fd, 4.0) + os.close(fd) + return data + + +def deep_test(dev: str, res: ProfileResult) -> ProfileResult: + line = res.line + data_bits, parity, stop_bits = line_format(line) + gentle = res.baud == 19200 + + fd = open_serial(dev, res.baud, data_bits, parity, stop_bits) + res.rts, res.cts, res.dsr, res.dcd = modem_lines(fd) + + write_flush(fd, b"kiss off\r") + time.sleep(0.6 if gentle else 0.4) + read_for(fd, 0.3) + + for _ in range(4): + write_flush(fd, b"\r") + time.sleep(0.35) + enters = read_for(fd, 1.5) + if b"cmd:" in enters.lower(): + res.has_cmd = True + res.host_score += 20 + res.notes.append("cmd_after_enter") + + write_flush(fd, b"MYCALL TEST-0\r") + time.sleep(0.6) + mycall = read_for(fd, 1.0) + if mycall and not is_echo(b"MYCALL TEST-0\r", mycall): + lower = mycall.lower() + if b"cmd:" in lower or has_firmware_banner(mycall): + res.host_score += 15 + res.notes.append("mycall_ack") + + if not res.has_firmware: + boot = boot_listen(dev, res.baud, line, data_bits, parity, stop_bits) + res.boot_bytes = len(boot) + if has_firmware_banner(boot): + res.has_firmware = True + res.host_score = max(res.host_score, 55) + res.echo_only = False + res.notes.append("dtr_boot_banner") + + os.close(fd) + return res + + +def port_holders(dev: str) -> list[str]: + holders: list[str] = [] + try: + out = subprocess.run( + ["fuser", "-v", dev], + capture_output=True, + text=True, + timeout=3, + ) + if out.returncode == 0 and out.stderr: + for line in out.stderr.splitlines(): + line = line.strip() + if line and not line.startswith("USER"): + holders.append(line) + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + return holders + + +def preflight(dev: str) -> list[str]: + issues: list[str] = [] + if os.system("pgrep -x hybbx >/dev/null 2>&1") == 0: + issues.append("hybbx is running - stop it first") + if not os.path.exists(dev): + issues.append(f"{dev} does not exist") + elif not os.access(dev, os.R_OK | os.W_OK): + issues.append(f"no access to {dev} (dialout group?)") + holders = port_holders(dev) + if holders: + issues.append(f"port in use: {', '.join(holders[:3])}") + return issues + + +def quartz_hint(baud: int) -> str: + hints = [] + term_98304 = {9600: "TERM Pos.1", 4800: "TERM Pos.2", 2400: "TERM Pos.3", 1200: "TERM Pos.4"} + term_49152 = {4800: "TERM Pos.1", 2400: "TERM Pos.2", 1200: "TERM Pos.3", 600: "TERM Pos.4"} + if baud in term_98304: + hints.append(f"with 9.8304 MHz crystal: {term_98304[baud]}") + if baud in term_49152: + hints.append(f"with 4.9152 MHz crystal: {term_49152[baud]}") + if baud == 19200: + hints.append("19200 is in the manual only with RADIO bridge (2.4576 MHz Pos.5), not TERM") + hints.append("-> possible mod, extended firmware, or different crystal") + return "; ".join(hints) if hints else "-" + + +def suggest_env(best: ProfileResult) -> dict[str, str]: + line = best.line.lower() + return { + "TNC2C_DEV": "", # filled by caller + "TNC2C_BAUD": str(best.baud), + "TNC2C_LINE": line, + "TNC2C_RADIO_BAUD": "2400", + "TNC2C_MODEM": "tcm3105", + "HYBBX_SERIAL_LINE": line, + } + + +def write_env_file(path: str, dev: str, best: ProfileResult) -> None: + vals = suggest_env(best) + vals["TNC2C_DEV"] = dev + lines = [ + f"# Auto-generated by tnc2c-autotest {time.strftime('%Y-%m-%d %H:%M:%S')}", + f"# Best profile: {best.name} ({best.classify()}, score={best.host_score})", + ] + for k, v in vals.items(): + lines.append(f"{k}={v}") + lines.append("") + with open(path, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + + +# --- optional TX (from health check) --- + +def kiss_escape(data: bytes) -> bytes: + out = bytearray() + for b in data: + if b == 0xC0: + out.extend((0xDB, 0xDC)) + elif b == 0xDB: + out.extend((0xDB, 0xDD)) + else: + out.append(b) + return bytes(out) + + +def ax25_addr(call: str, ssid: int, last: bool) -> bytes: + call = call.upper().ljust(6)[:6] + raw = bytes((ord(c) << 1) for c in call) + ssid_b = ((ssid & 0x0F) << 1) | (0x01 if last else 0x00) + return raw + bytes([ssid_b]) + + +def ax25_crc(data: bytes) -> int: + crc = 0xFFFF + for b in data: + crc ^= b + for _ in range(8): + crc = (crc >> 1) ^ 0x8408 if crc & 1 else crc >> 1 + return crc ^ 0xFFFF + + +def ax25_ui(src: str, dst: str, info: bytes) -> bytes: + body = ax25_addr(dst, 0, False) + ax25_addr(src, 0, True) + b"\x03\xF0" + info + crc = ax25_crc(body) + return body + bytes((crc & 0xFF, crc >> 8)) + + +def run_tx_test(dev: str, best: ProfileResult) -> dict: + fmt = next(f for f in LINE_FORMATS if f[0] == best.line) + _, data_bits, parity, stop_bits = fmt + fd = open_serial(dev, best.baud, data_bits, parity, stop_bits) + write_flush(fd, b"kiss off\r") + time.sleep(0.4) + read_for(fd, 0.3) + write_flush(fd, b"kiss on\r") + time.sleep(0.5) + read_for(fd, 0.3) + payload = ax25_ui("TEST-0", "CQ", b"AUTOTEST") + pkt = b"\xC0" + bytes([0]) + kiss_escape(payload) + b"\xC0" + write_flush(fd, pkt) + time.sleep(3.0) + rx = read_for(fd, 1.0) + write_flush(fd, b"kiss off\r") + os.close(fd) + return { + "sent": len(pkt), + "rx_len": len(rx), + "echoed": pkt in rx or payload in rx, + } + + +def preview(data: bytes, limit: int = 72) -> str: + if not data: + return "(empty)" + s = data[:limit].decode("ascii", errors="replace") + s = s.replace("\r", "\\r").replace("\n", "\\n") + if len(data) > limit: + s += "..." + return s + + +def print_header(title: str) -> None: + print(f"\n{'=' * 64}") + print(title) + print("=" * 64) + + +def print_result_summary( + best: ProfileResult, + dev: str, + ranked: list[ProfileResult], + root: str, + args: argparse.Namespace, + suggested: dict[str, str], + tx_result: Optional[dict], + t0: float, +) -> int: + host_profiles = [r for r in ranked if r.classify() == "HOST"] + echo_profiles = [r for r in ranked if r.classify() == "ECHO"] + garbage_profiles = [r for r in ranked if r.classify() == "GARBAGE"] + + print_header("Result") + print(f" Best profile: {best.name}") + print(f" Class: {best.classify()}") + print(f" Host score: {best.host_score}") + print(f" Modem: RTS={int(best.rts)} CTS={int(best.cts)} DSR={int(best.dsr)}") + print(f" INFO: {preview(best.info_reply, 120)}") + if best.has_firmware or has_firmware_banner(best.info_reply): + print(" Firmware banner: YES") + if best.has_cmd: + print(" cmd: prompt: YES") + print(f" Crystal/bridge: {quartz_hint(best.baud)}") + + if host_profiles: + print(f"\n HOST profiles ({len(host_profiles)}): {', '.join(r.name for r in host_profiles[:5])}") + if echo_profiles and not host_profiles: + print(f"\n ECHO only ({len(echo_profiles)}) - TNC mirrors commands, does not process them.") + print(" -> Power-reset the TNC, then: ./tnc2c-autotest.sh --host-check --write-env") + if garbage_profiles: + top_g = sorted(garbage_profiles, key=lambda r: r.printable_ratio, reverse=True)[:3] + print(f" GARBAGE profiles: {', '.join(r.name for r in top_g)} (wrong baud/parity)") + + print("\n Recommended settings:") + print(f" max25-terminal or tnc2c-host-reset @ {dev} -b {best.baud} (# {best.line})") + for k in ("TNC2C_BAUD", "TNC2C_LINE", "HYBBX_SERIAL_LINE"): + print(f" {k}={suggested[k]}") + + if args.write_env and best.classify() == "HOST": + env_path = os.path.join(root, "tnc2c-serial.env") + write_env_file(env_path, dev, best) + print(f"\n Written: {env_path}") + elif args.write_env: + print("\n --write-env skipped (no reliable HOST profile)") + + print_header("KISS-TX") + if not args.tx: + print(" Skipped (safe). Optional: --tx (PTT may key!)") + else: + print(" WARNING: sends AX.25 UI frame - check PTT LED!") + tx_result = run_tx_test(dev, best) + print(f" Sent {tx_result['sent']} B, RX {tx_result['rx_len']} B, Echo={tx_result['echoed']}") + + elapsed = time.time() - t0 + status = "OK" if best.classify() == "HOST" else ( + "DEGRADED" if best.classify() in ("ECHO", "PARTIAL") else "FAIL" + ) + + print_header("SUMMARY") + print(f" Status: {status}") + print(f" Device: {dev}") + print(f" Profile: {best.name} ({best.classify()})") + print(f" Duration: {elapsed:.0f}s") + + if args.json: + report = { + "device": dev, + "status": status, + "elapsed_s": round(elapsed, 1), + "best": asdict(best), + "ranking": [asdict(r) for r in ranked[:12]], + "suggested_env": suggested, + "tx": tx_result, + } + + def fix(obj): + if isinstance(obj, dict): + return {k: fix(v) for k, v in obj.items()} + if isinstance(obj, list): + return [fix(v) for v in obj] + if isinstance(obj, bytes): + return obj.decode("ascii", errors="replace") + return obj + + with open(args.json, "w", encoding="utf-8") as f: + json.dump(fix(report), f, indent=2, ensure_ascii=False) + print(f" JSON: {args.json}") + + return 0 if status == "OK" else (1 if status == "DEGRADED" else 2) + + +def run_host_check( + dev: str, + root: str, + env: dict[str, str], + args: argparse.Namespace, +) -> int: + t0 = time.time() + print_header("TNC2C HOST-CHECK (DTR high - no DTR drop, no baud sweep)") + print(f"Device: {dev}") + print(f"Started: {time.strftime('%Y-%m-%d %H:%M:%S')}") + if args.dtr_cycle: + print("Note: --dtr-cycle active (DTR drop - may trigger echo mode)") + + print_header("1) Preflight") + issues = preflight(dev) + if issues: + for i in issues: + print(f" FAIL: {i}") + print("\nAbort - clear port/background services and retry.") + return 2 + print(" OK: port free, hybbx inactive, access OK") + + kiss_step = "kiss off -> INFO" if args.no_kiss else "KISS-reset -> kiss off -> INFO" + print_header(f"2) Host-Check 19200-8N1 (passive -> {kiss_step})") + results: list[ProfileResult] = [] + for line in HOST_CHECK_LINES: + name = profile_name(19200, line) + if not args.quiet: + print(f" {name:12} ...", end="", flush=True) + try: + r = test_host_check( + dev, 19200, line, dtr_cycle=args.dtr_cycle, no_kiss=args.no_kiss + ) + results.append(r) + if not args.quiet: + print( + f" {r.classify():7} score={r.host_score:3} fw={int(r.has_firmware)} " + f"INFO={preview(r.info_reply, 50)!r}" + ) + except OSError as e: + if not args.quiet: + print(f" ERROR: {e}") + + if not results: + print(" No profiles tested.") + return 2 + + ranked = sorted(results, key=rank_key, reverse=True) + best = ranked[0] + suggested = suggest_env(best) + suggested["TNC2C_DEV"] = dev + return print_result_summary(best, dev, ranked, root, args, suggested, None, t0) + + +def main() -> int: + parser = argparse.ArgumentParser(description="TNC2C safe exhaustive autotest") + parser.add_argument("device", nargs="?", default=None) + parser.add_argument( + "--host-check", + action="store_true", + help="gentle 19200-8N1 only - after power reset (~10s)", + ) + parser.add_argument( + "--dtr-cycle", + action="store_true", + help="brief DTR drop (legacy; may trigger echo mode - not for host-check)", + ) + parser.add_argument( + "--no-kiss", + action="store_true", + help="host-check: skip KISS-reset frame", + ) + parser.add_argument("--quick", action="store_true", help="19200 first, then 2400/4800/9600") + parser.add_argument("--write-env", action="store_true", help="write tnc2c-serial.env if HOST found") + parser.add_argument("--json", metavar="FILE", help="write JSON report") + parser.add_argument("--tx", action="store_true", help="KISS TX test - keys PTT!") + parser.add_argument("-q", "--quiet", action="store_true") + args = parser.parse_args() + + root = os.path.dirname(os.path.abspath(__file__)) + env = load_env(os.path.join(root, "tnc2c-serial.env")) + dev = args.device or env.get("TNC2C_DEV", "/dev/ttyS4") + + if args.host_check: + return run_host_check(dev, root, env, args) + + bauds = order_bauds(BAUDS_QUICK if args.quick else BAUDS_FULL) + total = len(bauds) * len(LINE_FORMATS) + + t0 = time.time() + print_header("TNC2C AUTOTEST (safe - no PTT unless --tx)") + print(f"Device: {dev}") + print(f"Mode: {'quick' if args.quick else 'full'} ({total} profiles, 19200 first)") + print(f"Started: {time.strftime('%Y-%m-%d %H:%M:%S')}") + + print_header("1) Preflight") + issues = preflight(dev) + if issues: + for i in issues: + print(f" FAIL: {i}") + print("\nAbort - clear port/background services and retry.") + return 2 + print(" OK: port free, hybbx inactive, access OK") + + print_header("2) Host precheck 19200-8N1") + precheck = test_host_check(dev, 19200, "8N1", dtr_cycle=args.dtr_cycle) + print( + f" {precheck.name:12} {precheck.classify():7} fw={int(precheck.has_firmware)} " + f"INFO={preview(precheck.info_reply, 60)!r}" + ) + + print_header("3) Profile sweep (19200 first, no Ctrl+C at 19200)") + results: list[ProfileResult] = [precheck] + seen = {precheck.name} + n = 0 + for baud in bauds: + for line, data_bits, parity, stop_bits in LINE_FORMATS: + name = profile_name(baud, line) + if name in seen: + continue + n += 1 + if not args.quiet: + print(f" [{n:2}/{total - 1}] {name:12} ...", end="", flush=True) + try: + gentle = baud == 19200 + r = test_profile(dev, baud, line, data_bits, parity, stop_bits, gentle=gentle) + results.append(r) + seen.add(name) + if not args.quiet: + print( + f" {r.classify():7} score={r.host_score:3} " + f"CTS={int(r.cts)} pr={r.printable_ratio:.0%} " + f"INFO={preview(r.info_reply, 40)!r}" + ) + except OSError as e: + if not args.quiet: + print(f" ERROR: {e}") + + ranked = sorted(results, key=rank_key, reverse=True) + best = ranked[0] if ranked else None + + print_header("4) Ranking (Top 8)") + for r in ranked[:8]: + print( + f" {r.name:12} {r.classify():7} score={r.host_score:3} " + f"cmd={int(r.has_cmd)} fw={int(r.has_firmware)} echo={int(r.echo_only)} " + f"CTS={int(r.cts)}" + ) + + print_header("5) Deep test (Top 3)") + deep_targets = [r for r in ranked[:3] if r.host_score > 0 or r.combined_reply] + if not deep_targets and ranked: + deep_targets = ranked[:1] + for r in deep_targets: + if r.name == precheck.name and precheck.classify() == "HOST": + continue + print(f" -> {r.name} ...", flush=True) + deep_test(dev, r) + + ranked = sorted(results, key=rank_key, reverse=True) + best = ranked[0] + + if best and best.classify() in ("ECHO", "PARTIAL") and env.get("TNC2C_BAUD"): + saved_line = env.get("TNC2C_LINE", "8n1").upper() + if "7" in saved_line and "E" in saved_line: + saved_line = "7E1" + elif saved_line.startswith("8") and "E" in saved_line: + saved_line = "8E1" + else: + saved_line = "8N1" + saved_name = profile_name(int(env["TNC2C_BAUD"]), saved_line) + saved = next((r for r in results if r.name == saved_name), None) + if saved and saved.classify() != "HOST": + print(f" (Note: saved profile {saved_name} - use --host-check after power reset)") + + if best is None: + print(" No response on any profile.") + return 2 + + suggested = suggest_env(best) + suggested["TNC2C_DEV"] = dev + return print_result_summary(best, dev, ranked, root, args, suggested, None, t0) + + +if __name__ == "__main__": + sys.exit(main()) |
