#!/usr/bin/env bash # tx-rx-test.sh — unified TX/RX release test (BayCom/based max25-bcpr + classic TNC). # Default: L0 offline only (CI / make test). Live/TX are operator-gated. # Public: BayCom/based / max25-bcpr. Never Konverter/converter. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Tree: scripts/ → repo root. Install: PREFIX/bin or PREFIX/sbin → no stacks/. if [[ -d "${SCRIPT_DIR}/../stacks/max25-bcpr" ]]; then ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" cd "$ROOT" _BCPR_CTL_DEFAULT="${ROOT}/stacks/max25-bcpr/tools/max25-bcpr-ctl" _BCPR_INI_DEFAULT="${ROOT}/stacks/max25-bcpr/share/max25-bcpr.ini.example" else ROOT="" cd "${PWD:-/}" _BCPR_CTL_DEFAULT="$(command -v max25-bcpr-ctl 2>/dev/null || true)" _BCPR_INI_DEFAULT="/etc/max25/max25-bcpr.ini" fi LEVEL=0 DEVICE=all # all | modem | tnc DO_LIVE=0 DO_TX=0 FORCE_TX=0 SECONDS_LIVE=15 TX_SECONDS=3 BCPR_INI="${BCPR_INI:-$_BCPR_INI_DEFAULT}" SOCK="${MAX25_SOCK:-/run/max25/modem.sock}" ERR=0 BCPR_CTL="${BCPR_CTL:-$_BCPR_CTL_DEFAULT}" if [[ -z "$BCPR_CTL" ]] || [[ ! -x "$BCPR_CTL" ]]; then if command -v max25-bcpr-ctl >/dev/null 2>&1; then BCPR_CTL="$(command -v max25-bcpr-ctl)" elif [[ -n "$ROOT" && -x "$ROOT/stacks/max25-bcpr/tools/max25-bcpr-ctl" ]]; then BCPR_CTL="$ROOT/stacks/max25-bcpr/tools/max25-bcpr-ctl" fi fi BCPR_SMOKE="${BCPR_SMOKE:-}" if [[ -z "$BCPR_SMOKE" ]]; then if [[ -n "$ROOT" && -x "$ROOT/stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh" ]]; then BCPR_SMOKE="$ROOT/stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh" elif command -v max25-bcpr-rxtx-smoke.sh >/dev/null 2>&1; then BCPR_SMOKE="$(command -v max25-bcpr-rxtx-smoke.sh)" elif [[ -x /usr/local/sbin/max25-bcpr-rxtx-smoke.sh ]]; then BCPR_SMOKE=/usr/local/sbin/max25-bcpr-rxtx-smoke.sh fi fi usage() { cat <<'USAGE' Usage: tx-rx-test.sh [--level N] [--device all|modem|tnc] [--live] [--tx] [--seconds N] [--tx-seconds N] [-c bcpr.ini] L0 offline TX/RX proofs (default) — CI / release-check / ctest L1 soft preflight (no attach / no RF) L2 live attach (modem: max25-bcprd timed; tnc: max25d sock status) L3 live RX listen (same as L2 + RX readiness) L4 TX (--tx required; PTT / RF risk; default ~3s MCR key) --live implies at least L2. --tx requires --live (L4). --tx-seconds: modem PTT key target (default 3; proven ≈376B info). Default: NO TX. Hard time caps. Safe for make test / CI. Static rule (§0.20): live --tx is refused unless RX was detected in this run (modem: Soft-DCD/noise → state_dir/dcd-bc* or rx-activity-bc*; TNC: live CONNECT/RX path OK first). Override only with --force-tx (against policy — debug only). USAGE } while [[ $# -gt 0 ]]; do case "$1" in --level) LEVEL="$2"; shift 2 ;; --device) DEVICE="$2"; shift 2 ;; --live) DO_LIVE=1; shift ;; --tx) DO_TX=1; shift ;; --force-tx) FORCE_TX=1; shift ;; --seconds) SECONDS_LIVE="$2"; shift 2 ;; --tx-seconds) TX_SECONDS="$2"; shift 2 ;; -c) BCPR_INI="$2"; shift 2 ;; -h|--help) usage; exit 0 ;; *) echo "ERROR: unknown arg: $1"; usage; exit 2 ;; esac done case "$DEVICE" in all|modem|tnc) ;; *) echo "ERROR: --device must be all|modem|tnc"; exit 2 ;; esac if [[ "$DO_TX" -eq 1 && "$DO_LIVE" -eq 0 ]]; then echo "ERROR: --tx requires --live" exit 2 fi if [[ "$DO_LIVE" -eq 1 && "$LEVEL" -lt 2 ]]; then LEVEL=2 fi if [[ "$DO_TX" -eq 1 ]]; then LEVEL=4 fi if [[ "$SECONDS_LIVE" -lt 1 ]]; then SECONDS_LIVE=1 fi if [[ "$SECONDS_LIVE" -gt 60 ]]; then echo "WARN: capping --seconds from $SECONDS_LIVE to 60" SECONDS_LIVE=60 fi if [[ "$TX_SECONDS" -lt 1 ]]; then TX_SECONDS=1 fi if [[ "$TX_SECONDS" -gt 12 ]]; then echo "WARN: capping --tx-seconds from $TX_SECONDS to 12" TX_SECONDS=12 fi need_live=$((TX_SECONDS + 5)) if [[ "$DO_TX" -eq 1 && "$SECONDS_LIVE" -lt "$need_live" ]]; then echo "NOTE: raising --seconds from $SECONDS_LIVE to $need_live for --tx-seconds $TX_SECONDS" SECONDS_LIVE=$need_live fi log() { printf '%s\n' "$*"; } ok() { log "PASS: $*"; } fail() { log "FAIL: $*"; ERR=1; } stage() { log ""; log "=== $* ==="; } want_modem() { [[ "$DEVICE" == "all" || "$DEVICE" == "modem" ]]; } want_tnc() { [[ "$DEVICE" == "all" || "$DEVICE" == "tnc" ]]; } run_l0() { stage "L0 offline TX/RX" if [[ -n "$ROOT" && -f "$ROOT/stacks/daemon/test_tx_rx_offline.py" ]]; then if python3 "$ROOT/stacks/daemon/test_tx_rx_offline.py"; then ok "test_tx_rx_offline (TNC + bcpr host)" else fail "test_tx_rx_offline" fi else ok "test_tx_rx_offline skipped (installed layout — use bcpr-rxtx-smoke)" fi if want_tnc; then if python3 stacks/tncs/test_tnc_serial_recovery.py >/dev/null; then ok "tnc serial recovery unit" else fail "tnc serial recovery unit" fi if python3 stacks/daemon/test_kiss_bridge.py >/dev/null; then ok "kiss_bridge unit (TNC KISS)" else fail "kiss_bridge unit" fi fi if want_modem; then # Default tree builds max25-bcpr (ON). Smoke when binaries exist. bcprd="" for cand in \ "${BCPR_BUILD_DIR:-}/bin/max25-bcprd" \ "${BUILD_DIR:-}/bin/max25-bcprd" \ build/bin/max25-bcprd \ build-default/bin/max25-bcprd \ "build-max25-bcpr/bin/max25-bcprd" \ "/tmp/max25-build-max25-bcpr-${USER:-user}/bin/max25-bcprd"; do if [[ -n "$cand" && -x "$cand" ]]; then bcprd="$cand" break fi done if [[ -z "$bcprd" ]]; then warn "max25-bcpr L0 skipped (binary not found; default ON — expected built)" elif [[ -x stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh ]]; then if [[ -n "$bcprd" ]]; then if MAX25_BCPR_NO_AUTOBUILD=1 bash stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh -c "$BCPR_INI"; then ok "max25-bcpr-rxtx-smoke L0 (BayCom/based)" else fail "max25-bcpr-rxtx-smoke L0" fi else if bash stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh -c "$BCPR_INI"; then ok "max25-bcpr-rxtx-smoke L0 (BayCom/based)" else fail "max25-bcpr-rxtx-smoke L0" fi fi else fail "max25-bcpr-rxtx-smoke.sh missing" fi fi } run_l1() { stage "L1 soft preflight" if want_modem; then if [[ -n "${BCPR_CTL:-}" && -x "$BCPR_CTL" ]]; then set +e "$BCPR_CTL" -c "$BCPR_INI" preflight pf_rc=$? set -e if [[ "$pf_rc" -eq 0 ]]; then ok "max25-bcpr-ctl preflight" else if [[ "$DO_LIVE" -eq 1 ]]; then fail "max25-bcpr-ctl preflight (required for --live modem)" else log "NOTE: bcpr preflight rc=$pf_rc — soft at L1 without --live" fi fi else fail "max25-bcpr-ctl missing (install max25-bcpr or set BCPR_CTL)" fi fi if want_tnc; then if [[ -f share/clients/tnc2c.yaml ]] && [[ -f stacks/tncs/tnc_serial_recovery.py ]]; then ok "tnc client profile + recovery module present" else fail "tnc profile/recovery missing" fi fi } live_tnc_sock() { local mode="$1" # rx|tx local sock="$SOCK" if [[ ! -S "$sock" ]]; then if [[ -S /tmp/max25/modem.sock ]]; then sock=/tmp/max25/modem.sock else fail "no max25d unix socket ($SOCK) — start max25d with TNC first" return 1 fi fi log "using unix socket $sock (mode=$mode)" python3 - "$sock" "$mode" <<'PY' || return 1 import socket, sys, time sock_path, mode = sys.argv[1], sys.argv[2] class LineReader: def __init__(self, s): self.s = s self.buf = b"" def read(self, timeout=8.0): self.s.settimeout(timeout) while b"\n" not in self.buf: chunk = self.s.recv(4096) if not chunk: raise RuntimeError("connection closed") self.buf += chunk line, self.buf = self.buf.split(b"\n", 1) return line.decode("utf-8", errors="replace") s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.connect(sock_path) r = LineReader(s) hello = r.read() assert hello == "OK", hello status = r.read() assert status.startswith("STATUS "), status s.sendall(b"CONNECT\n") ev = r.read() assert ev == "EVENT connected", ev assert r.read() == "OK" if mode == "tx": s.sendall(b"SEND TXRXTEST\n") rx = r.read() assert rx.startswith("RX "), rx assert r.read() == "OK" print("TNC live TX: SEND OK (host path)") else: # RX readiness: connected + status shows device s.sendall(b"GET STATUS\n") st = r.read() assert "device=" in st, st assert r.read() == "OK" print("TNC live RX: CONNECT + STATUS OK (listen ready)") s.close() PY } run_live() { stage "L2/L3 live (${SECONDS_LIVE}s) device=$DEVICE" if want_modem; then local smoke_args=(-c "$BCPR_INI" --live --seconds "$SECONDS_LIVE") if [[ "$DO_TX" -eq 1 ]]; then smoke_args+=(--tx --tx-seconds "$TX_SECONDS") if [[ "${FORCE_TX:-0}" -eq 1 ]]; then smoke_args+=(--force-tx) fi log "WARN: modem --tx asserts PTT (~${TX_SECONDS}s; watch LED/wattmeter) — only after RX (§0.20)" fi _smoke="${BCPR_SMOKE:-stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh}" if [[ ! -x "$_smoke" ]]; then fail "max25-bcpr-rxtx-smoke.sh missing (install max25-bcpr or set BCPR_SMOKE)" elif bash "$_smoke" "${smoke_args[@]}"; then ok "bcpr live smoke (BayCom/based)" else fail "bcpr live smoke" fi fi if want_tnc; then local TNC_RX_OK=0 if live_tnc_sock rx; then ok "tnc live CONNECT (RX ready)" TNC_RX_OK=1 else fail "tnc live CONNECT" fi if [[ "$DO_TX" -eq 1 ]]; then if [[ "$TNC_RX_OK" -ne 1 && "${FORCE_TX:-0}" -eq 0 ]]; then fail "TNC TX blocked (§0.20): RX/CONNECT failed" else log "WARN: TNC --tx sends one UI frame over air if radio keyed" if live_tnc_sock tx; then ok "tnc live CONNECT+SEND" else fail "tnc live CONNECT+SEND" fi fi fi fi } # --- main --- log "tx-rx-test level=$LEVEL device=$DEVICE live=$DO_LIVE tx=$DO_TX tx_seconds=$TX_SECONDS" run_l0 if [[ "$LEVEL" -ge 1 ]]; then run_l1 fi if [[ "$LEVEL" -ge 2 || "$DO_LIVE" -eq 1 ]]; then run_live fi stage "summary" if [[ "$ERR" -eq 0 ]]; then if [[ "$DO_TX" -eq 1 ]]; then ok "TX/RX complete through L4 (live + TX)" elif [[ "$DO_LIVE" -eq 1 ]]; then ok "TX/RX complete through L2/L3 (live RX, no TX)" elif [[ "$LEVEL" -ge 1 ]]; then ok "TX/RX L0+L1 complete" else ok "TX/RX L0 offline complete — use --live / --tx for hardware" fi exit 0 fi fail "one or more stages failed" exit 1