summaryrefslogtreecommitdiff
path: root/stacks/tncs/tnc2c-health.py
diff options
context:
space:
mode:
authorinfo@mode42.com <info@mode42.com>2026-08-07 18:25:13 +0000
committerinfo@mode42.com <info@mode42.com>2026-08-07 18:25:13 +0000
commit04d965d67a7264a1c7c211494aebda1953df7603 (patch)
tree0ebd700a6e219f84a26a656f4bee8778bc75da7b /stacks/tncs/tnc2c-health.py
Initial push
Diffstat (limited to 'stacks/tncs/tnc2c-health.py')
-rw-r--r--stacks/tncs/tnc2c-health.py445
1 files changed, 445 insertions, 0 deletions
diff --git a/stacks/tncs/tnc2c-health.py b/stacks/tncs/tnc2c-health.py
new file mode 100644
index 0000000..642b6cf
--- /dev/null
+++ b/stacks/tncs/tnc2c-health.py
@@ -0,0 +1,445 @@
+#!/usr/bin/env python3
+"""
+tnc2c-health - full Landolt TNC2C health check (serial, host, KISS, optional TX).
+
+Does NOT replace visual LED checks (Power/PTT/CD/Connected/Status).
+
+Usage:
+ ./tnc2c-health.sh
+ ./tnc2c-health.sh /dev/ttyS4
+ ./tnc2c-health.sh --tx # include KISS TX attempt (keys PTT!)
+ ./tnc2c-health.sh --tx --quiet
+"""
+
+from __future__ import annotations
+
+import argparse
+import fcntl
+import os
+import struct
+import sys
+import time
+import termios
+from dataclasses import dataclass, field
+from typing import Optional
+
+BAUD_MAP = {
+ 300: termios.B300,
+ 600: termios.B600,
+ 1200: termios.B1200,
+ 2400: termios.B2400,
+ 4800: termios.B4800,
+ 9600: termios.B9600,
+ 19200: termios.B19200,
+}
+
+PROFILES = [
+ ("19200-8N1", 19200, False),
+ ("19200-7E1", 19200, True),
+ ("9600-7E1", 9600, True),
+ ("9600-8N1", 9600, False),
+ ("2400-7E1", 2400, True),
+ ("4800-7E1", 4800, True),
+]
+
+HOST_MARKERS = (
+ b"cmd:",
+ b"CMD:",
+ b"TNC",
+ b"***",
+ b"help",
+ b"HELP",
+ b"version",
+ b"Version",
+ b"MYCALL",
+ b"KISS was",
+ b"KISS is",
+)
+
+ECHO_ONLY_MARKERS = (
+ b"kiss off",
+ b"kiss on",
+ b"INFO",
+ b"HELP",
+ b"RESTART",
+)
+
+
+@dataclass
+class ProfileResult:
+ name: str
+ rts: bool
+ cts: bool
+ dsr: bool
+ info_reply: bytes = b""
+ host_score: int = 0
+ echo_only: bool = False
+ has_cmd_prompt: bool = False
+ kiss_ack: bool = False
+ kiss_echo_only: bool = False
+ notes: list[str] = field(default_factory=list)
+
+
+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 line in f:
+ line = line.strip()
+ if not line or line.startswith("#"):
+ continue
+ if "=" in line:
+ k, v = line.split("=", 1)
+ env[k.strip()] = v.strip()
+ return env
+
+
+def open_serial(dev: str, baud: int, seven_e1: bool) -> int:
+ fd = os.open(dev, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
+ if baud not in BAUD_MAP:
+ raise ValueError(f"unsupported baud {baud}")
+ t = termios.tcgetattr(fd)
+ t[0] = t[1] = 0
+ t[2] = termios.CLOCAL | termios.CREAD | (termios.CS7 if seven_e1 else termios.CS8)
+ if seven_e1:
+ t[2] |= termios.PARENB
+ 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]
+ flags |= 0x004 | 0x002 # RTS DTR
+ fcntl.ioctl(fd, 0x5416, struct.pack("I", flags))
+ return fd
+
+
+def modem_lines(fd: int) -> tuple[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)
+
+
+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.03)
+ return b"".join(chunks)
+
+
+def write_flush(fd: int, data: bytes) -> None:
+ os.write(fd, data)
+ termios.tcdrain(fd)
+
+
+def score_host_reply(cmd: bytes, reply: bytes) -> tuple[int, bool, bool]:
+ if not reply:
+ return 0, False, False
+
+ stripped_cmd = cmd.strip(b"\r\n")
+ echo_only = reply.strip() == stripped_cmd or reply.strip() in (
+ stripped_cmd,
+ stripped_cmd + b"\r",
+ stripped_cmd + b"\n",
+ stripped_cmd + b"\r\n",
+ )
+
+ score = 0
+ if b"cmd:" in reply.lower() or b"cmd>" in reply.lower():
+ score += 50
+ for m in HOST_MARKERS:
+ if m.lower() in reply.lower():
+ score += 10
+ if len(reply) > len(cmd) + 4 and not echo_only:
+ score += 5
+ if b"\n" in reply and len(reply) > 20:
+ score += 5
+
+ has_cmd = b"cmd:" in reply.lower()
+ return score, echo_only, has_cmd
+
+
+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)
+ body += b"\x03\xF0" + info
+ crc = ax25_crc(body)
+ return body + bytes((crc & 0xFF, crc >> 8))
+
+
+def kiss_frame(port: int, payload: bytes) -> bytes:
+ return b"\xC0" + bytes([port & 0x0F]) + kiss_escape(payload) + b"\xC0"
+
+
+def test_profile(dev: str, name: str, baud: int, seven_e1: bool) -> ProfileResult:
+ res = ProfileResult(name=name, rts=False, cts=False, dsr=False)
+ fd = open_serial(dev, baud, seven_e1)
+ res.rts, res.cts, res.dsr = modem_lines(fd)
+
+ # break out of possible modes
+ write_flush(fd, b"\x03\x1a") # Ctrl+C, Ctrl+Z
+ time.sleep(0.2)
+ read_for(fd, 0.3)
+
+ write_flush(fd, b"kiss off\r")
+ time.sleep(0.4)
+ read_for(fd, 0.4)
+
+ write_flush(fd, b"\r")
+ time.sleep(0.2)
+ read_for(fd, 0.3)
+
+ cmd = b"INFO\r"
+ write_flush(fd, cmd)
+ time.sleep(0.6)
+ info = read_for(fd, 1.0)
+ res.info_reply = info
+ score, echo, has_cmd = score_host_reply(cmd, info)
+ res.host_score = score
+ res.echo_only = echo and score < 15
+ res.has_cmd_prompt = has_cmd
+
+ write_flush(fd, b"HELP\r")
+ time.sleep(0.5)
+ help_r = read_for(fd, 0.8)
+ hs, echo2, hc = score_host_reply(b"HELP\r", help_r)
+ res.host_score = max(res.host_score, hs)
+ if hc:
+ res.has_cmd_prompt = True
+ if echo2 and hs < 10:
+ res.echo_only = res.echo_only or True
+
+ write_flush(fd, b"kiss on\r")
+ time.sleep(0.5)
+ kiss_r = read_for(fd, 0.6)
+ if b"kiss" in kiss_r.lower() and len(kiss_r) < 32:
+ res.kiss_echo_only = True
+ if b"on" in kiss_r.lower() and len(kiss_r) > 8 and not res.kiss_echo_only:
+ res.kiss_ack = True
+
+ passive = read_for(fd, 1.0)
+ if passive:
+ res.notes.append(f"passive_after_kiss={len(passive)}B")
+
+ write_flush(fd, b"kiss off\r")
+ time.sleep(0.2)
+ os.close(fd)
+ return res
+
+
+def run_tx_test(dev: str, profile_name: str, baud: int, seven_e1: bool) -> dict:
+ fd = open_serial(dev, baud, seven_e1)
+ 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"HEALTH")
+ pkt = kiss_frame(0, payload)
+ write_flush(fd, pkt)
+ time.sleep(3.0)
+ rx = read_for(fd, 1.0)
+ write_flush(fd, b"kiss off\r")
+ os.close(fd)
+
+ echoed = pkt in rx or payload in rx
+ return {
+ "sent": len(pkt),
+ "rx_len": len(rx),
+ "frame_echoed_back": echoed,
+ "rx_sample": rx[:96],
+ }
+
+
+def print_header(title: str) -> None:
+ print(f"\n{'=' * 60}")
+ print(title)
+ print("=" * 60)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="TNC2C full health check")
+ parser.add_argument("device", nargs="?", default=None)
+ parser.add_argument("--tx", action="store_true", help="attempt KISS TX (keys PTT)")
+ parser.add_argument("--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")
+
+ print_header("TNC2C HEALTH CHECK")
+ print(f"Device: {dev}")
+ print(f"Time: {time.strftime('%Y-%m-%d %H:%M:%S')}")
+
+ # --- preflight ---
+ print_header("1) Preflight")
+ if os.system("pgrep -x hybbx >/dev/null 2>&1") == 0:
+ print("FAIL: hybbx is running - stop it first")
+ return 2
+ print("OK: hybbx not active")
+
+ if not os.access(dev, os.R_OK | os.W_OK):
+ print(f"FAIL: no access to {dev} (dialout group?)")
+ return 2
+ print(f"OK: access to {dev}")
+
+ # --- profile sweep ---
+ print_header("2) Serial profiles (host detection)")
+ results: list[ProfileResult] = []
+ for name, baud, seven in PROFILES:
+ try:
+ r = test_profile(dev, name, baud, seven)
+ results.append(r)
+ info_preview = r.info_reply[:40].replace(b"\r", b"\\r").replace(b"\n", b"\\n")
+ print(
+ f" {name:12} RTS={int(r.rts)} CTS={int(r.cts)} DSR={int(r.dsr)} "
+ f"score={r.host_score:2} cmd={int(r.has_cmd_prompt)} "
+ f"echo_only={int(r.echo_only)} INFO={info_preview!r}"
+ )
+ except OSError as e:
+ print(f" {name:12} ERROR: {e}")
+
+ best = max(results, key=lambda x: x.host_score, default=None)
+ if best is None:
+ print("FAIL: no profiles tested")
+ return 2
+
+ # pick working line: prefer env, else best score, else 19200-8N1 clean echo
+ preferred = env.get("TNC2C_LINE", "8n1").lower()
+ work_name = best.name
+ work_baud = 19200
+ work_7e1 = preferred.startswith("7e")
+ for name, baud, seven in PROFILES:
+ if name == best.name:
+ work_baud, work_7e1 = baud, seven
+ break
+ if best.host_score < 15:
+ for r in results:
+ if r.name == "19200-8N1" and r.info_reply.strip() in (
+ b"INFO\r\n",
+ b"INFO\r",
+ b"INFO",
+ ):
+ work_name = r.name
+ work_baud, work_7e1 = 19200, False
+ break
+
+ print(f"\nBest profile (host score): {best.name} (score={best.host_score})")
+ print(f"Working profile for tests: {work_name}")
+
+ # --- diagnosis ---
+ print_header("3) Serial / host mode diagnosis")
+ any_cmd = any(r.has_cmd_prompt for r in results)
+ mostly_echo = all(r.echo_only or r.host_score < 15 for r in results)
+
+ if any_cmd:
+ print("OK: real host mode (cmd: prompt) detected")
+ elif mostly_echo:
+ print("WARN: command echo only - no cmd: prompt, no INFO listing")
+ print(" -> TNC probably does NOT process commands")
+ print(" -> KISS/PTT via software probably ineffective")
+ print(" -> LEDs do not change (matches this picture)")
+ else:
+ print("PARTIAL: replies present but no clear host dialog")
+
+ if all(not r.cts for r in results):
+ print("WARN: CTS=0 on all profiles - handshake problem")
+ print(" -> bridge DB25 pins 4 and 5 in cable if needed (manual)")
+
+ # --- RF / LED manual ---
+ print_header("4) TNC LEDs (no radio / with radio)")
+ print("No radio (audio off): Pin4 open -> LED3 (CD) often ON continuously (normal)")
+ print("With radio: LED3 should be ON with noise, OFF with squelch closed")
+ print("LED 1 Power | LED 2 PTT | LED 3 CD | LED 4 Connected | LED 5 Status")
+
+ # --- optional TX ---
+ print_header("5) KISS send test")
+ if not args.tx:
+ print("Skipped (without --tx). To test: ./tnc2c-health.sh --tx")
+ else:
+ print("WARNING: sends KISS frame - PTT may key!")
+ tx = run_tx_test(dev, work_name, work_baud, work_7e1)
+ print(f" Profile: {work_name}")
+ print(f" Sent: {tx['sent']} bytes KISS")
+ print(f" RX: {tx['rx_len']} bytes")
+ print(f" Frame echoed back directly: {tx['frame_echoed_back']}")
+ if tx["frame_echoed_back"]:
+ print(" WARN: frame returns unchanged -> possible serial loopback,")
+ print(" not sent over radio")
+ print(" -> CHECK NOW: LED2 (PTT) and S-meter on radio")
+
+ # --- hardware checklist ---
+ print_header("6) Hardware checklist (if not transmitting)")
+ print(" [ ] PTT radio red -> TNC DIN Pin 3")
+ print(" [ ] GND radio black -> TNC DIN Pin 2")
+ print(" [ ] TX radio white -> TNC DIN Pin 1")
+ print(" [ ] RX 3.5mm tip -> TNC DIN Pin 4 (left center)")
+ print(" [ ] VOX on AE6110 -> OFF")
+ print(" [ ] Mike gain on TNC -> increase slowly (pot on modem)")
+ print(" [ ] RADIO bridge TNC -> 2400 baud (Pos. 2)")
+ print(" [ ] TERM bridge TNC -> 19200")
+
+ # --- software checklist ---
+ print_header("7) Software / next steps")
+ if mostly_echo and not any_cmd:
+ print(" 1. Software recovery (no power cycle):")
+ print(" ./tnc2c-host-reset.sh --kiss")
+ print(" ./tnc2c-boot-wait.sh --recover-only")
+ print(" 2. Expect cmd: or TheFirmware banner after recovery")
+ print(" 3. Rescue only if still echo-only: ./tnc2c-boot-wait.sh + power cycle (DTR high)")
+ print(" 4. When cmd: appears -> ./tnc2c-health.sh --tx")
+ print(" 5. HyBBX: kiss_entry=none when max25d owns KISS (see docs/TNC-RECOVERY.md)")
+ else:
+ print(f" Serial OK with {work_name}")
+ print(" ./tnc2c-send-test.sh for single send test")
+ print(" HyBBX: baud=19200 serial_line=8n1 (this device)")
+
+ print_header("SUMMARY")
+ status = "DEGRADED" if mostly_echo and not any_cmd else "OK"
+ print(f"Overall: {status}")
+ print(f"Device: {dev}")
+ print(f"Best host profile: {best.name} (score={best.host_score})")
+ print(f"CTS on best: {int(best.cts)} echo_only: {int(best.echo_only)} cmd: {int(best.has_cmd_prompt)}")
+
+ return 1 if status == "DEGRADED" else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
git clone -b <branch> https://cgit.mode42.com/<repo>.git
git clone -b <branch> git://cgit.mode42.com/<repo>.git

info@mode42.com