summaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorinfo@mode42.com <info@mode42.com>2026-08-07 18:23:28 +0000
committerinfo@mode42.com <info@mode42.com>2026-08-07 18:23:28 +0000
commitfa05a5f8238e9e1417235711656e5062ccc843a7 (patch)
tree46fbbd18de54492b59307c16efcc7f3152723238 /scripts
Initial push
Diffstat (limited to 'scripts')
-rwxr-xr-xscripts/build-all.sh54
-rwxr-xr-xscripts/build-amiga-terminal.sh17
-rwxr-xr-xscripts/build-ax25-deps.sh146
-rwxr-xr-xscripts/build-freebsd.sh30
-rwxr-xr-xscripts/build.sh11
-rwxr-xr-xscripts/clean.sh9
-rwxr-xr-xscripts/discover-plugins.sh97
-rwxr-xr-xscripts/install-freebsd.sh111
-rwxr-xr-xscripts/install-max25.sh116
-rwxr-xr-xscripts/max25-ctl408
-rwxr-xr-xscripts/max25-lite-setup.sh517
-rwxr-xr-xscripts/max25-terminal3
-rwxr-xr-xscripts/max25d-session.sh160
-rwxr-xr-xscripts/release-check.sh258
-rwxr-xr-xscripts/run-max25-terminal.sh62
-rwxr-xr-xscripts/run-max25d.sh99
-rwxr-xr-xscripts/terminology-check.sh41
-rwxr-xr-xscripts/test-wizard.sh113
-rwxr-xr-xscripts/test.sh12
-rwxr-xr-xscripts/tx-rx-test.sh358
20 files changed, 2622 insertions, 0 deletions
diff --git a/scripts/build-all.sh b/scripts/build-all.sh
new file mode 100755
index 0000000..571b466
--- /dev/null
+++ b/scripts/build-all.sh
@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+# MainAX25-Stack — build all merged stacks (max25-bcpr default ON).
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$ROOT"
+BUILD_DIR="${BUILD_DIR:-build}"
+BUILD_TYPE="${CMAKE_BUILD_TYPE:-Release}"
+DO_INSTALL=0
+PREFIX="${PREFIX:-/usr/local}"
+
+usage() {
+ cat <<USAGE
+Usage: $0 [--install] [--prefix DIR] [--build-dir DIR]
+
+ Default: cmake configure + build (TERMINAL/DAEMON/BCPR ON).
+ --install also cmake --install to PREFIX
+USAGE
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --install) DO_INSTALL=1; shift ;;
+ --prefix) PREFIX="$2"; shift 2 ;;
+ --build-dir) BUILD_DIR="$2"; shift 2 ;;
+ -h|--help) usage; exit 0 ;;
+ *) echo "unknown: $1" >&2; usage; exit 2 ;;
+ esac
+done
+
+echo "== MainAX25-Stack build-all (${BUILD_DIR}, ${BUILD_TYPE}) =="
+cmake -B "${BUILD_DIR}" \
+ -DCMAKE_BUILD_TYPE="${BUILD_TYPE}" \
+ -DMAX25_BUILD_MAX25_BCPR="${MAX25_BUILD_MAX25_BCPR:-ON}" \
+ -DMAX25_BUILD_TERMINAL=ON \
+ -DMAX25_BUILD_DAEMON=ON \
+ -DMAX25_BUNDLE_AX25=OFF
+cmake --build "${BUILD_DIR}" -j"$(nproc 2>/dev/null || echo 2)"
+
+echo "== plugin discovery =="
+bash scripts/discover-plugins.sh || true
+
+if [[ "$DO_INSTALL" -eq 1 ]]; then
+ echo "== install → ${PREFIX} =="
+ if [[ "$(id -u)" -eq 0 ]]; then
+ cmake --install "${BUILD_DIR}" --prefix "${PREFIX}"
+ else
+ sudo cmake --install "${BUILD_DIR}" --prefix "${PREFIX}"
+ fi
+ echo "Launchers: ${PREFIX}/bin/run-max25d ${PREFIX}/bin/run-max25-terminal"
+ echo "Tests: ${PREFIX}/sbin/max25-tx-rx-test ${PREFIX}/sbin/max25-bcpr-rxtx-smoke.sh"
+fi
+
+echo "== done =="
diff --git a/scripts/build-amiga-terminal.sh b/scripts/build-amiga-terminal.sh
new file mode 100755
index 0000000..f6336b5
--- /dev/null
+++ b/scripts/build-amiga-terminal.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env sh
+# Cross-build max25-terminal for AmigaOS 3.9+ (bebbo-gcc / clib2).
+set -eu
+
+ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
+SDK="${AMIGA_SDK_PATH:-/opt/amiga}"
+
+if [ ! -x "$SDK/bin/m68k-amigaos-gcc" ]; then
+ echo "Amiga SDK not found at $SDK (set AMIGA_SDK_PATH)" >&2
+ exit 1
+fi
+
+make -C "$ROOT/stacks/terminal/amiga" AMIGA_SDK="$SDK" all
+make -C "$ROOT/stacks/terminal/amiga" AMIGA_SDK="$SDK" test
+
+echo "AmigaOS client:"
+echo " $ROOT/stacks/terminal/amiga/max25-terminal"
diff --git a/scripts/build-ax25-deps.sh b/scripts/build-ax25-deps.sh
new file mode 100755
index 0000000..d909cdc
--- /dev/null
+++ b/scripts/build-ax25-deps.sh
@@ -0,0 +1,146 @@
+#!/usr/bin/env bash
+# DEPRECATED — manual build of vendored libax25 / ax25-tools / ax25-apps (Linux).
+#
+# MAX25 default build does NOT invoke this script. Tarballs in third_party/ax25/
+# are study reference only; max25d uses native ax25_codec.py.
+#
+# Use when you need host listen/call/kissattach without distro packages:
+# scripts/build-ax25-deps.sh --prefix /opt/ax25 --need-apps --need-tools
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+VENDOR="${ROOT}/third_party/ax25"
+PATCHES="${VENDOR}/patches"
+
+LIBAX25_VER="0.0.12-rc5"
+TOOLS_VER="0.0.10-rc5"
+APPS_VER="0.0.8-rc5"
+
+usage() {
+ cat <<EOF
+Usage: $0 --prefix DIR [options]
+
+Options:
+ --prefix DIR Install prefix (required)
+ --build-dir DIR Extract/build tree (default: <prefix>/../build)
+ --need-libax25 Build libax25 even if present on host
+ --need-tools Build ax25-tools
+ --need-apps Build ax25-apps
+ -h, --help This help
+
+Environment:
+ MAKE, CC, CXX Passed to upstream make/configure
+EOF
+}
+
+PREFIX=""
+BUILD_DIR=""
+NEED_LIBAX25=0
+NEED_TOOLS=0
+NEED_APPS=0
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --prefix) PREFIX="$2"; shift 2 ;;
+ --build-dir) BUILD_DIR="$2"; shift 2 ;;
+ --need-libax25) NEED_LIBAX25=1; shift ;;
+ --need-tools) NEED_TOOLS=1; shift ;;
+ --need-apps) NEED_APPS=1; shift ;;
+ -h|--help) usage; exit 0 ;;
+ *) echo "Unknown option: $1" >&2; usage; exit 1 ;;
+ esac
+done
+
+[[ -n "$PREFIX" ]] || { echo "error: --prefix required" >&2; exit 1; }
+PREFIX="$(cd "$(dirname "$PREFIX")" && pwd)/$(basename "$PREFIX")"
+BUILD_DIR="${BUILD_DIR:-$(dirname "$PREFIX")/build}"
+BUILD_DIR="$(mkdir -p "$BUILD_DIR" && cd "$BUILD_DIR" && pwd)"
+PREFIX="$(mkdir -p "$PREFIX" && cd "$PREFIX" && pwd)"
+
+verify_tarball() {
+ local file="$1"
+ [[ -f "$file" ]] || { echo "error: missing tarball $file" >&2; exit 1; }
+ if [[ -f "${VENDOR}/SHA256SUMS" ]]; then
+ local base
+ base="$(basename "$file")"
+ (cd "$VENDOR" && sha256sum -c SHA256SUMS 2>/dev/null | grep -q "^${base}: OK") \
+ || echo "WARN: SHA256 mismatch for ${base} — continuing" >&2
+ fi
+}
+
+extract() {
+ local tarball="$1" dir="$2"
+ verify_tarball "$tarball"
+ rm -rf "$dir"
+ tar xzf "$tarball" -C "$BUILD_DIR"
+}
+
+apply_patches() {
+ local srcdir="$1"
+ shift
+ local patch failed=0
+ for patch in "$@"; do
+ [[ -f "$patch" ]] || continue
+ echo "-- patch $(basename "$patch")"
+ if ! patch -d "$srcdir" -p1 -N -s -f < "$patch"; then
+ echo "error: patch failed: $(basename "$patch")" >&2
+ failed=1
+ fi
+ done
+ [[ "$failed" -eq 0 ]]
+}
+
+ax25_link_flags() {
+ if [[ "$NEED_LIBAX25" -eq 1 ]]; then
+ export CPPFLAGS="-I${PREFIX}/include ${CPPFLAGS:-}"
+ export LDFLAGS="-L${PREFIX}/lib -Wl,-rpath,${PREFIX}/lib ${LDFLAGS:-}"
+ elif [[ -d /usr/include/netax25 ]]; then
+ export CPPFLAGS="-I/usr/include ${CPPFLAGS:-}"
+ export LDFLAGS="${LDFLAGS:-}"
+ fi
+}
+
+build_libax25() {
+ local dir="${BUILD_DIR}/libax25-${LIBAX25_VER}"
+ echo "== libax25 ${LIBAX25_VER} -> ${PREFIX}"
+ extract "${VENDOR}/libax25-${LIBAX25_VER}.tar.gz" "$dir"
+ (
+ cd "$dir"
+ ./configure --prefix="$PREFIX"
+ "${MAKE:-make}" -j"$(nproc 2>/dev/null || echo 2)"
+ "${MAKE:-make}" install
+ )
+}
+
+build_ax25_tools() {
+ local dir="${BUILD_DIR}/ax25-tools-${TOOLS_VER}"
+ echo "== ax25-tools ${TOOLS_VER} -> ${PREFIX}"
+ extract "${VENDOR}/ax25-tools-${TOOLS_VER}.tar.gz" "$dir"
+ ax25_link_flags
+ (
+ cd "$dir"
+ ./configure --prefix="$PREFIX"
+ "${MAKE:-make}" -j"$(nproc 2>/dev/null || echo 2)"
+ "${MAKE:-make}" install
+ )
+}
+
+build_ax25_apps() {
+ local dir="${BUILD_DIR}/ax25-apps-${APPS_VER}"
+ echo "== ax25-apps ${APPS_VER} -> ${PREFIX}"
+ extract "${VENDOR}/ax25-apps-${APPS_VER}.tar.gz" "$dir"
+ apply_patches "$dir" "${PATCHES}/ax25-apps-0.0.8-termios.patch"
+ ax25_link_flags
+ (
+ cd "$dir"
+ ./configure --prefix="$PREFIX"
+ "${MAKE:-make}" -j"$(nproc 2>/dev/null || echo 2)"
+ "${MAKE:-make}" install
+ )
+}
+
+[[ "$NEED_LIBAX25" -eq 1 ]] && build_libax25
+[[ "$NEED_TOOLS" -eq 1 ]] && build_ax25_tools
+[[ "$NEED_APPS" -eq 1 ]] && build_ax25_apps
+
+echo "== AX.25 deps ready under ${PREFIX} =="
diff --git a/scripts/build-freebsd.sh b/scripts/build-freebsd.sh
new file mode 100755
index 0000000..9c9ac6c
--- /dev/null
+++ b/scripts/build-freebsd.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+# build-freebsd.sh — MAX25-Stack-Lite FreeBSD build (max25d, max25-bcpr, max25-terminal, CRDOP/OSS)
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$ROOT"
+
+BUILD_TYPE="${CMAKE_BUILD_TYPE:-Release}"
+BUILD_DIR="${BUILD_DIR:-build}"
+
+if [[ "$(uname -s)" != "FreeBSD" ]]; then
+ echo "build-freebsd.sh: FreeBSD only" >&2
+ exit 1
+fi
+
+echo "== MAX25-Stack-Lite FreeBSD build -> ${BUILD_DIR} (${BUILD_TYPE}) =="
+
+cmake -B "${BUILD_DIR}" \
+ -DCMAKE_BUILD_TYPE="${BUILD_TYPE}" \
+ -DMAX25_BUILD_TNCS=OFF \
+ -DMAX25_BUILD_MAX25_BCPR=ON \
+ -DMAX25_BUILD_CRDOP=ON \
+ -DMAX25_BUILD_DAEMON=ON \
+ -DMAX25_BUILD_TERMINAL=ON \
+ -DMAX25_BUNDLE_AX25=OFF \
+ "$@"
+
+cmake --build "${BUILD_DIR}" -j"$(sysctl -n hw.ncpu 2>/dev/null || echo 2)"
+
+echo "== build done: ${BUILD_DIR}/bin =="
diff --git a/scripts/build.sh b/scripts/build.sh
new file mode 100755
index 0000000..87c957d
--- /dev/null
+++ b/scripts/build.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+# MainAX25-Stack (MAX25-Stack) — configure and build via CMake.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$ROOT"
+
+BUILD_TYPE="${CMAKE_BUILD_TYPE:-Release}"
+
+cmake -B build -DCMAKE_BUILD_TYPE="${BUILD_TYPE}"
+cmake --build build -j"$(nproc 2>/dev/null || echo 2)"
diff --git a/scripts/clean.sh b/scripts/clean.sh
new file mode 100755
index 0000000..9447da2
--- /dev/null
+++ b/scripts/clean.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+# MainAX25-Stack (MAX25-Stack) — remove CMake build trees.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$ROOT"
+
+rm -rf build stacks/crdop/build
+echo "Removed build/ and stacks/crdop/build/"
diff --git a/scripts/discover-plugins.sh b/scripts/discover-plugins.sh
new file mode 100755
index 0000000..651b963
--- /dev/null
+++ b/scripts/discover-plugins.sh
@@ -0,0 +1,97 @@
+#!/usr/bin/env bash
+# discover-plugins.sh — list device backends (Lite: configured directly in max25d.ini, no plugins/ tree).
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+MANIFEST="${ROOT}/plugins/manifest.yaml"
+JSON=0
+
+usage() {
+ cat <<'EOF'
+Usage: discover-plugins.sh [--json] [--type TYPE] [--id ID]
+
+ --json Machine-readable output (one JSON object per line)
+ --type TYPE Filter: hardware | device
+ --id ID Show single plugin by id
+EOF
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --json) JSON=1; shift ;;
+ --type) TYPE_FILTER="${2:-}"; shift 2 ;;
+ --id) ID_FILTER="${2:-}"; shift 2 ;;
+ -h|--help) usage; exit 0 ;;
+ *) echo "Unknown option: $1" >&2; usage; exit 1 ;;
+ esac
+done
+
+if [[ ! -f "$MANIFEST" ]]; then
+ echo "discover-plugins: no plugin manifest in MAX25-Stack-Lite — devices are set in max25d.ini directly (see README.md / max25-lite-setup)" >&2
+ exit 1
+fi
+
+emit_plugin() {
+ local type="$1" id="$2" path="$3" extra="${4:-}"
+ local full="${ROOT}/plugins/${path}"
+ local yaml="${full}/plugin.yaml"
+ local label="" status="" hybbx=""
+
+ if [[ -f "$yaml" ]]; then
+ label=$(grep -E '^label:' "$yaml" 2>/dev/null | head -1 | sed 's/^label:[[:space:]]*//' || true)
+ status=$(grep -E '^status:' "$yaml" 2>/dev/null | head -1 | sed 's/^status:[[:space:]]*//' || true)
+ hybbx=$(grep -E '^(hybbx_plugin|plugin):' "$yaml" 2>/dev/null | head -1 | sed 's/^[^:]*:[[:space:]]*//' || true)
+ fi
+
+ if [[ -n "${TYPE_FILTER:-}" && "$type" != "$TYPE_FILTER" ]]; then return; fi
+ if [[ -n "${ID_FILTER:-}" && "$id" != "$ID_FILTER" ]]; then return; fi
+
+ if [[ "$JSON" -eq 1 ]]; then
+ printf '{"type":"%s","id":"%s","path":"plugins/%s","label":"%s","status":"%s","hybbx":"%s"%s}\n' \
+ "$type" "$id" "$path" "$label" "$status" "$hybbx" "$extra"
+ else
+ printf '%-14s %-18s %-30s %s\n' "$type" "$id" "plugins/$path" "${label:-—}"
+ fi
+}
+
+if [[ "$JSON" -eq 0 && -z "${ID_FILTER:-}" ]]; then
+ printf '%-14s %-18s %-30s %s\n' "TYPE" "ID" "PATH" "LABEL"
+ printf '%s\n' "--------------------------------------------------------------------------------"
+fi
+
+# Parse manifest sections (simple line-based — no PyYAML dependency)
+section=""
+while IFS= read -r line || [[ -n "$line" ]]; do
+ line="${line%%#*}"
+ line="${line%"${line##*[![:space:]]}"}"
+ [[ -z "$line" ]] && continue
+
+ case "$line" in
+ betriebsform:) section=""; continue ;;
+ hardware:) section=hardware; continue ;;
+ devices:) section=device; continue ;;
+ schema_version:*|stack:*|description:*) continue ;;
+ esac
+
+ [[ -z "$section" ]] && continue
+
+ if [[ "$line" =~ ^[[:space:]]*-[[:space:]]+id:[[:space:]]*(.+)$ ]]; then
+ cur_id="${BASH_REMATCH[1]}"
+ cur_path=""
+ continue
+ fi
+ if [[ -n "${cur_id:-}" && "$line" =~ ^[[:space:]]+path:[[:space:]]*(.+)$ ]]; then
+ cur_path="${BASH_REMATCH[1]}"
+ emit_plugin "$section" "$cur_id" "$cur_path"
+ cur_id=""
+ fi
+done < "$MANIFEST"
+
+# Also scan for plugin.yaml not in manifest (future auto-gen)
+while IFS= read -r yaml; do
+ dir="$(dirname "$yaml")"
+ rel="${dir#${ROOT}/plugins/}"
+ id="$(grep -E '^id:' "$yaml" | head -1 | sed 's/^id:[[:space:]]*//' || basename "$dir")"
+ type="$(grep -E '^type:' "$yaml" | head -1 | sed 's/^type:[[:space:]]*//' || echo unknown)"
+ # Skip if already emitted via manifest for same id+type would duplicate — acceptable for scaffold
+done < <(find "${ROOT}/plugins" -name plugin.yaml -not -path '*/betriebsform/*' 2>/dev/null | sort)
diff --git a/scripts/install-freebsd.sh b/scripts/install-freebsd.sh
new file mode 100755
index 0000000..3cc89ed
--- /dev/null
+++ b/scripts/install-freebsd.sh
@@ -0,0 +1,111 @@
+#!/usr/bin/env bash
+# install-freebsd.sh — MAX25-Stack-Lite FreeBSD build + install (daemon, max25-bcpr, terminal, CRDOP/OSS)
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+PREFIX="${PREFIX:-/usr/local}"
+ETC="${ETC:-${PREFIX}/etc/max25}"
+INSTALL_DEPS=0
+BUILD_ONLY=0
+BUILD_DIR="${BUILD_DIR:-build}"
+
+usage() {
+ cat <<EOF
+MAX25-Stack-Lite install — FreeBSD
+
+Usage: $0 [options]
+
+Options:
+ --deps Install pkg build/runtime dependencies
+ --build-only Build only, do not install
+ --prefix DIR Install prefix (default: /usr/local)
+ --build-dir D CMake build dir (default: build)
+ -h, --help This help
+
+Build (default): max25d, max25-bcpr, max25-terminal, CRDOP (OSS audio)
+
+After install:
+ sudo mkdir -p /var/run/max25 ${ETC}
+ sudo cp ${PREFIX}/share/max25/max25d.lite.bcpr.ini.example ${ETC}/max25d.ini
+ sudo cp ${PREFIX}/share/max25-bcpr/max25-bcpr.freebsd.ini.example ${ETC}/max25-bcpr.ini
+ sudo max25-bcpr-ctl start --ini ${ETC}/max25-bcpr.ini
+ sudo ${ROOT}/scripts/run-max25d.sh ${ETC}/max25d.ini
+
+Doc index: docs/README.md (stub — depth docs live in the research vault)
+EOF
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --deps) INSTALL_DEPS=1; shift ;;
+ --build-only) BUILD_ONLY=1; shift ;;
+ --prefix) PREFIX="$2"; shift 2 ;;
+ --build-dir) BUILD_DIR="$2"; shift 2 ;;
+ -h|--help) usage; exit 0 ;;
+ *) echo "Unknown option: $1" >&2; usage; exit 1 ;;
+ esac
+done
+
+if [[ "$(uname -s)" != "FreeBSD" ]]; then
+ echo "install-freebsd.sh: FreeBSD only" >&2
+ exit 1
+fi
+
+echo "== MAX25 install: $(uname -s) $(uname -m) → ${PREFIX} =="
+
+if [[ "$INSTALL_DEPS" -eq 1 ]]; then
+ pkgs=(cmake python3 ncurses sox git gmake pkgconf)
+ echo "Installing packages: ${pkgs[*]}"
+ pkg install -y "${pkgs[@]}"
+fi
+
+BUILD_DIR="${BUILD_DIR}" "${ROOT}/scripts/build-freebsd.sh"
+
+if [[ "$BUILD_ONLY" -eq 1 ]]; then
+ echo "== build done (no install) =="
+ exit 0
+fi
+
+echo "-- install to ${PREFIX}"
+if [[ "$(id -u)" -eq 0 ]]; then
+ cmake --install "${ROOT}/${BUILD_DIR}" --prefix "${PREFIX}"
+else
+ sudo cmake --install "${ROOT}/${BUILD_DIR}" --prefix "${PREFIX}"
+fi
+
+_install_ini() {
+ local src="$1" dst="$2"
+ if [[ ! -f "$dst" ]] && [[ -f "$src" ]]; then
+ mkdir -p "$(dirname "$dst")"
+ if [[ "$(id -u)" -eq 0 ]]; then
+ cp "$src" "$dst"
+ else
+ sudo cp "$src" "$dst"
+ fi
+ echo " installed $dst (from example)"
+ fi
+}
+
+if [[ "$(id -u)" -eq 0 ]] || sudo -n true 2>/dev/null; then
+ _install_ini "${ROOT}/share/max25-bcpr/max25-bcpr.freebsd.ini.example" "${ETC}/max25-bcpr.ini"
+ _install_ini "${ROOT}/share/max25/max25d.lite.bcpr.ini.example" "${ETC}/max25d.ini"
+fi
+
+echo ""
+echo "== MAX25-Stack-Lite installed to ${PREFIX} =="
+echo " max25d, max25-terminal → ${PREFIX}/bin"
+echo " max25-bcpr-ctl, max25-bcprd-init → ${PREFIX}/sbin"
+echo " examples → ${PREFIX}/share/max25/"
+echo ""
+echo "Next steps (PC-COM / ExSys cuau2 — edit INI before live RF):"
+echo " pw groupmod dialer -m \$USER"
+echo " sudo mkdir -p /var/run/max25 ${ETC}"
+echo " sudo chown \$USER:dialer /var/run/max25"
+echo " # ${ETC}/max25-bcpr.ini: serial=/dev/cuau2 iobase=0xd090 irq=36"
+echo " # ${ETC}/max25d.ini: [daemon] user=… group=dialer"
+echo " sudo max25-bcpr-ctl start --ini ${ETC}/max25-bcpr.ini"
+echo " sudo ${ROOT}/scripts/run-max25d.sh ${ETC}/max25d.ini"
+echo " max25-terminal -H 127.0.0.1 -p 7325 -d max25e0"
+echo ""
+echo "Optional: CRDOP/OSS → ${PREFIX}/share/crdop/crdop.freebsd.ini.example"
+echo ""
diff --git a/scripts/install-max25.sh b/scripts/install-max25.sh
new file mode 100755
index 0000000..44e3eb4
--- /dev/null
+++ b/scripts/install-max25.sh
@@ -0,0 +1,116 @@
+#!/usr/bin/env bash
+# install-max25.sh — MAX25-Stack-Lite build and install on Linux (daemon, bcpr, terminal, tests).
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+PREFIX="${PREFIX:-/usr/local}"
+INSTALL_DEPS=0
+BUILD_ONLY=0
+BUILD_DIR="${BUILD_DIR:-build}"
+
+usage() {
+ cat <<EOF
+MAX25-Stack-Lite install — Linux
+
+Usage: $0 [options]
+
+Options:
+ --deps Install apt build dependencies
+ --build-only Build only, do not install
+ --prefix DIR Install prefix (default: /usr/local)
+ --build-dir D CMake build dir (default: build)
+ -h, --help This help
+
+After install:
+ run-max25d
+ ./scripts/tx-rx-test.sh --device modem --live # from the source tree; RX before TX (§0.20)
+ run-max25-terminal -U /run/max25/modem.sock
+
+Doc index: docs/README.md (stub — depth docs live in the research vault)
+EOF
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --deps) INSTALL_DEPS=1; shift ;;
+ --build-only) BUILD_ONLY=1; shift ;;
+ --prefix) PREFIX="$2"; shift 2 ;;
+ --build-dir) BUILD_DIR="$2"; shift 2 ;;
+ -h|--help) usage; exit 0 ;;
+ *) echo "Unknown option: $1" >&2; usage; exit 1 ;;
+ esac
+done
+
+if [[ "$(uname -s)" != "Linux" ]]; then
+ echo "install-max25.sh: Linux only" >&2
+ exit 1
+fi
+
+echo "== MAX25 install: $(uname -s) $(uname -m) → ${PREFIX} =="
+
+if [[ "$INSTALL_DEPS" -eq 1 ]]; then
+ if command -v apt-get >/dev/null 2>&1; then
+ sudo apt-get update
+ pkgs=(build-essential make cmake pkg-config git python3 libncurses-dev libasound2-dev)
+ sudo apt-get install -y "${pkgs[@]}" || true
+ fi
+fi
+
+cd "$ROOT"
+echo "-- cmake (BCPR+TERMINAL+DAEMON ON — max25-bcpr default)"
+cmake -B "${BUILD_DIR}" \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DMAX25_BUILD_MAX25_BCPR=ON \
+ -DMAX25_BUILD_TERMINAL=ON \
+ -DMAX25_BUILD_DAEMON=ON \
+ -DMAX25_BUNDLE_AX25=OFF
+cmake --build "${BUILD_DIR}" -j"$(nproc 2>/dev/null || echo 2)"
+
+if [[ "$BUILD_ONLY" -eq 1 ]]; then
+ echo "== build done (no install) =="
+ exit 0
+fi
+
+echo "-- install to ${PREFIX}"
+if [[ "$(id -u)" -eq 0 ]]; then
+ cmake --install "${BUILD_DIR}" --prefix "$PREFIX"
+else
+ sudo cmake --install "${BUILD_DIR}" --prefix "$PREFIX"
+fi
+
+echo ""
+echo "== MAX25 installed to ${PREFIX} =="
+echo " max25d, kiss_bridge.py, max25-terminal, max25-ctl -> ${PREFIX}/bin"
+echo " max25-bcpr-ctl -> ${PREFIX}/sbin (when MAX25_BUILD_MAX25_BCPR=ON)"
+echo " examples -> ${PREFIX}/share/max25/"
+echo ""
+
+echo ""
+echo "Next steps (serial TNC / USB KISS):"
+echo " sudo usermod -aG dialout \$USER # serial access"
+echo " sudo cp share/max25/max25d.lite.ini.example /etc/max25/max25d.ini"
+echo " sudo max25d -c /etc/max25/max25d.ini"
+echo " max25-terminal -U /run/max25/modem.sock"
+echo ""
+echo "BayCom/based (max25-bcpr): built by default (MAX25_BUILD_MAX25_BCPR=ON)."
+echo " See stacks/max25-bcpr/README.md · share/max25-bcpr/max25-bcpr.ini.example"
+echo ""
+ETC="${ETC:-/etc/max25}"
+if [[ "$(id -u)" -eq 0 ]] || sudo -n true 2>/dev/null; then
+ _install_ini() {
+ local src="$1" dst="$2"
+ if [[ ! -f "$dst" ]] && [[ -f "$src" ]]; then
+ mkdir -p "$ETC"
+ if [[ "$(id -u)" -eq 0 ]]; then
+ cp "$src" "$dst"
+ else
+ sudo cp "$src" "$dst"
+ fi
+ echo " installed $dst (from example)"
+ fi
+ }
+ _install_ini "$ROOT/share/max25-bcpr/max25-bcpr.ini.example" "$ETC/max25-bcpr.ini"
+ if [[ -f "$ETC/bcpr.ini" ]] && [[ ! -L "$ETC/max25-bcpr.ini" ]]; then
+ echo " note: legacy $ETC/bcpr.ini kept — paths /tmp/bcpr auto-map to /tmp/max25-bcpr at runtime"
+ fi
+fi
diff --git a/scripts/max25-ctl b/scripts/max25-ctl
new file mode 100755
index 0000000..473b4a2
--- /dev/null
+++ b/scripts/max25-ctl
@@ -0,0 +1,408 @@
+#!/usr/bin/env bash
+# max25-ctl — MAX25-Stack-Lite control entry point (Packet Radio / AX.25, localhost).
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PREFIX=""
+ROOT=""
+
+if [[ -f "${SCRIPT_DIR}/../plugins/manifest.yaml" ]]; then
+ ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
+elif [[ -d "${SCRIPT_DIR}/../share/max25" ]]; then
+ PREFIX="$(cd "${SCRIPT_DIR}/.." && pwd)"
+ ROOT="${MAX25_ROOT:-${PREFIX}}"
+else
+ ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
+fi
+
+stack_script() {
+ local rel="$1"
+ if [[ -f "${ROOT}/stacks/${rel}" ]]; then
+ echo "${ROOT}/stacks/${rel}"
+ return 0
+ fi
+ if [[ -n "${PREFIX}" && -f "${PREFIX}/share/max25/stacks/${rel}" ]]; then
+ echo "${PREFIX}/share/max25/stacks/${rel}"
+ return 0
+ fi
+ return 1
+}
+
+resolve_crdop_bin() {
+ if [[ -n "${CRDOP_BIN:-}" && -x "${CRDOP_BIN}" ]]; then
+ echo "${CRDOP_BIN}"
+ return 0
+ fi
+ if command -v crdopc >/dev/null 2>&1; then
+ command -v crdopc
+ return 0
+ fi
+ if [[ -x "${ROOT}/build/bin/crdopc" ]]; then
+ echo "${ROOT}/build/bin/crdopc"
+ return 0
+ fi
+ return 1
+}
+
+resolve_baycom_ctl() {
+ local ctl=""
+ ctl="$(stack_script "baycom-pr/scripts/baycom-pr-ctl" 2>/dev/null || true)"
+ if [[ -n "${ctl}" && -x "${ctl}" ]]; then
+ echo "${ctl}"
+ return 0
+ fi
+ if command -v baycom-pr-ctl >/dev/null 2>&1; then
+ command -v baycom-pr-ctl
+ return 0
+ fi
+ return 1
+}
+
+MODE="${MAX25_MODE:-standalone}"
+HARDWARE=""
+DEVICE=""
+BAYCOM_INI=""
+BAYCOM_PROFILE=""
+ACTION="${1:-help}"
+shift || true
+
+usage() {
+ cat <<EOF
+max25-ctl — MAX25-Stack-Lite control (Packet Radio / AX.25, localhost)
+
+Usage:
+ max25-ctl <command> [options]
+
+Commands:
+ help This help
+ discover List local device backends
+ build Build the project (cmake -B build && cmake --build build)
+ start Start a hardware/soft-modem path
+ stop Stop a hardware/soft-modem path
+ status Show device status
+ test Offline tests (no root)
+
+Daemon (Linux):
+ stacks/daemon/max25d Launcher (ps shows this path, not python3)
+ stacks/daemon/max25d.py M25/1 daemon implementation
+ stacks/terminal/max25-terminal Operator UI (F10 menu, CALLERID/CALLID)
+
+Options (start/stop/status):
+ --hardware HW modems | soft-modems
+ --device DEV max25e0 (BayCom/based) | baycom-kiss | soft-crdop
+
+Examples:
+ max25-ctl discover
+ max25-ctl build
+ max25-ctl start --hardware modems --device max25e0
+ max25-ctl start --hardware modems --device baycom-kiss
+ max25-ctl start --hardware soft-modems --device soft-crdop
+ max25-ctl test
+
+Lite scope (max25-terminal, max25d, BayCom/based, CRDOP): README.md
+ SET DEVICE <id> → CONNECT → SEND via max25-terminal after max25d is up
+EOF
+}
+
+parse_opts() {
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --mode) MODE="$2"; shift 2 ;;
+ --hardware) HARDWARE="$2"; shift 2 ;;
+ --device) DEVICE="$2"; shift 2 ;;
+ --baycom-ini) BAYCOM_INI="$2"; shift 2 ;;
+ --baycom-profile) BAYCOM_PROFILE="$2"; shift 2 ;;
+ *) echo "Unknown option: $1" >&2; usage; exit 1 ;;
+ esac
+ done
+}
+
+resolve_baycom_ini_path() {
+ local device="${1:-max25e0}"
+ local explicit="${2:-${BAYCOM_INI:-}}"
+ local profile="${3:-${BAYCOM_PROFILE:-}}"
+
+ if [[ -z "${explicit}" && -n "${profile}" ]]; then
+ local paths_py=""
+ if [[ -f "${ROOT}/stacks/daemon/paths.py" ]]; then
+ paths_py="${ROOT}/stacks/daemon/paths.py"
+ elif [[ -n "${PREFIX}" && -f "${PREFIX}/bin/paths.py" ]]; then
+ paths_py="${PREFIX}/bin/paths.py"
+ fi
+ if [[ -n "${paths_py}" ]]; then
+ local prefix_py="None"
+ if [[ -n "${PREFIX}" ]]; then
+ prefix_py="Path('${PREFIX}')"
+ fi
+ explicit="$(python3 -c "
+import sys
+from pathlib import Path
+sys.path.insert(0, '$(dirname "${paths_py}")')
+from paths import resolve_baycom_profile
+p = resolve_baycom_profile('${profile}', '${device}', Path('${ROOT}'), ${prefix_py})
+print(p or '')
+" 2>/dev/null || true)"
+ if [[ -z "${explicit}" ]]; then
+ echo "baycom profile '${profile}' not found (use --baycom-ini PATH)" >&2
+ return 1
+ fi
+ elif [[ "${profile}" == "dual" ]]; then
+ for candidate in \
+ "${ROOT}/stacks/baycom-pr/config/examples/baycom-pr.dual.ini" \
+ "${PREFIX}/share/max25/stacks/baycom-pr/config/examples/baycom-pr.dual.ini"; do
+ if [[ -f "${candidate}" ]]; then
+ explicit="${candidate}"
+ break
+ fi
+ done
+ if [[ -z "${explicit}" ]]; then
+ echo "dual baycom-pr.ini not found — copy stacks/baycom-pr/config/examples/baycom-pr.dual.ini" >&2
+ return 1
+ fi
+ fi
+ fi
+
+ local paths_py=""
+ if [[ -f "${ROOT}/stacks/daemon/paths.py" ]]; then
+ paths_py="${ROOT}/stacks/daemon/paths.py"
+ elif [[ -n "${PREFIX}" && -f "${PREFIX}/bin/paths.py" ]]; then
+ paths_py="${PREFIX}/bin/paths.py"
+ fi
+ if [[ -n "${paths_py}" ]]; then
+ local resolved=""
+ local prefix_py="None"
+ if [[ -n "${PREFIX}" ]]; then
+ prefix_py="Path('${PREFIX}')"
+ fi
+ resolved="$(python3 -c "
+import sys
+from pathlib import Path
+sys.path.insert(0, '$(dirname "${paths_py}")')
+from paths import resolve_baycom_ini
+p = resolve_baycom_ini('${device}', Path('${ROOT}'), ${prefix_py}, '''${explicit}''')
+print(p or '')
+" 2>/dev/null || true)"
+ if [[ -n "${resolved}" && -f "${resolved}" ]]; then
+ echo "${resolved}"
+ return 0
+ fi
+ fi
+
+ if [[ -n "${explicit}" ]]; then
+ return 1
+ fi
+
+ local candidate=""
+ for candidate in \
+ "/etc/baycom/baycom-pr.ini" \
+ "/etc/baycom/baycom-pr-single.ini" \
+ "${ROOT}/local/baycom-pr.ini" \
+ "${ROOT}/share/baycom/baycom-pr.pccom-ttyS0-only.ini.example" \
+ "${PREFIX}/share/max25/baycom/baycom-pr.pccom-ttyS0-only.ini.example" \
+ "${ROOT}/stacks/baycom-pr/config/baycom-pr.ini"; do
+ if [[ -f "${candidate}" ]]; then
+ if [[ "${candidate}" == "/etc/baycom/baycom-pr.ini" ]]; then
+ local dual=""
+ dual="$(python3 -c "
+from pathlib import Path
+import sys
+sys.path.insert(0, '${ROOT}/stacks/daemon')
+try:
+ from paths import is_dual_baycom_ini
+ print('dual' if is_dual_baycom_ini(Path('/etc/baycom/baycom-pr.ini')) else 'single')
+except ImportError:
+ print('single')
+" 2>/dev/null || echo "single")"
+ if [[ "${dual}" == "dual" ]]; then
+ continue
+ fi
+ fi
+ echo "${candidate}"
+ return 0
+ fi
+ done
+ return 1
+}
+
+start_baycom_kernel() {
+ # Kernel baycom-pr removed 2026-07-18 — product path is bcpr.
+ echo "BayCom/based uses max25-bcpr (not old kernel BayCom)." >&2
+ echo "Start PC-COM first: sudo max25-bcpr-ctl start --ini /etc/max25/max25-bcpr.ini" >&2
+ echo "Then max25d — see stacks/max25-bcpr/README.md" >&2
+ echo "Frozen archive: MASTER vault archives/legacy/max25-stack-baycom-kernel-compa/" >&2
+ exit 1
+}
+
+cmd_discover() {
+ if [[ ! -f "${ROOT}/scripts/discover-plugins.sh" ]]; then
+ echo "discover: source checkout required (set MAX25_ROOT)" >&2
+ exit 1
+ fi
+ bash "${ROOT}/scripts/discover-plugins.sh" "$@"
+}
+
+cmd_build() {
+ if [[ ! -f "${ROOT}/scripts/build.sh" ]]; then
+ echo "build: source checkout required (set MAX25_ROOT)" >&2
+ exit 1
+ fi
+ bash "${ROOT}/scripts/build.sh"
+}
+
+cmd_start() {
+ parse_opts "$@"
+ case "$HARDWARE" in
+ tncs)
+ case "$DEVICE" in
+ tnc2c|"")
+ local boot=""
+ boot="$(stack_script "tncs/tnc2c-boot-wait.sh")"
+ if [[ "${MAX25_TNC_PREP:-}" == "recover" ]]; then
+ echo "TNC recover-only (software ladder, no power cycle)"
+ exec "${boot}" --recover-only
+ fi
+ echo "TNC boot-wait rescue: power-cycle TNC while script runs if needed"
+ exec "${boot}"
+ ;;
+ pktnc2)
+ local boot=""
+ boot="$(stack_script "tncs/pktnc2-boot-wait.sh")"
+ if [[ "${MAX25_TNC_PREP:-}" == "recover" ]]; then
+ echo "PK-TNC2 recover-only (software ladder, no power cycle)"
+ exec "${boot}" --recover-only
+ fi
+ exec "${boot}"
+ ;;
+ *) echo "Unknown TNC device: $DEVICE" >&2; exit 1 ;;
+ esac
+ ;;
+ modems)
+ case "$DEVICE" in
+ baycom-kiss|pccom-kiss)
+ echo "baycom-kiss: USB/async KISS — no kernel stack start"
+ echo "Configure max25d [devices] baycom-kiss and [serial.baycom-kiss]; max25d opens serial directly."
+ exit 0
+ ;;
+ bcpr|max25e0|max25e0:bc0|max25e0:bc1|"")
+ echo "BayCom/based PC-COM: sudo max25-bcpr-ctl start -c /etc/max25/max25-bcpr.ini" >&2
+ echo "Then: ./scripts/run-max25d.sh — see stacks/max25-bcpr/README.md" >&2
+ echo "Canonical: scripts/run-max25d.sh" >&2
+ exit 1
+ ;;
+ baycom-par96|baycom-ser12|baycom-a|baycom-b)
+ start_baycom_kernel "${DEVICE}"
+ ;;
+ *)
+ echo "Unknown modem device: $DEVICE (use max25e0 / baycom-kiss)" >&2
+ exit 1
+ ;;
+ esac
+ ;;
+ soft-modems)
+ case "$DEVICE" in
+ soft-crdop|"")
+ local crdop_bin=""
+ crdop_bin="$(resolve_crdop_bin)" || {
+ if [[ -f "${ROOT}/scripts/build.sh" ]]; then
+ echo "Building CRDOP..."
+ bash "${ROOT}/scripts/build.sh"
+ crdop_bin="$(resolve_crdop_bin)" || true
+ fi
+ }
+ if [[ -z "${crdop_bin}" ]]; then
+ echo "crdopc not found in PATH or build/bin" >&2
+ exit 1
+ fi
+ local launcher=""
+ launcher="$(stack_script "crdop/scripts/crdopc" 2>/dev/null || true)"
+ if [[ -z "${launcher}" ]]; then
+ echo "Starting crdopc (HyBBX crdop plugin attaches to TCP :8515)"
+ exec env CRDOP_BIN="${crdop_bin}" crdopc
+ fi
+ echo "Starting crdopc (HyBBX crdop plugin attaches to TCP :8515)"
+ CRDOP_BIN="${crdop_bin}" exec "${launcher}"
+ ;;
+ *)
+ echo "Unknown soft-modem device: $DEVICE" >&2
+ exit 1
+ ;;
+ esac
+ ;;
+ "")
+ echo "Specify --hardware modems|soft-modems" >&2
+ exit 1
+ ;;
+ *)
+ echo "Unknown hardware: $HARDWARE" >&2
+ exit 1
+ ;;
+ esac
+}
+
+cmd_stop() {
+ parse_opts "$@"
+ if [[ "$HARDWARE" == "modems" || -z "$HARDWARE" ]]; then
+ case "$DEVICE" in
+ baycom-kiss)
+ echo "baycom-kiss: no baycom-pr-ctl stop (USB serial owned by max25d)"
+ ;;
+ *)
+ local baycom_ctl=""
+ baycom_ctl="$(resolve_baycom_ctl 2>/dev/null || true)"
+ if [[ -n "${baycom_ctl}" ]]; then
+ local ini=""
+ ini="$(resolve_baycom_ini_path "${DEVICE:-baycom-ser12}" "${BAYCOM_INI}" 2>/dev/null || true)"
+ if [[ -n "${ini}" ]]; then
+ sudo "${baycom_ctl}" -c "${ini}" stop 2>/dev/null || true
+ else
+ sudo "${baycom_ctl}" stop 2>/dev/null || true
+ fi
+ fi
+ ;;
+ esac
+ fi
+ echo "TNC serial: release port (stop other userspace serial owner; fuser on device)"
+}
+
+cmd_status() {
+ parse_opts "$@"
+ echo "MAX25 mode: $MODE"
+ if [[ -f "${ROOT}/scripts/discover-plugins.sh" ]]; then
+ bash "${ROOT}/scripts/discover-plugins.sh" --type device || true
+ else
+ echo "discover: source checkout required (set MAX25_ROOT)"
+ fi
+ local baycom_ctl=""
+ baycom_ctl="$(resolve_baycom_ctl 2>/dev/null || true)"
+ if [[ -n "${baycom_ctl}" && "${DEVICE:-}" != "baycom-kiss" ]]; then
+ local ini=""
+ ini="$(resolve_baycom_ini_path "${DEVICE:-baycom-ser12}" "${BAYCOM_INI}" 2>/dev/null || true)"
+ if [[ -n "${ini}" ]]; then
+ sudo "${baycom_ctl}" -c "${ini}" status 2>/dev/null || echo "baycom-pr: not running or no root"
+ else
+ sudo "${baycom_ctl}" status 2>/dev/null || echo "baycom-pr: not running or no root"
+ fi
+ fi
+}
+
+cmd_test() {
+ if [[ ! -d "${ROOT}/build" ]]; then
+ if [[ ! -f "${ROOT}/scripts/build.sh" ]]; then
+ echo "test: source checkout with build/ required (set MAX25_ROOT)" >&2
+ exit 1
+ fi
+ bash "${ROOT}/scripts/build.sh"
+ fi
+ cmake --build "${ROOT}/build" --target max25_test
+}
+
+case "$ACTION" in
+ help|-h|--help) usage ;;
+ discover) cmd_discover "$@" ;;
+ build) cmd_build ;;
+ start) cmd_start "$@" ;;
+ stop) cmd_stop "$@" ;;
+ status) cmd_status "$@" ;;
+ test) cmd_test ;;
+ *) echo "Unknown command: $ACTION" >&2; usage; exit 1 ;;
+esac
diff --git a/scripts/max25-lite-setup.sh b/scripts/max25-lite-setup.sh
new file mode 100755
index 0000000..412d83e
--- /dev/null
+++ b/scripts/max25-lite-setup.sh
@@ -0,0 +1,517 @@
+#!/usr/bin/env bash
+# MAX25-Stack-Lite — semi-automatic setup wizard (dialog / whiptail)
+# Detects serial + audio devices, writes max25d.ini (+ crdop.ini for CRDOP path).
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+UI_TITLE="MAX25-Stack-Lite setup"
+UI_BACKTITLE="MAX25-Stack-Lite | local packet-radio setup"
+UI_WIDTH=72
+UI_HEIGHT=18
+TMP_FILES=()
+
+UI=""
+if command -v whiptail >/dev/null 2>&1; then
+ UI=whiptail
+elif command -v dialog >/dev/null 2>&1; then
+ UI=dialog
+else
+ echo "max25-lite-setup: install whiptail or dialog, then run this command again." >&2
+ exit 1
+fi
+
+cleanup() {
+ local file
+ for file in "${TMP_FILES[@]}"; do
+ rm -f -- "$file"
+ done
+}
+trap cleanup EXIT HUP INT TERM
+
+require_terminal_size() {
+ local columns lines
+ [[ -t 1 && -t 2 ]] || return 0
+ columns="$(tput cols 2>/dev/null || true)"
+ lines="$(tput lines 2>/dev/null || true)"
+ if [[ "$columns" =~ ^[0-9]+$ && "$lines" =~ ^[0-9]+$ ]] \
+ && ((columns < UI_WIDTH || lines < UI_HEIGHT)); then
+ printf '%s\n' \
+ "max25-lite-setup: terminal is ${columns}x${lines}; need at least ${UI_WIDTH}x${UI_HEIGHT}." \
+ "Resize the terminal and run max25-lite-setup again." >&2
+ exit 1
+ fi
+}
+
+show_help() {
+ "$UI" --backtitle "$UI_BACKTITLE" --title "Setup help" --ok-label "Back" \
+ --msgbox "Use Up/Down to move, Space to mark a radio-list choice, and Enter to continue.
+
+Cancel or Esc stops the wizard without writing configuration files.
+
+The wizard writes local-only configuration. It does not start the modem, key a radio, or transmit.
+
+For serial and BayCom/based paths, select the device connected to this computer. For CRDOP, select the local capture/playback device." \
+ "$UI_HEIGHT" "$UI_WIDTH"
+}
+
+msgbox() {
+ "$UI" --backtitle "$UI_BACKTITLE" --title "$UI_TITLE" --ok-label "Continue" \
+ --msgbox "$1" 14 "$UI_WIDTH"
+}
+
+yesno() {
+ local prompt="$1" rc
+ while :; do
+ if "$UI" --backtitle "$UI_BACKTITLE" --title "$UI_TITLE" \
+ --yes-label "Yes" --no-label "No" --help-button --help-label "Help" \
+ --yesno "$prompt" 10 "$UI_WIDTH"; then
+ return 0
+ else
+ rc=$?
+ fi
+ if [[ "$rc" -eq 2 ]]; then
+ show_help
+ continue
+ fi
+ return "$rc"
+ done
+}
+
+inputbox() {
+ local prompt="$1" default="${2:-}" value rc
+ while :; do
+ if value=$("$UI" --backtitle "$UI_BACKTITLE" --title "$UI_TITLE" \
+ --ok-label "Continue" --cancel-label "Cancel" --help-button --help-label "Help" \
+ --inputbox "$prompt" 10 "$UI_WIDTH" "$default" 3>&1 1>&2 2>&3); then
+ printf '%s\n' "$value"
+ return 0
+ else
+ rc=$?
+ fi
+ if [[ "$rc" -eq 2 ]]; then
+ show_help
+ continue
+ fi
+ return "$rc"
+ done
+}
+
+menu() {
+ local prompt="$1" value rc
+ shift
+ while :; do
+ if value=$("$UI" --backtitle "$UI_BACKTITLE" --title "$UI_TITLE" \
+ --ok-label "Continue" --cancel-label "Cancel" --help-button --help-label "Help" \
+ --menu "$prompt" "$UI_HEIGHT" "$UI_WIDTH" 10 "$@" 3>&1 1>&2 2>&3); then
+ printf '%s\n' "$value"
+ return 0
+ else
+ rc=$?
+ fi
+ if [[ "$rc" -eq 2 ]]; then
+ show_help
+ continue
+ fi
+ return "$rc"
+ done
+}
+
+radiolist() {
+ local prompt="$1" value rc
+ shift
+ while :; do
+ if value=$("$UI" --backtitle "$UI_BACKTITLE" --title "$UI_TITLE" \
+ --ok-label "Continue" --cancel-label "Cancel" --help-button --help-label "Help" \
+ --radiolist "$prompt" "$UI_HEIGHT" "$UI_WIDTH" 10 "$@" 3>&1 1>&2 2>&3); then
+ printf '%s\n' "$value"
+ return 0
+ else
+ rc=$?
+ fi
+ if [[ "$rc" -eq 2 ]]; then
+ show_help
+ continue
+ fi
+ return "$rc"
+ done
+}
+
+require_terminal_size
+
+list_serial_ports() {
+ local -a ports=()
+ shopt -s nullglob
+ for pat in /dev/ttyUSB* /dev/ttyACM* /dev/ttyS*; do
+ [[ -e "$pat" ]] || continue
+ ports+=("$pat")
+ done
+ shopt -u nullglob
+ if ((${#ports[@]} == 0)); then
+ ports=("/dev/ttyUSB0")
+ fi
+ printf '%s\n' "${ports[@]}"
+}
+
+list_alsa_cards() {
+ if command -v arecord >/dev/null 2>&1; then
+ arecord -l 2>/dev/null | awk '/card [0-9]+:/ {c=$2; gsub(":", "", c); desc=$0; gsub(/^.*card [0-9]+: /, "", desc); gsub(/,.*/, "", desc); printf "hw:%s,0 %s\n", c, desc}' || true
+ fi
+ if command -v aplay >/dev/null 2>&1; then
+ aplay -l 2>/dev/null | awk '/card [0-9]+:/ {c=$2; gsub(":", "", c); desc=$0; gsub(/^.*card [0-9]+: /, "", desc); gsub(/,.*/, "", desc); printf "hw:%s,0 %s (playback)\n", c, desc}' || true
+ fi
+}
+
+msgbox "Welcome to MAX25-Stack-Lite setup.
+
+This wizard detects serial and audio devices on this computer, writes max25d.ini, and shows how to start max25d and max25-terminal locally.
+
+It changes files only after you choose a save location. Use Help for key hints, or Cancel to leave without saving."
+
+MODEM_KIND="$(menu "Packet radio modem path:" \
+ serial "Serial TNC / USB KISS (T-Modem, TNC2)" \
+ baycom "BayCom/based PC-COM (SER12 on COM/ttyS)" \
+ crdop "CRDOP sound-card soft modem (MAX25-SoftModem)" \
+ )" || exit 0
+
+DEVICE_ID="tnc2c"
+HARDWARE="tncs"
+SERIAL_PATH=""
+CRDOP_CAPTURE=""
+CRDOP_PLAYBACK=""
+WRITE_CRDOP_INI=0
+WRITE_BCPR_INI=0
+BCPR_INI_DEST=""
+
+if [[ "$MODEM_KIND" == "baycom" ]]; then
+ DEVICE_ID="max25e0"
+ HARDWARE="tncs"
+ WRITE_BCPR_INI=1
+ mapfile -t PORTS < <(list_serial_ports)
+ menu_args=()
+ idx=0
+ for p in "${PORTS[@]}"; do
+ menu_args+=("$p" "serial port" "$((idx == 0 ? 1 : 0))")
+ idx=$((idx + 1))
+ done
+ SERIAL_PATH="$(radiolist "Select PC-COM serial port (often /dev/ttyS0):" "${menu_args[@]}")" || exit 0
+elif [[ "$MODEM_KIND" == "serial" ]]; then
+ mapfile -t PORTS < <(list_serial_ports)
+ menu_args=()
+ idx=0
+ for p in "${PORTS[@]}"; do
+ menu_args+=("$p" "serial port" "$((idx == 0 ? 1 : 0))")
+ idx=$((idx + 1))
+ done
+ SERIAL_PATH="$(radiolist "Select serial device:" "${menu_args[@]}")" || exit 0
+ if [[ "$SERIAL_PATH" == /dev/ttyACM* ]]; then
+ if yesno "Device looks like USB KISS (T-Modem). Use device id tmodem instead of tnc2c?"; then
+ DEVICE_ID="tmodem"
+ fi
+ fi
+else
+ HARDWARE="soft-modems"
+ DEVICE_ID="soft-crdop"
+ WRITE_CRDOP_INI=1
+ mapfile -t ALSA_LINES < <(list_alsa_cards)
+ if ((${#ALSA_LINES[@]} == 0)); then
+ msgbox "No ALSA cards listed (arecord/aplay). You can edit ~/.config/crdop/crdop.ini later."
+ CRDOP_CAPTURE="hw:0,0"
+ CRDOP_PLAYBACK="hw:0,0"
+ else
+ menu_args=()
+ idx=0
+ for line in "${ALSA_LINES[@]}"; do
+ dev="${line%% *}"
+ label="${line#* }"
+ menu_args+=("$dev" "$label" "$((idx == 0 ? 1 : 0))")
+ idx=$((idx + 1))
+ done
+ sel="$(radiolist "Select capture/playback ALSA device (hw:N,0):" "${menu_args[@]}")" || exit 0
+ CRDOP_CAPTURE="$sel"
+ CRDOP_PLAYBACK="$sel"
+ fi
+fi
+
+CALLERID="$(inputbox "Your AX.25 caller ID (e.g. CB-0):" "CB-0")" || exit 0
+CALLID="$(inputbox "Default destination callsign for CONNECT tests:" "QST")" || exit 0
+
+if yesno "Write system config /etc/max25/max25d.ini? (No = ./local/max25d.ini in repo)"; then
+ INI_DEST="/etc/max25/max25d.ini"
+ CRDOP_INI_DEST="${HOME}/.config/crdop/crdop.ini"
+ BCPR_INI_DEST="/etc/max25/max25-bcpr.ini"
+ NEED_SUDO=1
+else
+ INI_DEST="${ROOT}/local/max25d.ini"
+ CRDOP_INI_DEST="${ROOT}/local/crdop.ini"
+ BCPR_INI_DEST="${ROOT}/local/max25-bcpr.ini"
+ NEED_SUDO=0
+fi
+
+if [[ "$NEED_SUDO" -eq 1 ]]; then
+ if ! command -v sudo >/dev/null 2>&1; then
+ msgbox "System configuration needs sudo, but sudo is not available.
+
+Choose No at the save-location screen to write local configuration in this repository."
+ exit 1
+ fi
+ sudo mkdir -p "$(dirname "$INI_DEST")"
+ mkdir -p "$(dirname "$CRDOP_INI_DEST")"
+else
+ mkdir -p "$(dirname "$INI_DEST")"
+fi
+
+write_max25d_ini() {
+ local dest="$1"
+ if [[ "$MODEM_KIND" == "baycom" ]]; then
+ cat >"$dest" <<EOF
+; MAX25-Stack-Lite — generated by max25-lite-setup.sh (BayCom/based)
+
+[features]
+max25_bcpr = yes
+
+[daemon]
+mode = standalone
+hardware = tncs
+device = max25e0
+
+[network]
+tcp_host = 127.0.0.1
+tcp_port = 7325
+unix_socket = /run/max25/modem.sock
+tcp_password =
+
+[modem]
+callerid = ${CALLERID}
+callid = ${CALLID}
+ax25_ui = yes
+
+[reporting]
+error_transmissions = yes
+voice_transmissions = yes
+data_passes = 3
+data_quality_min = 50
+data_pass_seconds = 20
+
+[stack]
+auto_start = yes
+
+[devices]
+default = max25e0
+max25e0 = max25-bcpr:bc0
+
+[device.max25e0]
+kiss_link = /tmp/max25-bcpr/kiss-bc0
+max25_bcpr_ini = ${BCPR_INI_DEST}
+EOF
+ elif [[ "$MODEM_KIND" == "serial" ]]; then
+ cat >"$dest" <<EOF
+; MAX25-Stack-Lite — generated by max25-lite-setup.sh
+
+[daemon]
+mode = standalone
+hardware = ${HARDWARE}
+device = ${DEVICE_ID}
+
+[network]
+tcp_host = 127.0.0.1
+tcp_port = 7325
+unix_socket = /run/max25/modem.sock
+; Lite: localhost only — terminal on same host as max25d
+tcp_password =
+
+[modem]
+callerid = ${CALLERID}
+callid = ${CALLID}
+ax25_ui = yes
+
+[reporting]
+error_transmissions = yes
+voice_transmissions = yes
+data_passes = 3
+data_quality_min = 50
+data_pass_seconds = 20
+
+[stack]
+auto_start = yes
+serial_watch = yes
+serial_watch_interval = 60
+stack_recover_only = yes
+serial_bootwait_escalate = yes
+serial_bootwait_escalate_after = 3
+
+[devices]
+default = ${DEVICE_ID}
+${DEVICE_ID} = ${SERIAL_PATH}
+
+[serial.${DEVICE_ID}]
+baud = 19200
+line = 8n1
+dtr_rts = yes
+kiss_entry = kiss_on
+EOF
+ else
+ cat >"$dest" <<EOF
+; MAX25-Stack-Lite — generated by max25-lite-setup.sh (CRDOP)
+
+[daemon]
+mode = standalone
+hardware = soft-modems
+device = soft-crdop
+
+[network]
+tcp_host = 127.0.0.1
+tcp_port = 7325
+unix_socket = /run/max25/modem.sock
+; Lite: localhost only — terminal on same host as max25d
+tcp_password =
+
+[modem]
+callerid = ${CALLERID}
+callid = ${CALLID}
+ax25_ui = yes
+
+[reporting]
+error_transmissions = yes
+voice_transmissions = yes
+data_passes = 3
+data_quality_min = 50
+data_pass_seconds = 20
+
+[stack]
+auto_start = yes
+
+[devices]
+default = soft-crdop
+soft-crdop = crdop:default
+
+[device.soft-crdop]
+host = 127.0.0.1
+port = 8515
+listen = yes
+EOF
+ fi
+}
+
+write_crdop_ini() {
+ local dest="$1"
+ mkdir -p "$(dirname "$dest")"
+ cat >"$dest" <<EOF
+; MAX25-SoftModem (CRDOP) — generated by max25-lite-setup.sh
+
+[profile]
+radio_profile = cb
+
+[modem]
+speed_baud = 1200
+duplex = half
+max_speed_baud = 19200
+
+[host]
+port = 8515
+
+[mycall]
+call = ${CALLERID}
+
+[audio]
+backend = alsa-kernel
+no_pulse = yes
+capture = ${CRDOP_CAPTURE}
+playback = ${CRDOP_PLAYBACK}
+
+[coupling]
+mode = line
+EOF
+}
+
+write_bcpr_ini() {
+ local dest="$1"
+ mkdir -p "$(dirname "$dest")"
+ cat >"$dest" <<EOF
+; max25-bcpr — BayCom/based PC-COM (generated by max25-lite-setup.sh)
+
+[max25-bcpr]
+dry_run = no
+state_dir = /tmp/max25-bcpr
+user = max25
+group = max25
+
+[bc0]
+enabled = yes
+serial = ${SERIAL_PATH}
+iobase = 0x3f8
+irq = 4
+mode = ser12*
+kiss_link = /tmp/max25-bcpr/kiss-bc0
+baud = 1200
+tx_delay = 35
+tx_tail = 2
+slottime = 10
+ppersist = 40
+fulldup = no
+EOF
+}
+
+if [[ "$NEED_SUDO" -eq 1 ]]; then
+ tmp="$(mktemp)"
+ TMP_FILES+=("$tmp")
+ write_max25d_ini "$tmp"
+ sudo cp "$tmp" "$INI_DEST"
+ if [[ "$WRITE_CRDOP_INI" -eq 1 ]]; then
+ tmp="$(mktemp)"
+ TMP_FILES+=("$tmp")
+ write_crdop_ini "$tmp"
+ cp "$tmp" "$CRDOP_INI_DEST"
+ fi
+ if [[ "$WRITE_BCPR_INI" -eq 1 ]]; then
+ tmp="$(mktemp)"
+ TMP_FILES+=("$tmp")
+ write_bcpr_ini "$tmp"
+ sudo cp "$tmp" "$BCPR_INI_DEST"
+ fi
+else
+ mkdir -p "${ROOT}/local"
+ write_max25d_ini "$INI_DEST"
+ if [[ "$WRITE_CRDOP_INI" -eq 1 ]]; then
+ write_crdop_ini "$CRDOP_INI_DEST"
+ fi
+ if [[ "$WRITE_BCPR_INI" -eq 1 ]]; then
+ write_bcpr_ini "$BCPR_INI_DEST"
+ fi
+fi
+
+NEXT_STEPS="Configuration written:
+
+ max25d.ini → ${INI_DEST}"
+if [[ "$WRITE_CRDOP_INI" -eq 1 ]]; then
+ NEXT_STEPS="${NEXT_STEPS}
+ crdop.ini → ${CRDOP_INI_DEST}"
+fi
+if [[ "$WRITE_BCPR_INI" -eq 1 ]]; then
+ NEXT_STEPS="${NEXT_STEPS}
+ max25-bcpr.ini → ${BCPR_INI_DEST}"
+fi
+NEXT_STEPS="${NEXT_STEPS}
+
+Build (if not done):
+ cmake -B build && cmake --build build
+ sudo cmake --install build
+
+BayCom/based only — start bcpr first (needs root on Linux):
+ sudo max25-bcpr-ctl start --ini ${BCPR_INI_DEST}
+
+Start daemon:
+ ${ROOT}/scripts/run-max25d.sh ${INI_DEST}
+
+CRDOP only — start soft modem (separate terminal):
+ max25-ctl start --hardware soft-modems --device soft-crdop
+ (or: crdop -c ${CRDOP_INI_DEST})
+
+Terminal + CONNECT:
+ ${ROOT}/scripts/run-max25-terminal.sh -U /run/max25/modem.sock
+ At prompt: CONNECT ${CALLID}
+ Or F10 menu → Connect
+
+RX before TX: confirm receive/noise/SQ before live transmit."
+
+"$UI" --backtitle "$UI_BACKTITLE" --title "Setup complete" --ok-label "Done" \
+ --msgbox "$NEXT_STEPS" "$UI_HEIGHT" "$UI_WIDTH"
diff --git a/scripts/max25-terminal b/scripts/max25-terminal
new file mode 100755
index 0000000..71a689e
--- /dev/null
+++ b/scripts/max25-terminal
@@ -0,0 +1,3 @@
+#!/usr/bin/env bash
+# Alias → canonical launcher (stale stacks/terminal ELF avoided).
+exec "$(cd "$(dirname "$0")" && pwd)/run-max25-terminal.sh" "$@"
diff --git a/scripts/max25d-session.sh b/scripts/max25d-session.sh
new file mode 100755
index 0000000..701d23e
--- /dev/null
+++ b/scripts/max25d-session.sh
@@ -0,0 +1,160 @@
+#!/usr/bin/env bash
+# max25d-session — run max25d inside tmux or GNU screen (operator attach/detach).
+#
+# max25d-session start [--tmux|--screen] [-c /etc/max25/max25d.ini]
+# max25d-session attach [--tmux|--screen]
+# max25d-session status
+# max25d-session stop
+#
+# Environment overrides:
+# MAX25D_SESSION session name (default: max25d)
+# MAX25D_SESSION_BACKEND tmux | screen (default: tmux)
+# MAX25D_INI config path
+set -euo pipefail
+
+SESSION_NAME="${MAX25D_SESSION:-max25d}"
+BACKEND="${MAX25D_SESSION_BACKEND:-tmux}"
+CONFIG="${MAX25D_INI:-/etc/max25/max25d.ini}"
+MAX25D_BIN="${MAX25D_BIN:-max25d}"
+
+usage() {
+ cat <<'EOF'
+max25d-session — persistent max25d in tmux or screen
+
+Commands:
+ start [--tmux|--screen] [-c ini] start detached session
+ attach [--tmux|--screen] attach to running session
+ status show session + max25d hint
+ stop kill session
+
+Examples:
+ max25d-session start -c /etc/max25/max25d.ini
+ max25d-session attach
+ MAX25D_SESSION_BACKEND=screen max25d-session start
+
+EOF
+}
+
+have_tmux() { command -v tmux >/dev/null 2>&1; }
+have_screen() { command -v screen >/dev/null 2>&1; }
+
+pick_backend() {
+ case "${1:-$BACKEND}" in
+ tmux)
+ if ! have_tmux; then
+ echo "max25d-session: tmux not installed" >&2
+ exit 1
+ fi
+ echo tmux
+ ;;
+ screen)
+ if ! have_screen; then
+ echo "max25d-session: screen not installed" >&2
+ exit 1
+ fi
+ echo screen
+ ;;
+ *)
+ echo "max25d-session: unknown backend '$1' (use tmux or screen)" >&2
+ exit 1
+ ;;
+ esac
+}
+
+session_running() {
+ local b="$1"
+ case "$b" in
+ tmux) tmux has-session -t "$SESSION_NAME" 2>/dev/null ;;
+ screen) screen -ls | grep -q "[[:space:]]*[0-9]*\.${SESSION_NAME}[[:space:]]" ;;
+ esac
+}
+
+cmd_start() {
+ local b
+ b="$(pick_backend "$BACKEND")"
+ if session_running "$b"; then
+ echo "max25d-session: session '$SESSION_NAME' already running ($b)" >&2
+ echo " attach: max25d-session attach --$b" >&2
+ exit 1
+ fi
+ local cmd="${MAX25D_BIN} -c ${CONFIG}"
+ case "$b" in
+ tmux)
+ tmux new-session -d -s "$SESSION_NAME" \
+ "${MAX25D_BIN} -c ${CONFIG}; echo; echo max25d exited \$?; read -r _"
+ ;;
+ screen)
+ screen -dmS "$SESSION_NAME" bash -lc "${cmd}"
+ ;;
+ esac
+ echo "max25d-session: started ($b) name=$SESSION_NAME config=$CONFIG"
+ echo "max25d-session: attach with: max25d-session attach --$b"
+}
+
+cmd_attach() {
+ local b
+ b="$(pick_backend "$BACKEND")"
+ if ! session_running "$b"; then
+ echo "max25d-session: no session '$SESSION_NAME' ($b)" >&2
+ exit 1
+ fi
+ case "$b" in
+ tmux) exec tmux attach -t "$SESSION_NAME" ;;
+ screen) exec screen -r "$SESSION_NAME" ;;
+ esac
+}
+
+cmd_status() {
+ local b
+ b="$(pick_backend "$BACKEND")"
+ if session_running "$b"; then
+ echo "max25d-session: running backend=$b name=$SESSION_NAME"
+ case "$b" in
+ tmux) tmux list-panes -t "$SESSION_NAME" -F 'pane #{pane_index}: #{pane_current_command}' 2>/dev/null || true ;;
+ screen) screen -ls | grep "${SESSION_NAME}" || true ;;
+ esac
+ else
+ echo "max25d-session: not running (backend=$b name=$SESSION_NAME)"
+ fi
+ if command -v pgrep >/dev/null 2>&1 && pgrep -x max25d >/dev/null 2>&1; then
+ echo "max25d-session: max25d process: $(pgrep -xa max25d || true)"
+ fi
+}
+
+cmd_stop() {
+ local b
+ b="$(pick_backend "$BACKEND")"
+ if ! session_running "$b"; then
+ echo "max25d-session: not running" >&2
+ exit 0
+ fi
+ case "$b" in
+ tmux) tmux kill-session -t "$SESSION_NAME" ;;
+ screen) screen -S "$SESSION_NAME" -X quit ;;
+ esac
+ echo "max25d-session: stopped ($b) name=$SESSION_NAME"
+}
+
+CMD="${1:-}"
+shift || true
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --tmux) BACKEND=tmux; shift ;;
+ --screen) BACKEND=screen; shift ;;
+ -c|--config)
+ CONFIG="${2:?config path required}"
+ shift 2
+ ;;
+ -h|--help) usage; exit 0 ;;
+ *) echo "max25d-session: unknown option: $1" >&2; usage >&2; exit 1 ;;
+ esac
+done
+
+case "$CMD" in
+ start) cmd_start ;;
+ attach) cmd_attach ;;
+ status) cmd_status ;;
+ stop) cmd_stop ;;
+ ""|-h|--help) usage ;;
+ *) echo "max25d-session: unknown command: $CMD" >&2; usage >&2; exit 1 ;;
+esac
diff --git a/scripts/release-check.sh b/scripts/release-check.sh
new file mode 100755
index 0000000..223c6b6
--- /dev/null
+++ b/scripts/release-check.sh
@@ -0,0 +1,258 @@
+#!/usr/bin/env bash
+# release-check.sh — MAX25-Stack-Lite offline release gates (no root, no live RF TX).
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$ROOT"
+
+PASS=0
+FAIL=0
+WARN=0
+
+ok() { echo "OK: $*"; PASS=$((PASS + 1)); }
+fail() { echo "FAIL: $*" >&2; FAIL=$((FAIL + 1)); }
+warn() { echo "WARN: $*"; WARN=$((WARN + 1)); }
+
+echo "======== MAX25-Stack-Lite release-check ($(tr -d '[:space:]' < VERSION 2>/dev/null || echo '?')) ========"
+
+# --- version ---
+if [[ -f VERSION ]]; then
+ VER="$(tr -d '[:space:]' < VERSION)"
+ if [[ "$VER" == "1.8.0" ]]; then
+ ok "VERSION=${VER}"
+ elif [[ -n "$VER" ]]; then
+ fail "VERSION=${VER} (expected 1.8.0)"
+ else
+ fail "VERSION file empty"
+ fi
+else
+ fail "VERSION file missing"
+fi
+
+# --- required docs (Lite ships a stub docs/, depth lives in the research vault) ---
+for doc in README.md CONTRIBUTING.md CHANGELOG.md docs/README.md local/README.md stacks/daemon/README.md stacks/max25-bcpr/README.md include/max25/protocol.md include/max25/packet-radio.md; do
+ [[ -f "$doc" ]] && ok "doc $doc" || fail "missing $doc"
+done
+
+# --- scripts present + executable (Lite scope only) ---
+for script in scripts/max25-lite-setup.sh scripts/test-wizard.sh scripts/run-max25d.sh scripts/run-max25-terminal.sh scripts/max25-ctl scripts/discover-plugins.sh scripts/build.sh scripts/build-all.sh scripts/clean.sh scripts/test.sh scripts/release-check.sh scripts/tx-rx-test.sh scripts/terminology-check.sh scripts/install-max25.sh; do
+ if [[ -x "$script" ]]; then
+ ok "executable $script"
+ else
+ fail "not executable: $script"
+ fi
+done
+
+# --- public terminology gate (§0.16 / §0.19) ---
+if [[ -x scripts/terminology-check.sh ]]; then
+ if scripts/terminology-check.sh; then
+ ok "terminology-check"
+ else
+ fail "terminology-check"
+ fi
+else
+ fail "scripts/terminology-check.sh missing or not executable"
+fi
+
+# --- daemon core files ---
+if [[ -x stacks/daemon/max25d ]] && [[ -f stacks/daemon/max25d.py ]]; then
+ ok "executable stacks/daemon/max25d launcher + max25d.py"
+else
+ fail "stacks/daemon/max25d launcher or max25d.py missing"
+fi
+for f in kiss_bridge.py ax25_codec.py privilege_drop.py; do
+ [[ -f "stacks/daemon/${f}" ]] && ok "stacks/daemon/${f}" || fail "missing stacks/daemon/${f}"
+done
+if python3 stacks/daemon/test_ax25_codec.py >/dev/null 2>&1; then
+ ok "ax25_codec unit tests"
+else
+ fail "ax25_codec unit tests"
+fi
+
+# --- Lite INI examples (localhost-only model) ---
+for ini in share/max25/max25d.ini.example share/max25/max25d.lite.ini.example share/max25/max25d.lite.bcpr.ini.example share/max25/max25d.lite.crdop.ini.example share/max25-bcpr/max25-bcpr.ini.example stacks/max25-bcpr/share/max25-bcpr.ini.example stacks/crdop/share/crdop.ini.example; do
+ [[ -f "$ini" ]] && ok "ini $ini" || fail "missing $ini"
+done
+
+# --- localhost TCP in shipped max25d examples ---
+for ini in share/max25/max25d.ini.example share/max25/max25d.lite.ini.example share/max25/max25d.lite.bcpr.ini.example share/max25/max25d.lite.crdop.ini.example; do
+ if grep -Eq '^[[:space:]]*tcp_host[[:space:]]*=[[:space:]]*0\.0\.0\.0[[:space:]]*$' "$ini"; then
+ fail "ini $ini has tcp_host=0.0.0.0 (Lite localhost-only)"
+ elif grep -Eq '^[[:space:]]*tcp_host[[:space:]]*=[[:space:]]*127\.0\.0\.1[[:space:]]*$' "$ini"; then
+ ok "ini $ini tcp_host=127.0.0.1"
+ else
+ fail "ini $ini missing tcp_host=127.0.0.1"
+ fi
+done
+
+if [[ -f share/clients/index.yaml ]] && [[ -f share/clients/tnc2c.yaml ]]; then
+ ok "share/clients registry"
+else
+ warn "share/clients/ (index.yaml / tnc2c.yaml) missing (informational only in Lite)"
+fi
+
+# --- out-of-scope surfaces must stay inert (HyBBX co-host, hardware TNC stacks, plugin tree) ---
+if [[ -d plugins ]]; then
+ fail "plugins/ present — Lite configures devices directly in max25d.ini, no plugin tree expected"
+else
+ ok "no plugins/ tree (Lite: direct INI device config)"
+fi
+if [[ -d stacks/tncs ]]; then
+ fail "stacks/tncs/ present — TNC2C/pktnc2 hardware stack is out of scope for Lite"
+else
+ ok "no stacks/tncs/ (hardware TNC stack out of scope for Lite)"
+fi
+if grep -rq 'hybbx' CMakeLists.txt 2>/dev/null; then
+ fail "CMakeLists.txt references hybbx — HyBBX co-host build hooks are out of scope for Lite"
+else
+ ok "CMakeLists.txt free of hybbx references"
+fi
+if [[ -f share/hybbx/README.md ]]; then
+ ok "share/hybbx/README.md scope note present (kept for upstream sync, not a Lite path)"
+else
+ warn "share/hybbx/ present without a scope note (see design-principles.md sync policy)"
+fi
+
+# --- build artifacts (CMake, Lite target set) ---
+BUILD_DIR="build"
+if ! cmake -B "${BUILD_DIR}" -DCMAKE_BUILD_TYPE=Release >/dev/null 2>&1; then
+ BUILD_DIR="build-default"
+ cmake -B "${BUILD_DIR}" -DCMAKE_BUILD_TYPE=Release >/dev/null 2>&1 \
+ || fail "cmake configure"
+fi
+if cmake --build "${BUILD_DIR}" -j"$(nproc 2>/dev/null || echo 2)" >/dev/null 2>&1; then
+ ok "cmake build (${BUILD_DIR}/bin/)"
+else
+ fail "cmake build"
+fi
+[[ -x "${BUILD_DIR}/bin/max25-terminal" ]] && ok "max25-terminal built" || fail "max25-terminal build"
+if [[ -x "${BUILD_DIR}/bin/max25-bcprd" ]] && [[ -x "${BUILD_DIR}/bin/max25-bcprd-init" ]]; then
+ ok "max25-bcprd + max25-bcprd-init built (default ON in Lite)"
+else
+ fail "max25-bcprd / max25-bcprd-init not built (expected default ON — see cmake/MAX25Platform.cmake)"
+fi
+[[ -f stacks/crdop/share/crdop.ini.example ]] && ok "CRDOP scaffold" || fail "CRDOP scaffold missing"
+if [[ -x "${BUILD_DIR}/bin/audio-dummyd" ]]; then
+ ok "audio-dummyd binary"
+else
+ ok "audio-dummyd not in build dir (launcher uses source tree path)"
+fi
+
+# --- install tree hygiene: no vendor/ARDOP artifacts, no out-of-scope leakage ---
+INSTALL_DIR="${BUILD_DIR}/install-release-check"
+rm -rf "${INSTALL_DIR}"
+if cmake --install "${BUILD_DIR}" --prefix "${INSTALL_DIR}" >/dev/null 2>&1; then
+ ok "cmake --install prefix"
+ if find "${INSTALL_DIR}" \( -path '*/vendor/ardopcf/*' -o -name 'ardopcf' -o -name 'crdopc' \) 2>/dev/null | grep -q .; then
+ fail "ARDOP vendor artifacts in install tree (forbidden in release)"
+ else
+ ok "install tree free of ARDOP vendor binaries/sources"
+ fi
+ if find "${INSTALL_DIR}" -type d -iname 'hybbx' 2>/dev/null | grep -q .; then
+ fail "hybbx/ present in install tree (out of scope for Lite)"
+ else
+ ok "install tree free of hybbx/ directories"
+ fi
+else
+ warn "cmake --install skipped (non-fatal for dev trees)"
+fi
+
+# --- offline tests ---
+if [[ -x "${BUILD_DIR}/bin/max25-bcprd" ]]; then
+ if "${BUILD_DIR}/bin/max25-bcprd" -c stacks/max25-bcpr/share/max25-bcpr.ini.example --dry-run --once >/dev/null 2>&1; then
+ ok "max25-bcpr dry-run once"
+ else
+ fail "max25-bcpr dry-run once"
+ fi
+else
+ warn "max25-bcpr dry-run skipped (not built)"
+fi
+
+if python3 stacks/daemon/test_bcpr_backend.py >/dev/null 2>&1; then
+ ok "bcpr backend unit tests"
+else
+ fail "bcpr backend unit tests"
+fi
+
+if python3 stacks/daemon/test_crdop_backend.py >/dev/null 2>&1; then
+ ok "crdop backend unit tests"
+else
+ fail "crdop backend unit tests"
+fi
+
+if python3 stacks/daemon/test_paths.py >/dev/null 2>&1; then
+ ok "daemon path layout unit tests"
+else
+ fail "daemon path layout unit tests"
+fi
+
+if bash scripts/test-wizard.sh >/dev/null 2>&1; then
+ ok "wizard non-interactive smoke (serial/baycom/crdop)"
+else
+ fail "wizard non-interactive smoke"
+fi
+
+if bash scripts/discover-plugins.sh --type device >/dev/null 2>&1; then
+ ok "discover-plugins.sh --type device"
+else
+ warn "discover-plugins.sh --type device (no manifest — expected in Lite, see script output)"
+fi
+
+chmod +x stacks/daemon/max25d stacks/daemon/max25d.py 2>/dev/null || true
+[[ -x stacks/daemon/max25d ]] && [[ -f stacks/daemon/max25d.py ]] && ok "max25d ready" || fail "max25d"
+if [[ -L "${BUILD_DIR}/bin/max25-client" ]] || [[ -x "${BUILD_DIR}/bin/max25-client" ]]; then
+ ok "max25-client symlink"
+else
+ warn "max25-client symlink missing until cmake --install"
+fi
+if MAX25_TERMINAL="${PWD}/${BUILD_DIR}/bin/max25-terminal" \
+ bash stacks/terminal/test-terminal.sh >/dev/null 2>&1; then
+ ok "max25-terminal TCP probe"
+else
+ fail "max25-terminal TCP probe"
+fi
+
+if python3 stacks/daemon/test_proto.py >/dev/null 2>&1; then
+ ok "max25d protocol smoke"
+else
+ fail "max25d protocol smoke"
+fi
+
+if python3 stacks/daemon/test_auth.py >/dev/null 2>&1; then
+ ok "max25d TCP auth smoke"
+else
+ fail "max25d TCP auth smoke"
+fi
+
+# --- RX before TX gate (§0.20) — offline check only, no live RF TX here ---
+if bash scripts/tx-rx-test.sh >/dev/null 2>&1; then
+ ok "tx-rx-test L0 offline (RX before TX posture)"
+else
+ fail "tx-rx-test L0 offline"
+fi
+
+if python3 stacks/daemon/test_serial_watch.py >/dev/null 2>&1; then
+ ok "max25d serial watch tests"
+else
+ fail "max25d serial watch tests"
+fi
+
+# --- AmigaOS terminal (optional cross-build) ---
+if [[ -x /opt/amiga/bin/m68k-amigaos-gcc ]]; then
+ if bash scripts/build-amiga-terminal.sh >/dev/null 2>&1; then
+ ok "max25-terminal AmigaOS cross-build"
+ else
+ fail "max25-terminal AmigaOS cross-build"
+ fi
+else
+ warn "Amiga SDK not at /opt/amiga; skip Amiga terminal build (optional)"
+fi
+
+# --- CI workflow ---
+[[ -f .github/workflows/ci.yml ]] && ok "ci workflow" || warn "missing .github/workflows/ci.yml"
+
+echo "======== release-check: ${PASS} passed, ${FAIL} failed, ${WARN} warnings ========"
+if [[ "$FAIL" -gt 0 ]]; then
+ exit 1
+fi
+exit 0
diff --git a/scripts/run-max25-terminal.sh b/scripts/run-max25-terminal.sh
new file mode 100755
index 0000000..21f45da
--- /dev/null
+++ b/scripts/run-max25-terminal.sh
@@ -0,0 +1,62 @@
+#!/usr/bin/env bash
+# Canonical max25-terminal launcher — tree or PREFIX/bin.
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
+
+pick() {
+ local c
+ # Prefer installed binary beside this script
+ if [[ -x "${SCRIPT_DIR}/max25-terminal" && ! -d "${SCRIPT_DIR}/../stacks/terminal" ]]; then
+ c="${SCRIPT_DIR}/max25-terminal"
+ if "$c" -h 2>&1 | grep -q -- '-d, --device'; then
+ echo "$c"
+ return 0
+ fi
+ fi
+ local TREE=""
+ if [[ -d "${SCRIPT_DIR}/../stacks/terminal" ]]; then
+ TREE="$(cd "${SCRIPT_DIR}/.." && pwd)"
+ fi
+ for c in \
+ ${TREE:+"${TREE}/build/bin/max25-terminal"} \
+ ${TREE:+"${TREE}/build-max25-bcpr/bin/max25-terminal"} \
+ "${ROOT}/build-max25-bcpr/bin/max25-terminal" \
+ "${ROOT}/build/bin/max25-terminal" \
+ /usr/local/bin/max25-terminal
+ do
+ [[ -n "$c" && -x "$c" ]] || continue
+ if "$c" -h 2>&1 | grep -q -- '-d, --device'; then
+ echo "$c"
+ return 0
+ fi
+ done
+ if command -v max25-terminal >/dev/null 2>&1; then
+ c="$(command -v max25-terminal)"
+ if "$c" -h 2>&1 | grep -q -- '-d, --device'; then
+ echo "$c"
+ return 0
+ fi
+ fi
+ return 1
+}
+
+BIN="$(pick)" || {
+ echo "run-max25-terminal: no current max25-terminal binary found (or it is missing -d/--device)" >&2
+ echo " build it: cmake -B build && cmake --build build --target max25-terminal" >&2
+ echo " install: scripts/install-max25.sh or cmake --install build" >&2
+ echo " Do not run stacks/terminal/max25-terminal leftovers." >&2
+ exit 127
+}
+
+SOCK="${MAX25_UNIX:-/run/max25/modem.sock}"
+if [[ ! -S "$SOCK" ]] && [[ "${1:-}" != "-h" ]] && [[ "${1:-}" != "--help" ]]; then
+ if [[ " $* " != *" -T "* ]] && [[ " $* " != *" --tcp-only "* ]]; then
+ echo "run-max25-terminal: no socket $SOCK — start max25d first:" >&2
+ echo " run-max25d # or: ${SCRIPT_DIR}/run-max25d.sh" >&2
+ exit 1
+ fi
+fi
+
+exec "$BIN" "$@"
diff --git a/scripts/run-max25d.sh b/scripts/run-max25d.sh
new file mode 100755
index 0000000..232caf4
--- /dev/null
+++ b/scripts/run-max25d.sh
@@ -0,0 +1,99 @@
+#!/usr/bin/env bash
+# Canonical max25d start — escalate to root only when hardware needs it.
+# Works from source tree (scripts/) and from PREFIX/bin after install.
+# Root needed: ttyS / USB serial / ioperm / /dev/port / max25-bcpr SER12 (max25e0).
+# Escalation: already root → sudo -n → sudo → su → pkexec.
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+if [[ -x "${SCRIPT_DIR}/max25d" && ! -d "${SCRIPT_DIR}/../stacks/daemon" ]]; then
+ DAEMON="${SCRIPT_DIR}/max25d"
+ TREE=""
+else
+ TREE="$(cd "${SCRIPT_DIR}/.." && pwd)"
+ DAEMON="${TREE}/stacks/daemon/max25d"
+fi
+
+if [[ ! -x "$DAEMON" ]]; then
+ echo "run-max25d: missing $DAEMON" >&2
+ exit 1
+fi
+
+# Match real daemon only (not agent shells whose argv text mentions max25d).
+if pgrep -x max25d >/dev/null 2>&1 \
+ || pgrep -f -- '/max25d(\.py)?[[:space:]]+-c[[:space:]]' >/dev/null 2>&1; then
+ echo "run-max25d: max25d already running" >&2
+ pgrep -af -- '/max25d(\.py)?[[:space:]]+-c[[:space:]]' | head -5 || true
+ pgrep -ax max25d | head -5 || true
+ ls -la /run/max25/modem.sock /var/run/max25/modem.sock 2>/dev/null \
+ || echo "run-max25d: WARNING no modem.sock under /run/max25 or /var/run/max25" >&2
+ exit 0
+fi
+
+# Accept path or max25d-style "-c path".
+INI="${1:-}"
+if [[ "$INI" == "-c" || "$INI" == "--config" ]]; then
+ INI="${2:-}"
+fi
+if [[ -z "$INI" ]]; then
+ if [[ -n "$TREE" && -f "${TREE}/local/max25d.ini" ]]; then
+ INI="${TREE}/local/max25d.ini"
+ elif [[ -f "${PWD}/local/max25d.ini" ]]; then
+ INI="${PWD}/local/max25d.ini"
+ elif [[ -f /etc/max25/max25d.ini ]]; then
+ INI=/etc/max25/max25d.ini
+ elif [[ -f /usr/local/etc/max25/max25d.ini ]]; then
+ INI=/usr/local/etc/max25/max25d.ini
+ else
+ echo "run-max25d: no INI (pass path or create local/max25d.ini / /etc/max25/max25d.ini / /usr/local/etc/max25/max25d.ini)" >&2
+ exit 1
+ fi
+fi
+if [[ ! -f "$INI" ]]; then
+ echo "run-max25d: INI not found: $INI" >&2
+ exit 1
+fi
+
+needs_root() {
+ local f="$1"
+ [[ -f "$f" ]] || return 1
+ if grep -Eiq '^[[:space:]]*(max25[-_]bcpr|bcpr|baycom|pccom)[[:space:]]*=[[:space:]]*yes' "$f"; then return 0; fi
+ if grep -Eiq '^[[:space:]]*(max25e0|max25e0:bc[01]|tnc2c|pktnc2|baycom-kiss|pccom-kiss)[[:space:]]*=' "$f"; then return 0; fi
+ if grep -Eiq '^[[:space:]]*device[[:space:]]*=[[:space:]]*(max25e0|tnc2c|pktnc2)' "$f"; then return 0; fi
+ if grep -Eqi '/dev/(ttyS|ttyUSB|ttyACM|port)\b' "$f"; then return 0; fi
+ if grep -Eiq '^[[:space:]]*unix_socket[[:space:]]*=[[:space:]]*/(var/)?run/max25/' "$f"; then return 0; fi
+ return 1
+}
+
+if [[ "$(id -u)" -ne 0 ]]; then
+ if needs_root "$INI"; then
+ if command -v sudo >/dev/null 2>&1; then
+ if sudo -n true 2>/dev/null; then
+ echo "run-max25d: hardware path — escalating via sudo -n" >&2
+ exec sudo -n "$0" "$INI"
+ fi
+ echo "run-max25d: hardware path — escalating via sudo (password may be required)" >&2
+ exec sudo "$0" "$INI"
+ fi
+ if command -v su >/dev/null 2>&1; then
+ echo "run-max25d: hardware path — escalating via su" >&2
+ exec su -c "$(printf '%q ' "$0" "$INI")"
+ fi
+ if command -v pkexec >/dev/null 2>&1; then
+ echo "run-max25d: hardware path — escalating via pkexec" >&2
+ exec pkexec "$0" "$INI"
+ fi
+ echo "run-max25d: PC-COM or serial hardware needs admin — run: sudo $0 $INI" >&2
+ exit 1
+ fi
+ echo "run-max25d: starting unprivileged (uid=$(id -u))" >&2
+else
+ echo "run-max25d: running as root (uid=0)" >&2
+fi
+
+if [[ "$(id -u)" -eq 0 ]]; then
+ mkdir -p /run/max25 /var/run/max25
+fi
+echo "run-max25d: ini=$INI"
+exec "$DAEMON" -c "$INI"
diff --git a/scripts/terminology-check.sh b/scripts/terminology-check.sh
new file mode 100755
index 0000000..96c01fb
--- /dev/null
+++ b/scripts/terminology-check.sh
@@ -0,0 +1,41 @@
+#!/usr/bin/env bash
+# terminology-check.sh — release gate for AGENT-INDEX §0.16 / §0.19
+# Fail on forbidden product terms in shipped markdown (not tests/archives).
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+ERR=0
+
+check() {
+ local pat="$1" msg="$2"
+ local hits
+ hits="$(grep -RInE --include='*.md' "$pat" README.md CHANGELOG.md RELEASE-READINESS.md docs share/clients plugins 2>/dev/null \
+ | grep -viE 'Never |never |forbidden|LEGACY|removed|prefer bcpr|BayCom/based' || true)"
+ if [[ -n "$hits" ]]; then
+ echo "FAIL: $msg"
+ echo "$hits" | head -40
+ ERR=1
+ else
+ echo "OK: $msg"
+ fi
+}
+
+check '\bbcpr-bc0\b' 'product device id bcpr-bc0 (use max25e0)'
+check '\bbcpr-bc1\b' 'product device id bcpr-bc1 (use max25e0:bc1)'
+# product device face for SER12 — allow historical "removed" lines
+if grep -RInE --include='*.md' 'device = baycom-ser12|`baycom-ser12` \|' README.md docs 2>/dev/null \
+ | grep -vE 'removed|LEGACY|kernel baycom|not a kernel|Never' | grep -q .; then
+ echo "FAIL: product baycom-ser12 as active device face"
+ grep -RInE --include='*.md' 'device = baycom-ser12|`baycom-ser12` \|' README.md docs 2>/dev/null | head -20 || true
+ ERR=1
+else
+ echo "OK: no active baycom-ser12 product device face in README/docs"
+fi
+check '\bKonverter\b' 'Konverter in shipped docs'
+check '\bAX25SRV\b|\bax25srv\b' 'AX25SRV in shipped docs'
+
+if [[ "$ERR" -ne 0 ]]; then
+ exit 1
+fi
+echo "terminology-check: PASS"
+exit 0
diff --git a/scripts/test-wizard.sh b/scripts/test-wizard.sh
new file mode 100755
index 0000000..e75a401
--- /dev/null
+++ b/scripts/test-wizard.sh
@@ -0,0 +1,113 @@
+#!/usr/bin/env bash
+# test-wizard.sh — non-interactive smoke test for max25-lite-setup.sh.
+#
+# Drives the wizard through all three modem paths (serial, baycom, crdop)
+# using a mock `whiptail` on PATH that answers canned responses, then checks
+# the generated max25d.ini / crdop.ini / max25-bcpr.ini files are well formed.
+# No sudo, no hardware, no PTT — local ./local/ destination only.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+WIZARD="${ROOT}/scripts/max25-lite-setup.sh"
+WORKDIR="$(mktemp -d)"
+ERR=0
+
+ok() { echo "PASS: $*"; }
+fail() { echo "FAIL: $*" >&2; ERR=1; }
+
+cleanup() { rm -rf "$WORKDIR"; }
+trap cleanup EXIT
+
+mock_bin="${WORKDIR}/bin"
+mkdir -p "$mock_bin"
+
+# Mock whiptail: reads canned answers from $WHIPTAIL_ANSWERS (newline list, one
+# per --menu/--radiolist/--inputbox/--yesno call, in call order). --msgbox is a
+# no-op (informational only). Mirrors the 3>&1 1>&2 2>&3 capture convention:
+# the "answer" goes to this script's stderr; exit 0 means OK/Yes.
+cat >"${mock_bin}/whiptail" <<'MOCK'
+#!/usr/bin/env bash
+mode=""
+for a in "$@"; do
+ case "$a" in
+ --msgbox) mode=msgbox ;;
+ --yesno) mode=yesno ;;
+ --inputbox) mode=inputbox ;;
+ --menu) mode=menu ;;
+ --radiolist) mode=radiolist ;;
+ esac
+done
+[[ "$mode" == "msgbox" ]] && exit 0
+answers_file="${WHIPTAIL_ANSWERS:?WHIPTAIL_ANSWERS not set}"
+answer="$(head -n1 "$answers_file")"
+tail -n +2 "$answers_file" > "${answers_file}.next"
+mv "${answers_file}.next" "$answers_file"
+if [[ "$mode" == "yesno" ]]; then
+ [[ "$answer" == "yes" ]] && exit 0 || exit 1
+fi
+echo "$answer" >&2
+exit 0
+MOCK
+chmod +x "${mock_bin}/whiptail"
+
+# Mock arecord/aplay: force the "no ALSA cards" branch deterministically,
+# independent of the host's actual sound hardware, so the CRDOP path always
+# takes the same code path in this smoke test.
+for tool in arecord aplay; do
+ cat >"${mock_bin}/${tool}" <<'MOCK'
+#!/usr/bin/env bash
+exit 1
+MOCK
+ chmod +x "${mock_bin}/${tool}"
+done
+
+run_path() {
+ local label="$1" answers="$2" ini_glob="$3" want_grep="$4"
+ local answers_file="${WORKDIR}/answers-${label}"
+ printf '%s\n' "$answers" > "$answers_file"
+ rm -rf "${ROOT}/local/max25d.ini" "${ROOT}/local/crdop.ini" "${ROOT}/local/max25-bcpr.ini"
+ if PATH="${mock_bin}:${PATH}" WHIPTAIL_ANSWERS="$answers_file" \
+ bash "$WIZARD" </dev/null >"${WORKDIR}/out-${label}.log" 2>&1; then
+ if [[ -f "${ROOT}/local/max25d.ini" ]] && grep -q "$want_grep" "${ROOT}/local/max25d.ini"; then
+ ok "wizard ${label} path: max25d.ini written (${want_grep})"
+ else
+ fail "wizard ${label} path: max25d.ini missing or unexpected content"
+ cat "${WORKDIR}/out-${label}.log" >&2
+ fi
+ else
+ fail "wizard ${label} path: exited non-zero"
+ cat "${WORKDIR}/out-${label}.log" >&2
+ fi
+ rm -rf "${ROOT}/local/max25d.ini" "${ROOT}/local/crdop.ini" "${ROOT}/local/max25-bcpr.ini"
+}
+
+# serial: menu=serial, radiolist=/dev/ttyUSB0, callerid, callid, save-location=no (local)
+run_path "serial" "serial
+/dev/ttyUSB0
+CB-0
+QST
+no" "local/max25d.ini" "device = tnc2c"
+
+# baycom: menu=baycom, radiolist=/dev/ttyS0 (or whatever first port is), callerid, callid, no
+run_path "baycom" "baycom
+$(bash -c '
+ shopt -s nullglob
+ ports=(/dev/ttyUSB* /dev/ttyACM* /dev/ttyS*)
+ shopt -u nullglob
+ echo "${ports[0]:-/dev/ttyUSB0}"
+')
+CB-0
+QST
+no" "local/max25d.ini" "device = max25e0"
+
+# crdop: menu=crdop, [radiolist ALSA device only if arecord/aplay found any — assume
+# none in CI so wizard shows an informational msgbox and uses hw:0,0 defaults], callerid, callid, no
+run_path "crdop" "crdop
+CB-0
+QST
+no" "local/max25d.ini" "device = soft-crdop"
+
+if [[ "$ERR" -eq 0 ]]; then
+ echo "OK: wizard non-interactive smoke (serial / baycom / crdop)"
+fi
+exit "$ERR"
diff --git a/scripts/test.sh b/scripts/test.sh
new file mode 100755
index 0000000..24a83d7
--- /dev/null
+++ b/scripts/test.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+# test.sh — MAX25-Stack-Lite offline tests via CMake targets.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$ROOT"
+
+if [[ ! -d build ]]; then
+ bash "${ROOT}/scripts/build.sh"
+fi
+
+cmake --build build --target max25_test
diff --git a/scripts/tx-rx-test.sh b/scripts/tx-rx-test.sh
new file mode 100755
index 0000000..cdfbdca
--- /dev/null
+++ b/scripts/tx-rx-test.sh
@@ -0,0 +1,358 @@
+#!/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 [[ -f stacks/tncs/test_tnc_serial_recovery.py ]]; 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
+ else
+ ok "tnc serial recovery unit skipped (stacks/tncs/ not shipped in this build — Lite: modem/BayCom-based only)"
+ 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
+ # Lite default tree builds max25-bcpr (MAX25_BUILD_MAX25_BCPR=ON). Do not
+ # auto-rebuild it here for L0 in CI / release-check — only smoke when
+ # binaries already exist or MAX25_BCPR_ALLOW_BUILD=1.
+ 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" && "${MAX25_BCPR_ALLOW_BUILD:-0}" != "1" ]]; then
+ ok "max25-bcpr L0 skipped (not built — run cmake --build with MAX25_BUILD_MAX25_BCPR=ON, default in Lite)"
+ 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 [[ ! -d stacks/tncs ]]; then
+ ok "tnc preflight skipped (stacks/tncs/ not shipped in this build — Lite: modem/BayCom-based only)"
+ elif [[ -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
git clone -b <branch> https://cgit.mode42.com/<repo>.git
git clone -b <branch> git://cgit.mode42.com/<repo>.git

info@mode42.com