From fa05a5f8238e9e1417235711656e5062ccc843a7 Mon Sep 17 00:00:00 2001 From: "info@mode42.com" Date: Fri, 7 Aug 2026 18:23:28 +0000 Subject: Initial push --- scripts/build-all.sh | 54 +++++ scripts/build-amiga-terminal.sh | 17 ++ scripts/build-ax25-deps.sh | 146 ++++++++++++ scripts/build-freebsd.sh | 30 +++ scripts/build.sh | 11 + scripts/clean.sh | 9 + scripts/discover-plugins.sh | 97 ++++++++ scripts/install-freebsd.sh | 111 +++++++++ scripts/install-max25.sh | 116 +++++++++ scripts/max25-ctl | 408 +++++++++++++++++++++++++++++++ scripts/max25-lite-setup.sh | 517 ++++++++++++++++++++++++++++++++++++++++ scripts/max25-terminal | 3 + scripts/max25d-session.sh | 160 +++++++++++++ scripts/release-check.sh | 258 ++++++++++++++++++++ scripts/run-max25-terminal.sh | 62 +++++ scripts/run-max25d.sh | 99 ++++++++ scripts/terminology-check.sh | 41 ++++ scripts/test-wizard.sh | 113 +++++++++ scripts/test.sh | 12 + scripts/tx-rx-test.sh | 358 ++++++++++++++++++++++++++++ 20 files changed, 2622 insertions(+) create mode 100755 scripts/build-all.sh create mode 100755 scripts/build-amiga-terminal.sh create mode 100755 scripts/build-ax25-deps.sh create mode 100755 scripts/build-freebsd.sh create mode 100755 scripts/build.sh create mode 100755 scripts/clean.sh create mode 100755 scripts/discover-plugins.sh create mode 100755 scripts/install-freebsd.sh create mode 100755 scripts/install-max25.sh create mode 100755 scripts/max25-ctl create mode 100755 scripts/max25-lite-setup.sh create mode 100755 scripts/max25-terminal create mode 100755 scripts/max25d-session.sh create mode 100755 scripts/release-check.sh create mode 100755 scripts/run-max25-terminal.sh create mode 100755 scripts/run-max25d.sh create mode 100755 scripts/terminology-check.sh create mode 100755 scripts/test-wizard.sh create mode 100755 scripts/test.sh create mode 100755 scripts/tx-rx-test.sh (limited to 'scripts') 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 <&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 </../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 <&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 <&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 < [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 → 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" <"$dest" <"$dest" <"$dest" <"$dest" </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" "${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 -- cgit v1.3.1