summaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
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.sh31
-rwxr-xr-xscripts/build.sh11
-rwxr-xr-xscripts/clean.sh9
-rwxr-xr-xscripts/discover-plugins.sh97
-rwxr-xr-xscripts/install-freebsd.sh115
-rwxr-xr-xscripts/install-max25.sh116
-rwxr-xr-xscripts/max25-ctl413
-rwxr-xr-xscripts/max25-terminal3
-rwxr-xr-xscripts/max25d-session.sh160
-rwxr-xr-xscripts/release-check.sh343
-rwxr-xr-xscripts/run-max25-terminal.sh62
-rwxr-xr-xscripts/run-max25d.sh99
-rwxr-xr-xscripts/terminology-check.sh41
-rwxr-xr-xscripts/test.sh12
-rwxr-xr-xscripts/tx-rx-test.sh350
18 files changed, 2079 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..a226dad
--- /dev/null
+++ b/scripts/build-freebsd.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+# MAX25-Stack — FreeBSD full build (max25d, bcpr, terminal, CRDOP/OSS, web)
+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 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_BUILD_WEB=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..a8845b6
--- /dev/null
+++ b/scripts/discover-plugins.sh
@@ -0,0 +1,97 @@
+#!/usr/bin/env bash
+# MainAX25-Stack (MAX25-Stack) — list registered plugins from manifest.yaml + on-disk plugin.yaml files.
+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 "manifest not found: $MANIFEST" >&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..01ff533
--- /dev/null
+++ b/scripts/install-freebsd.sh
@@ -0,0 +1,115 @@
+#!/usr/bin/env bash
+# MAX25-Stack — FreeBSD full build + install (daemon, bcpr, terminal, CRDOP, web)
+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 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
+
+Full build (default): max25d · max25-bcpr · max25-terminal · CRDOP/OSS · web proxy/UI scaffold
+
+After install:
+ sudo mkdir -p /var/run/max25 ${ETC}
+ sudo cp ${PREFIX}/share/max25/max25d.freebsd.bcpr.ini.example ${ETC}/max25d.ini
+ sudo cp ${PREFIX}/share/max25-bcpr/max25-bcpr.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
+
+See docs/FREEBSD-AX25.md · docs/WEBSOCKET.md · docs/BAYCOM.md
+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.freebsd.bcpr.ini.example" "${ETC}/max25d.ini"
+ _install_ini "${ROOT}/stacks/web/share/web-proxy.ini.example" "${ETC}/web-proxy.ini"
+ _install_ini "${ROOT}/stacks/web/share/web-terminal.ini.example" "${ETC}/web-terminal.ini"
+fi
+
+echo ""
+echo "== MAX25 installed to ${PREFIX} =="
+echo " max25d, max25-terminal, max25-ws-proxy → ${PREFIX}/bin"
+echo " max25-bcpr-ctl, max25-bcprd-init → ${PREFIX}/sbin"
+echo " examples → ${PREFIX}/share/max25/ · web UI → ${PREFIX}/share/max25/reverse-proxy/"
+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 " MAX25_WEB_TERMINAL_INI=${ETC}/web-terminal.ini max25-ws-proxy -c ${ETC}/web-proxy.ini"
+echo ""
+echo "Optional: tmodem/c1224 → ${PREFIX}/share/max25/max25d.freebsd.tmodem.ini.example"
+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..c21f673
--- /dev/null
+++ b/scripts/install-max25.sh
@@ -0,0 +1,116 @@
+#!/usr/bin/env bash
+# MainAX25-Stack — 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
+MainAX25-Stack 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
+ max25-tx-rx-test --device modem --live
+ run-max25-terminal -U /run/max25/modem.sock
+
+See docs/LINUX-HOST-SETUP.md · docs/DEV/TNC-MODEM-DEV.md
+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 (TNC host):"
+echo " sudo usermod -aG dialout \$USER # serial access"
+echo " sudo cp share/max25/max25d.ini.host.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 docs/BAYCOM.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..f17888b
--- /dev/null
+++ b/scripts/max25-ctl
@@ -0,0 +1,413 @@
+#!/usr/bin/env bash
+# MainAX25-Stack (MAX25-Stack) — unified control entry point for Packet Radio / AX.25 stacks.
+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
+MainAX25-Stack (MAX25-Stack) control — Packet Radio / AX.25 (HyBBX-compatible)
+
+Usage:
+ max25-ctl <command> [options]
+
+Commands:
+ help This help
+ discover List plugins
+ build Build all stacks
+ start Start hardware stack
+ stop Stop hardware stack
+ status Show stack 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):
+ --mode MODE standalone | service | hybbx-host (default: standalone)
+ --hardware HW tncs | modems | soft-modems
+ --device DEV tnc2c | pktnc2 | max25e0 | baycom-kiss | soft-crdop
+ (legacy aliases baycom-ser12/par96 redirect to bcpr docs)
+ --baycom-ini PATH legacy kernel baycom-pr.ini (removed — use bcpr.ini / max25e0)
+ --baycom-profile NAME single (default) | dual — legacy only (kernel path removed)
+
+Examples:
+ max25-ctl discover
+ max25-ctl build
+ max25-ctl start --hardware tncs --device tnc2c
+ 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
+
+Unified workflow (all devices): docs/PLUGINS-DEVICE-MODEL.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 "Kernel BayCom (baycom-pr / baycom_ser_fdx) removed from this tree." >&2
+ echo "Use BayCom/based via max25-bcpr: docs/BAYCOM.md · stacks/max25-bcpr/README.md" >&2
+ echo " [features] max25_bcpr=yes · max25e0 = max25-bcpr:bc0 · max25-bcpr-ctl / max25d auto_start" >&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 "max25-bcpr / max25e0: start via max25d [features] max25_bcpr=yes (auto_start) or max25-bcpr-ctl" >&2
+ echo "See docs/BAYCOM.md · 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 tncs|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 HyBBX or other userspace serial owner; fuser on device)"
+}
+
+cmd_status() {
+ parse_opts "$@"
+ echo "MainAX25 mode: $MODE"
+ if [[ -f "${ROOT}/scripts/discover-plugins.sh" ]]; then
+ bash "${ROOT}/scripts/discover-plugins.sh" --type device
+ 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-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..53f6c61
--- /dev/null
+++ b/scripts/release-check.sh
@@ -0,0 +1,343 @@
+#!/usr/bin/env bash
+# MainAX25-Stack (MAX25-Stack-v1.5.0) — release gates (offline, no root).
+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 "======== MainAX25-Stack release-check ($(tr -d '[:space:]' < VERSION)) ========"
+
+# --- version & manifest ---
+if [[ -f VERSION ]]; then
+ VER="$(tr -d '[:space:]' < VERSION)"
+ [[ -n "$VER" ]] && ok "VERSION=${VER}" || fail "VERSION file empty"
+else
+ fail "VERSION file missing"
+fi
+
+if [[ -f plugins/manifest.yaml ]]; then
+ ok "plugins/manifest.yaml"
+ grep -q 'default_betriebsform: standalone' plugins/manifest.yaml \
+ && ok "default operating mode: standalone" \
+ || fail "manifest missing default operating mode (default_betriebsform: standalone)"
+else
+ fail "plugins/manifest.yaml missing"
+fi
+
+# --- required docs ---
+for doc in README.md CONTRIBUTING.md docs/README.md docs/ARCHITECTURE.md docs/DEVELOPMENT.md docs/HYBBX.md docs/PLATFORMS.md docs/V1.0.0-SCOPE.md docs/BAYCOM.md docs/CRDOP.md docs/MERGE-REPORT.md docs/MAX25-TERMINAL.md docs/MAX25-CLIENT.md docs/LINUX-HOST-SETUP.md docs/PACKET-RADIO.md stacks/daemon/README.md; do
+ [[ -f "$doc" ]] && ok "doc $doc" || fail "missing $doc"
+done
+
+# --- scripts executable ---
+for script in scripts/discover-plugins.sh scripts/build-all.sh scripts/build.sh scripts/test.sh scripts/clean.sh scripts/release-check.sh scripts/max25-ctl scripts/install-max25.sh scripts/tx-rx-test.sh scripts/terminology-check.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
+
+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
+
+if [[ -f stacks/daemon/kiss_bridge.py ]]; then
+ ok "stacks/daemon/kiss_bridge.py"
+else
+ fail "missing stacks/daemon/kiss_bridge.py"
+fi
+
+if [[ -f stacks/daemon/ax25_codec.py ]]; then
+ ok "stacks/daemon/ax25_codec.py (native AX.25 codec)"
+else
+ fail "missing stacks/daemon/ax25_codec.py"
+fi
+
+if [[ -f stacks/daemon/privilege_drop.py ]]; then
+ ok "stacks/daemon/privilege_drop.py"
+else
+ fail "missing stacks/daemon/privilege_drop.py"
+fi
+
+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
+
+if [[ -f share/max25/max25d.ini.example ]]; then
+ ok "share/max25/max25d.ini.example"
+else
+ fail "missing share/max25/max25d.ini.example"
+fi
+
+if [[ -f share/max25/max25d.ini.host.example ]]; then
+ ok "share/max25/max25d.ini.host.example"
+else
+ fail "missing share/max25/max25d.ini.host.example"
+fi
+
+if [[ -f share/baycom/README.md ]] && { [[ -f stacks/max25-bcpr/share/max25-bcpr.ini.example ]] || [[ -f share/max25-bcpr/max25-bcpr.ini.example ]]; }; then
+ ok "BayCom/based bcpr example + share/baycom README"
+else
+ fail "missing max25-bcpr.ini.example or share/baycom/README.md"
+fi
+
+if [[ -f share/clients/index.yaml ]] && [[ -f share/clients/tnc2c.yaml ]]; then
+ ok "share/clients registry"
+else
+ fail "missing share/clients/ (index.yaml or tnc2c.yaml)"
+fi
+
+if [[ -f local/README.md ]]; then
+ ok "local/README.md (gitignored site overrides)"
+else
+ fail "missing local/README.md"
+fi
+
+if [[ -f include/max25/protocol.md ]]; then
+ ok "include/max25/protocol.md"
+else
+ fail "missing include/max25/protocol.md"
+fi
+
+if [[ -f include/max25/packet-radio.md ]]; then
+ ok "include/max25/packet-radio.md"
+else
+ fail "missing include/max25/packet-radio.md"
+fi
+
+# --- plugin count ---
+YAML_COUNT="$(find plugins -name plugin.yaml | wc -l | tr -d ' ')"
+if [[ "$YAML_COUNT" -ge 10 ]]; then
+ ok "plugin.yaml count: ${YAML_COUNT}"
+else
+ fail "expected >=10 plugin.yaml, got ${YAML_COUNT}"
+fi
+
+# --- v1 active devices in manifest ---
+for dev in tnc2c soft-crdop; do
+ if grep -A6 "id: ${dev}" plugins/manifest.yaml | grep -q 'status: active'; then
+ ok "device ${dev} status: active"
+ else
+ fail "device ${dev} not active in manifest"
+ fi
+done
+
+if grep -A6 "id: soft-modems" plugins/manifest.yaml | grep -q 'optional: true'; then
+ fail "hardware soft-modems should not be optional-only"
+else
+ ok "hardware soft-modems is MAX25-SoftModem standard group"
+fi
+
+# --- planned/scaffold devices stay out of v1 active set ---
+for dev in pktnc2; do
+ if grep -A6 "id: ${dev}" plugins/manifest.yaml | grep -Eq 'status: (planned|scaffold)'; then
+ ok "device ${dev} correctly non-active"
+ else
+ warn "device ${dev} status unexpected (should be planned/scaffold)"
+ fi
+done
+
+if grep -A6 "id: baycom-kiss" plugins/manifest.yaml | grep -q 'status: active'; then
+ ok "device baycom-kiss status: active (USB/async KISS)"
+else
+ warn "device baycom-kiss status unexpected"
+fi
+
+# kernel baycom-ser12 / baycom-par96 removed 2026-07-18
+if grep -q 'id: baycom-ser12' plugins/manifest.yaml || grep -q 'id: baycom-par96' plugins/manifest.yaml; then
+ fail "legacy kernel baycom-ser12/par96 still in manifest (use bcpr)"
+else
+ ok "manifest free of kernel baycom-ser12/par96"
+fi
+
+# --- HyBBX INI templates (stack-local) ---
+for ini in stacks/tncs/hybbx-tnc2c.ini \
+ stacks/tncs/hybbx-dual.ini \
+ share/hybbx/tnc2c-host.ini.example \
+ share/hybbx/crdop-host.ini.example; do
+ [[ -f "$ini" ]] && ok "ini $ini" || fail "missing $ini"
+done
+[[ -d stacks/max25-bcpr ]] && ok "stacks/max25-bcpr (BayCom/based)" || fail "missing stacks/max25-bcpr"
+
+# --- build artifacts (CMake) ---
+BUILD_DIR="build"
+if ! cmake -B "${BUILD_DIR}" -DCMAKE_BUILD_TYPE=Release \
+ -DMAX25_BUNDLE_AX25=OFF >/dev/null 2>&1; then
+ BUILD_DIR="build-default"
+ cmake -B "${BUILD_DIR}" -DCMAKE_BUILD_TYPE=Release \
+ -DMAX25_BUNDLE_AX25=OFF >/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/, CRDOP default ON)"
+else
+ fail "cmake build"
+fi
+[[ -x "${BUILD_DIR}/bin/tnc2c-probe" ]] && ok "tnc2c-probe built" || fail "tnc2c-probe build"
+if [[ -x "${BUILD_DIR}/bin/max25-bcprd" ]]; then
+ ok "max25-bcprd built"
+else
+ ok "max25-bcprd not built (check cmake MAX25_BUILD_MAX25_BCPR)"
+fi
+[[ -x "${BUILD_DIR}/bin/max25-terminal" ]] && ok "max25-terminal built" || fail "max25-terminal build"
+[[ -f stacks/crdop/share/crdop.ini.example ]] && ok "CRDOP scaffold (MAX25-SoftModem)" || fail "CRDOP scaffold missing"
+[[ -f plugins/external/ardop/plugin.yaml ]] && ok "ARDOP-plugin metadata" || fail "missing plugins/external/ardop/plugin.yaml"
+if [[ -x "${BUILD_DIR}/bin/crdopc" ]]; then
+ warn "vendor crdopc binary present (dev-only CRDOP_VENDOR_ARDOPCF build — not for release)"
+else
+ ok "no vendor crdopc binary (native SoftModem default)"
+fi
+if [[ -x "${BUILD_DIR}/bin/audio-dummyd" ]]; then
+ ok "audio-dummyd binary (native SoftModem host)"
+else
+ ok "audio-dummyd not in build dir (launcher uses source tree path)"
+fi
+
+# --- ARDOP must not appear in default install tree ---
+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 f -exec grep -l 'ardopcf' {} + 2>/dev/null | grep -q .; then
+ warn "install tree contains files mentioning ardopcf (review)"
+ else
+ ok "install tree has no ardopcf references"
+ 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
+ ok "max25-bcpr dry-run skipped (not built)"
+fi
+
+if [[ -x "${BUILD_DIR}/bin/crdopc" ]] && CRDOP_BIN="${PWD}/${BUILD_DIR}/bin/crdopc" bash stacks/crdop/scripts/test-smoke.sh >/dev/null 2>&1; then
+ ok "crdop smoke (dev vendor build only)"
+elif [[ -x "${BUILD_DIR}/bin/crdopc" ]]; then
+ fail "crdop smoke (vendor crdopc present but smoke failed)"
+else
+ ok "crdop vendor smoke skipped — native SoftModem default"
+fi
+
+# --- discovery ---
+if bash scripts/discover-plugins.sh --json >/dev/null 2>&1; then
+ ok "discover-plugins --json"
+else
+ fail "discover-plugins --json"
+fi
+
+# --- max25d + terminal ---
+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
+
+if bash scripts/tx-rx-test.sh >/dev/null 2>&1; then
+ ok "tx-rx-test L0 (TNC + BayCom/based)"
+else
+ fail "tx-rx-test L0 (TNC + BayCom/based)"
+fi
+
+if [[ -f docs/TX-RX-TEST.md ]]; then
+ ok "doc docs/TX-RX-TEST.md"
+else
+ fail "missing docs/TX-RX-TEST.md"
+fi
+
+if python3 stacks/tncs/test_tnc_serial_recovery.py >/dev/null 2>&1; then
+ ok "tnc serial recovery unit tests"
+else
+ fail "tnc serial recovery unit tests"
+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
+
+[[ -x stacks/tncs/tnc2c-host-reset.sh ]] && ok "tnc2c-host-reset.sh" || fail "tnc2c-host-reset.sh"
+
+# --- 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"
+fi
+
+# --- CI workflow ---
+[[ -f .github/workflows/ci.yml ]] && ok "ci workflow" || fail "missing .github/workflows/ci.yml"
+
+# --- tncs probe (warn without hardware) ---
+if "${BUILD_DIR}/bin/tnc2c-probe" >/dev/null 2>&1; then
+ ok "tnc2c-probe (serial found)"
+else
+ warn "tnc2c-probe: no serial devices (OK for CI without hardware)"
+fi
+
+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..adb0cca
--- /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 with -d/--device" >&2
+ echo " cmake -B build-max25-bcpr -DMAX25_BUILD_MAX25_BCPR=ON && cmake --build build-max25-bcpr" >&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..c3e1ca9
--- /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: ERROR need root for SER12/UART/ioperm" >&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.sh b/scripts/test.sh
new file mode 100755
index 0000000..bc52a97
--- /dev/null
+++ b/scripts/test.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+# MainAX25-Stack (MAX25-Stack) — 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..31b4435
--- /dev/null
+++ b/scripts/tx-rx-test.sh
@@ -0,0 +1,350 @@
+#!/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
git clone -b <branch> https://cgit.mode42.com/<repo>.git
git clone -b <branch> git://cgit.mode42.com/<repo>.git

info@mode42.com