summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorinfo@mode42.com <info@mode42.com>2026-08-07 18:23:28 +0000
committerinfo@mode42.com <info@mode42.com>2026-08-07 18:23:28 +0000
commitfa05a5f8238e9e1417235711656e5062ccc843a7 (patch)
tree46fbbd18de54492b59307c16efcc7f3152723238
Initial push
-rw-r--r--.github/ISSUE_TEMPLATE/bug_report.md38
-rw-r--r--.github/ISSUE_TEMPLATE/config.yml1
-rw-r--r--.github/ISSUE_TEMPLATE/feature_request.md22
-rw-r--r--.github/pull_request_template.md37
-rw-r--r--.github/workflows/ci.yml30
-rw-r--r--.gitignore30
-rw-r--r--CHANGELOG.md128
-rw-r--r--CMakeLists.txt184
-rw-r--r--CONTRIBUTING.md16
-rw-r--r--LICENSE674
-rw-r--r--README.md67
-rw-r--r--RELEASE-READINESS.md53
-rw-r--r--VERSION1
-rw-r--r--cmake/MAX25Ax25.cmake19
-rw-r--r--cmake/MAX25Platform.cmake37
-rw-r--r--docs/README.md13
-rw-r--r--include/max25/packet-radio.md49
-rw-r--r--include/max25/protocol.md213
-rw-r--r--local/README.md25
-rwxr-xr-xscripts/build-all.sh54
-rwxr-xr-xscripts/build-amiga-terminal.sh17
-rwxr-xr-xscripts/build-ax25-deps.sh146
-rwxr-xr-xscripts/build-freebsd.sh30
-rwxr-xr-xscripts/build.sh11
-rwxr-xr-xscripts/clean.sh9
-rwxr-xr-xscripts/discover-plugins.sh97
-rwxr-xr-xscripts/install-freebsd.sh111
-rwxr-xr-xscripts/install-max25.sh116
-rwxr-xr-xscripts/max25-ctl408
-rwxr-xr-xscripts/max25-lite-setup.sh517
-rwxr-xr-xscripts/max25-terminal3
-rwxr-xr-xscripts/max25d-session.sh160
-rwxr-xr-xscripts/release-check.sh258
-rwxr-xr-xscripts/run-max25-terminal.sh62
-rwxr-xr-xscripts/run-max25d.sh99
-rwxr-xr-xscripts/terminology-check.sh41
-rwxr-xr-xscripts/test-wizard.sh113
-rwxr-xr-xscripts/test.sh12
-rwxr-xr-xscripts/tx-rx-test.sh358
-rw-r--r--share/clients/README.md79
-rw-r--r--share/clients/baycom-kiss.yaml46
-rw-r--r--share/clients/index.yaml39
-rw-r--r--share/clients/kiss-serial-rs232.yaml55
-rw-r--r--share/clients/kiss-serial-usb.yaml57
-rw-r--r--share/clients/pktnc2.yaml54
-rw-r--r--share/clients/soft-crdop.yaml66
-rw-r--r--share/clients/tmodem.yaml42
-rw-r--r--share/clients/tnc2c.yaml53
-rw-r--r--share/hybbx/README.md7
-rw-r--r--share/hybbx/baycom-kiss-host.ini.example38
-rw-r--r--share/hybbx/crdop-host.ini.example17
-rw-r--r--share/hybbx/pktnc2-host.ini.example28
-rw-r--r--share/hybbx/service-dual.ini.example37
-rw-r--r--share/hybbx/tnc2c-host.ini.example38
-rw-r--r--share/max25-bcpr/max25-bcpr.freebsd.ini.example34
-rw-r--r--share/max25-bcpr/max25-bcpr.ini.example44
-rw-r--r--share/max25/max25d.ini.example46
-rw-r--r--share/max25/max25d.lite.bcpr.ini.example42
-rw-r--r--share/max25/max25d.lite.crdop.ini.example40
-rw-r--r--share/max25/max25d.lite.ini.example44
-rw-r--r--share/max25/max25d.service.example13
-rw-r--r--share/max25/serial/README.md22
-rw-r--r--share/max25/serial/pccom-kiss-serial.env.example9
-rw-r--r--share/max25/serial/pktnc2-serial.env.example5
-rw-r--r--share/max25/serial/tnc2c-serial.env.example8
-rw-r--r--share/systemd/README.md9
-rw-r--r--share/systemd/max25d.service32
-rw-r--r--stacks/crdop/.github/workflows/build.yml54
-rw-r--r--stacks/crdop/.gitignore13
-rw-r--r--stacks/crdop/CMakeLists.txt55
-rw-r--r--stacks/crdop/LICENSE22
-rw-r--r--stacks/crdop/NOTICE.md9
-rw-r--r--stacks/crdop/README.md36
-rw-r--r--stacks/crdop/ROADMAP.md445
-rw-r--r--stacks/crdop/VERSION1
-rw-r--r--stacks/crdop/cmake/CRDOPCompiler.cmake37
-rw-r--r--stacks/crdop/cmake/CRDOPPlatform.cmake71
-rw-r--r--stacks/crdop/cmake/CRDOPTests.cmake104
-rw-r--r--stacks/crdop/cmake/CRDOPVendorArdopcf.cmake127
-rw-r--r--stacks/crdop/cmake/toolchains/aarch64-linux-gnu.cmake4
-rw-r--r--stacks/crdop/cmake/toolchains/arm-linux-gnueabihf.cmake4
-rw-r--r--stacks/crdop/cmake/toolchains/x86_64-w64-mingw32.cmake4
-rw-r--r--stacks/crdop/docs/ACOUSTIC-TEST-PROTOCOL.md84
-rw-r--r--stacks/crdop/docs/AUDIO-ARCHITECTURE.md80
-rw-r--r--stacks/crdop/docs/BUILD.md99
-rw-r--r--stacks/crdop/docs/CHANGELOG.md25
-rw-r--r--stacks/crdop/docs/CONFIG.md50
-rw-r--r--stacks/crdop/docs/DEVELOPER.md188
-rw-r--r--stacks/crdop/docs/EXAMPLES.md91
-rw-r--r--stacks/crdop/docs/FEC-SPEC.md99
-rw-r--r--stacks/crdop/docs/G3RUH-DESIGN.md72
-rw-r--r--stacks/crdop/docs/HARDWARE-INTERFACE.md194
-rw-r--r--stacks/crdop/docs/HOST-PROTOCOL-SPEC.md103
-rw-r--r--stacks/crdop/docs/INDEX.md77
-rw-r--r--stacks/crdop/docs/LICENSE-USAGE.md116
-rw-r--r--stacks/crdop/docs/MAX25-USAGE.md292
-rw-r--r--stacks/crdop/docs/PROTOCOL.md36
-rw-r--r--stacks/crdop/docs/README.md31
-rw-r--r--stacks/crdop/docs/SOFTMODEM.md127
-rw-r--r--stacks/crdop/docs/SOUNDCARD-QUALIFICATION.md95
-rw-r--r--stacks/crdop/include/crdop/sound_proxy.h52
-rw-r--r--stacks/crdop/include/crdop/version.h8
-rw-r--r--stacks/crdop/lib/__init__.py1
-rw-r--r--stacks/crdop/lib/acoustic_engine.py89
-rw-r--r--stacks/crdop/lib/afsk_demodulator.py56
-rw-r--r--stacks/crdop/lib/afsk_modulator.py51
-rw-r--r--stacks/crdop/lib/bell202_line_code.py50
-rw-r--r--stacks/crdop/lib/hdlc_codec.py110
-rw-r--r--stacks/crdop/lib/m25_host_protocol.py142
-rw-r--r--stacks/crdop/lib/sound_proxy.py185
-rw-r--r--stacks/crdop/lib/sound_proxy_oss.py151
-rw-r--r--stacks/crdop/lib/test_bell202_line_code.py34
-rw-r--r--stacks/crdop/patches/ardopcf/0001-crdop-linux-gcc-branding.patch186
-rw-r--r--stacks/crdop/patches/ardopcf/0002-crdop-version-override.patch18
-rwxr-xr-xstacks/crdop/scripts/apply-vendor-patches.sh36
-rwxr-xr-xstacks/crdop/scripts/build-crdop.sh48
-rwxr-xr-xstacks/crdop/scripts/crdopc171
-rwxr-xr-xstacks/crdop/scripts/import-ardopcf.sh17
-rwxr-xr-xstacks/crdop/scripts/install-crdop.sh16
-rwxr-xr-xstacks/crdop/scripts/refresh-vendor-ardopcf.sh26
-rwxr-xr-xstacks/crdop/scripts/sync-upstream.sh21
-rwxr-xr-xstacks/crdop/scripts/test-all.sh17
-rwxr-xr-xstacks/crdop/scripts/test-smoke.sh24
-rwxr-xr-xstacks/crdop/scripts/test-with-host.sh9
-rw-r--r--stacks/crdop/share/crdop-amateur.ini.example15
-rw-r--r--stacks/crdop/share/crdop-dual.ini.example15
-rw-r--r--stacks/crdop/share/crdop.freebsd.ini.example25
-rw-r--r--stacks/crdop/share/crdop.ini.example37
-rw-r--r--stacks/crdop/src/crdop_version.c5
-rwxr-xr-xstacks/crdop/tools/audio-dummyd4
-rw-r--r--stacks/crdop/tools/audio-dummyd.py66
-rwxr-xr-xstacks/crdop/tools/max25-signal-sniffer5
-rw-r--r--stacks/crdop/tools/max25-signal-sniffer.py78
-rw-r--r--stacks/crdop/vendor/README.md5
-rw-r--r--stacks/crdop/vendor/ardopcf.ref9
-rw-r--r--stacks/daemon/README.md72
-rw-r--r--stacks/daemon/ax25_codec.py176
-rw-r--r--stacks/daemon/banlist.py89
-rw-r--r--stacks/daemon/daemon_log.py237
-rw-r--r--stacks/daemon/device_backends.py1076
-rw-r--r--stacks/daemon/kiss_bridge.py574
-rw-r--r--stacks/daemon/max25_platform.py83
-rwxr-xr-xstacks/daemon/max25d21
-rwxr-xr-xstacks/daemon/max25d.py2213
-rw-r--r--stacks/daemon/modular_tcp_server.py208
-rw-r--r--stacks/daemon/paths.py281
-rw-r--r--stacks/daemon/privilege_drop.py110
-rw-r--r--stacks/daemon/reporting_quality.py178
-rw-r--r--stacks/daemon/test_audio_dummy_backend.py49
-rw-r--r--stacks/daemon/test_auth.py95
-rw-r--r--stacks/daemon/test_ax25_codec.py97
-rw-r--r--stacks/daemon/test_bans.py199
-rw-r--r--stacks/daemon/test_baycom_backend.py95
-rw-r--r--stacks/daemon/test_bcpr_backend.py194
-rw-r--r--stacks/daemon/test_crdop_backend.py130
-rw-r--r--stacks/daemon/test_daemon_log.py99
-rw-r--r--stacks/daemon/test_kiss_bridge.py161
-rw-r--r--stacks/daemon/test_max25_platform.py54
-rw-r--r--stacks/daemon/test_modular_tcp.py35
-rw-r--r--stacks/daemon/test_multi_device.py518
-rw-r--r--stacks/daemon/test_path_normalize.py17
-rw-r--r--stacks/daemon/test_paths.py145
-rw-r--r--stacks/daemon/test_privilege_drop.py55
-rw-r--r--stacks/daemon/test_proto.py106
-rw-r--r--stacks/daemon/test_reporting.py163
-rw-r--r--stacks/daemon/test_reporting_quality.py123
-rw-r--r--stacks/daemon/test_serial_watch.py241
-rw-r--r--stacks/daemon/test_tx_rx_offline.py191
-rw-r--r--stacks/daemon/test_unix_socket_ownership.py61
-rw-r--r--stacks/daemon/tx_pace.py21
-rw-r--r--stacks/max25-bcpr/CMakeLists.txt76
-rw-r--r--stacks/max25-bcpr/NOTICE.md11
-rw-r--r--stacks/max25-bcpr/README.md69
-rw-r--r--stacks/max25-bcpr/VERSION1
-rw-r--r--stacks/max25-bcpr/include/bcpr/bcpr.h16
-rw-r--r--stacks/max25-bcpr/include/bcpr/bcpr_config.h70
-rw-r--r--stacks/max25-bcpr/include/bcpr/bcpr_crc.h12
-rw-r--r--stacks/max25-bcpr/include/bcpr/bcpr_daemon.h23
-rw-r--r--stacks/max25-bcpr/include/bcpr/bcpr_engine.h62
-rw-r--r--stacks/max25-bcpr/include/bcpr/bcpr_hdlc.h57
-rw-r--r--stacks/max25-bcpr/include/bcpr/bcpr_kiss.h17
-rw-r--r--stacks/max25-bcpr/include/bcpr/bcpr_lock.h22
-rw-r--r--stacks/max25-bcpr/include/bcpr/bcpr_runas.h12
-rw-r--r--stacks/max25-bcpr/include/bcpr/bcpr_ser12.h47
-rw-r--r--stacks/max25-bcpr/include/bcpr/bcpr_uart.h22
-rw-r--r--stacks/max25-bcpr/share/max25-bcpr.freebsd.ini.example34
-rw-r--r--stacks/max25-bcpr/share/max25-bcpr.ini.example48
-rw-r--r--stacks/max25-bcpr/src/bcpr_config.c397
-rw-r--r--stacks/max25-bcpr/src/bcpr_crc.c55
-rw-r--r--stacks/max25-bcpr/src/bcpr_daemon.c289
-rw-r--r--stacks/max25-bcpr/src/bcpr_engine.c652
-rw-r--r--stacks/max25-bcpr/src/bcpr_hdlc.c293
-rw-r--r--stacks/max25-bcpr/src/bcpr_kiss.c127
-rw-r--r--stacks/max25-bcpr/src/bcpr_lock.c202
-rw-r--r--stacks/max25-bcpr/src/bcpr_runas.c81
-rw-r--r--stacks/max25-bcpr/src/bcpr_ser12.c246
-rw-r--r--stacks/max25-bcpr/src/bcpr_uart.c251
-rw-r--r--stacks/max25-bcpr/src/max25-bcprd-init.c255
-rw-r--r--stacks/max25-bcpr/src/max25-bcprd.c205
-rw-r--r--stacks/max25-bcpr/tests/test_config_offline.c53
-rw-r--r--stacks/max25-bcpr/tests/test_hdlc_offline.c110
-rw-r--r--stacks/max25-bcpr/tools/NEBENBEI-MOVED.md5
-rwxr-xr-xstacks/max25-bcpr/tools/bcpr-ultimate-diag.sh916
-rwxr-xr-xstacks/max25-bcpr/tools/max25-bcpr-ctl379
-rwxr-xr-xstacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh566
-rwxr-xr-xstacks/max25-bcpr/tools/max25-bcpr-ultimate-diag.sh914
-rw-r--r--stacks/terminal/.gitignore9
-rw-r--r--stacks/terminal/CMakeLists.txt40
-rw-r--r--stacks/terminal/README.md31
-rw-r--r--stacks/terminal/amiga/Makefile32
-rw-r--r--stacks/terminal/amiga/max25_terminal_amiga.c309
-rw-r--r--stacks/terminal/max25_proto.c472
-rw-r--r--stacks/terminal/max25_proto.h55
-rw-r--r--stacks/terminal/max25_terminal.c767
-rw-r--r--stacks/terminal/max25_ui.c407
-rw-r--r--stacks/terminal/max25_ui.h45
-rwxr-xr-xstacks/terminal/test-terminal.sh28
-rw-r--r--stacks/terminal/test_apply_event.c30
-rw-r--r--stacks/terminal/test_connect.py78
219 files changed, 26955 insertions, 0 deletions
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..5cfc7ab
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,38 @@
+---
+name: Bug report
+about: Something does not work as documented or expected
+title: "[bug] "
+labels: bug
+---
+
+## What happened?
+
+## What did you expect?
+
+## Steps to reproduce
+
+1.
+2.
+3.
+
+## Environment
+
+- MainAX25 version / commit:
+- OS:
+- Hardware: TNC2C / BayCom SER12 / CRDOP / other
+- Operating mode: standalone / hybbx-host / service
+- Config (relevant paths or INI sections only, no secrets):
+
+## Logs / output
+
+```
+(paste here)
+```
+
+## Documentation checked
+
+- [ ] [README.md](../../README.md)
+- [ ] [docs/README.md](../../docs/README.md)
+- [ ] [docs/DEVELOPMENT.md](../../docs/DEVELOPMENT.md)
+- [ ] [docs/HYBBX.md](../../docs/HYBBX.md)
+- [ ] [docs/PLATFORMS.md](../../docs/PLATFORMS.md)
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..0086358
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1 @@
+blank_issues_enabled: true
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..eedec4c
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,22 @@
+---
+name: Feature request
+about: Suggest a new capability or improvement
+title: "[feature] "
+labels: enhancement
+---
+
+## Problem / use case
+
+## Proposed solution
+
+## Alternatives considered
+
+## Fits MainAX25 model?
+
+- [ ] Standalone-first stack; HyBBX attaches after prep ([docs/HYBBX.md](../../docs/HYBBX.md))
+- [ ] Plugin fits operating mode → Hardware → Device hierarchy ([docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md))
+- [ ] Linux-primary / BSD-later platform stance ([docs/PLATFORMS.md](../../docs/PLATFORMS.md))
+
+## Documentation
+
+Should update plugin.yaml / HYBBX INI when implemented: yes / no
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..57f4469
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,37 @@
+## Summary
+
+<!-- What does this PR do and why? -->
+
+## Type
+
+- [ ] Bug fix
+- [ ] Feature
+- [ ] Documentation
+- [ ] Refactor / internal
+- [ ] Build / CI
+- [ ] Plugin / stack integration
+
+## Testing
+
+- [ ] `./scripts/build.sh` succeeds
+- [ ] `./scripts/test.sh` succeeds (offline)
+- [ ] `./scripts/release-check.sh` succeeds
+- [ ] Manual hardware verification (if TNC/modem/RF touched):
+
+## Documentation
+
+- [ ] Updated `plugins/manifest.yaml` and/or `plugins/**/plugin.yaml` (if plugin changed)
+- [ ] Updated [docs/README.md](../docs/README.md) index (if docs added/removed)
+- [ ] Updated [docs/HYBBX.md](../docs/HYBBX.md) and/or `share/hybbx/*.ini.example` (if HyBBX attach changed)
+- [ ] Updated [docs/PLATFORMS.md](../docs/PLATFORMS.md) (if platform limits changed)
+- [ ] Updated [docs/V1.0.0-SCOPE.md](../docs/V1.0.0-SCOPE.md) (if v1 scope changed)
+
+## Architecture
+
+- [ ] Standalone-first: MAX25 owns TNC/modem lifecycle, not HyBBX sessions
+- [ ] Plugin hierarchy unchanged or documented ([docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md))
+- [ ] HyBBX remains external consumer — no vendoring hyBBX
+
+## Notes
+
+<!-- Optional: breaking changes, follow-ups -->
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..b77de00
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,30 @@
+name: CI
+
+on:
+ push:
+ branches: [main, preview-source-tree]
+ pull_request:
+ branches: [main, preview-source-tree]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Install build tools
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y build-essential cmake libasound2-dev pkg-config libncurses-dev
+
+ - name: Configure
+ run: cmake -B build -DCMAKE_BUILD_TYPE=Release -DMAX25_BUNDLE_AX25=OFF
+
+ - name: Build merged stacks
+ run: cmake --build build -j$(nproc)
+
+ - name: Offline tests
+ run: cmake --build build --target max25_test
+
+ - name: Release gates
+ run: bash scripts/release-check.sh
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3c82a67
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,30 @@
+__pycache__/
+*.pyc
+*.o
+*.a
+.DS_Store
+build/
+build-*/
+dist/
+*.log
+stacks/tncs/tnc2c-probe
+stacks/tncs/tnc2c-find
+stacks/tncs/research/
+stacks/crdop/build/
+stacks/baycom-pr/tools/baycom_test
+local/*
+!local/README.md
+.cursor/cache/
+third_party/ax25/*-rc5/
+third_party/_build-test/
+third_party/ax25/_build-test/
+third_party/ax25/**/build/
+.ax25-analysis/
+stacks/crdop/vendor/ardopcf/
+
+# vault/operator index — never ship
+AGENT-INDEX.md
+
+# agent navigation — never ship (vault §0.5 / operator)
+AGENTS.md
+AGENT.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..510aa0e
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,128 @@
+# Changelog · MAX25-Stack-Lite
+
+All notable changes to MAX25-Stack-Lite are documented here. Historical
+entries below may still name the upstream MAX25-Stack product.
+
+## [MAX25-Stack-Lite 1.8.0]
+
+Version file `1.8.0`. Aligned with upstream MAX25-Stack 1.8.0 features that
+apply on one localhost host. BayCom/based default ON. Localhost TCP enforced.
+
+| Change | Detail |
+|--------|--------|
+| **Product version** | `1.0.0-lite` → **`1.8.0`** (CMake + `VERSION`) |
+| **Localhost TCP** | `tcp_host` must be loopback (`127.0.0.1` / `::1` / `localhost`); LAN/`0.0.0.0` refused at start |
+| **Software TOT** | Time-on-target policy for max25-bcpr via `max25d.ini` |
+| **1.5 s TX gap** | Minimum gap between RF/KISS frame sends |
+| **max25-bcpr** | BayCom/based SER12 stack; device **`max25e0`**; default build **ON** |
+| **tmodem device** | T-Modem USB KISS half-TNC entry |
+| **privilege_drop** | `privilege_drop.py` installed with max25d |
+| **Out of Lite** | Web UI, HyBBX co-host, multi-RF / LAN terminal (use full MAX25-Stack) |
+
+## [MAX25-Stack 1.8.5-fallback_untested-upcoming#3-2]
+
+Version file .
+
+| Change | Detail |
+|--------|--------|
+| Docs | Move → (development material home) |
+
+## [MAX25-Stack 1.8.5-fallback_untested-upcoming#3]
+
+Version file `1.8.5-fallback_untested-upcoming#3` (after `#2-workhard-session_night`).
+
+| Change | Detail |
+|--------|--------|
+| Release mark | **fallback_untested** · upcoming **#3** |
+| CI | `max25_web_smoke` offline — `--no-stack --no-serial`, ephemeral ports, no UART |
+| Docs | Ship [TNC-MODEM-DEV.md](TNC-MODEM-DEV.md) (TNC/modem bring-up) |
+| Tests | Dual-device protocol smoke uses allowlisted `baycom-kiss`/`pccom-kiss` (not legacy `baycom-a`) |
+| Carry-forward | Live RF fallback path not fully re-certified under this tag |
+
+## [MAX25-Stack 1.8.5-fallback_untested-upcoming#2-workhard-session_night]
+
+Version file `1.8.5-fallback_untested-upcoming#2-workhard-session_night` (operator night-session mark after `1.8.5-fallback_untested-upcoming#1`).
+
+| Change | Detail |
+|--------|--------|
+| Release mark | **fallback_untested** · upcoming **#2** · **workhard-session_night** |
+| Carry-forward | Same consistency gate posture as `1.8.5-fallback_untested-upcoming#1` — live RF fallback path not fully re-certified under this tag |
+| Device face | BayCom/based SER12 → **`max25e0`** (internal `stacks/max25-bcpr/`) |
+| TX/RX | Live TX gated on RX (`scripts/tx-rx-test.sh`, §0.20) |
+
+## [MAX25-Stack 1.8.5-fallback_untested-upcoming#1]
+
+Version file `1.8.5-fallback_untested-upcoming#1` (operator override of prior `1.8.0-upcoming#1` marking).
+
+| Change | Detail |
+|--------|--------|
+| Release mark | **fallback_untested** — consistency gate green; live RF fallback path not fully re-certified under this tag |
+| Device face | BayCom/based SER12 → **`max25e0`** (internal `stacks/max25-bcpr/`; never `bcpr-bc0` as product id) |
+| Kernel baycom | Product path removed; vault legacy archive only |
+| Start | `scripts/run-max25d.sh` — root only when ttyS/USB/SER12 needs it |
+| TX/RX test | `scripts/tx-rx-test.sh` — live TX gated on RX (§0.20) |
+
+## [MAX25-Stack 1.5.0]
+
+Version file `1.5.0`. Product label **MAX25-Stack-v1.5.0**.
+
+### HyBBX boundary matrix
+
+| Change | Detail |
+|--------|--------|
+| `[features] baycom=no` | Default in max25d INI examples |
+| `[features] pccom=no` | Default in max25d INI examples |
+| Device filter | BayCom/PC-COM entries filtered unless features enabled |
+| CMake | BayCom tools still built; runtime opt-in via INI |
+
+### Platform and topology matrix
+
+| Change | Detail |
+|--------|--------|
+| One RF device per Linux host | Design rule in INI examples |
+| FreeBSD scaffold | `max25_platform.py`, OSS sound-proxy, FreeBSD INI |
+| Modular TCP/IP server | `modular_tcp_server.py` for Main/Secondary split |
+| New INI templates | `max25d.main.ini.example`, `max25d.secondary-linux.ini.example`, `max25d.freebsd.ini.example` |
+
+### Docs matrix
+
+| Item | Detail |
+|------|--------|
+| Operator docs | Updated for v1.5 boundary |
+| New | `V2.0.0-SCOPE.md`, `MODULAR-TCPIP-SERVER.md`, `MASTER-GUIDE.md` |
+
+## [MAX25-Stack 1.0.0] — 2026-07-13
+
+Version file `1.0.0`. Product label **MAX25-Stack-v1.0.0**.
+
+### Active devices matrix
+
+| Device | Standalone | HyBBX |
+|--------|------------|-------|
+| **tnc2c** | `max25-ctl start --hardware tncs --device tnc2c` | `packet_radio` |
+| **baycom-ser12** (superseded → **`max25e0`**/bcpr) | legacy kernel path removed | `baycom` via MAX25 |
+| **soft-crdop** | `max25-ctl start --hardware soft-modems` | `crdop` |
+
+### Architecture matrix
+
+| Item | Value |
+|------|-------|
+| AX.25 codec | In-tree `ax25_codec.py` via `kiss_bridge.py` |
+| `MAX25_BUNDLE_AX25` | OFF default |
+| `MAX25_BUILD_CRDOP` | ON default |
+| Daemon | `max25d` — Linux supervisor, M25/1 |
+| Client | `max25-terminal` / `max25-client` — sole official operator client |
+
+### Deferred to v1.1+ matrix
+
+| Item | Status |
+|------|--------|
+| `pktnc2`, `baycom-par96`, `baycom-kiss` | deferred |
+| CRDOP amateur / dual workflows | INI templates ship |
+
+## Related
+
+| Goal | Doc |
+|------|-----|
+| Release gates | [RELEASE-READINESS.md](RELEASE-READINESS.md) |
+| v1 scope | [docs/V1.0.0-SCOPE.md](docs/V1.0.0-SCOPE.md) |
diff --git a/CMakeLists.txt b/CMakeLists.txt
new file mode 100644
index 0000000..21b3f86
--- /dev/null
+++ b/CMakeLists.txt
@@ -0,0 +1,184 @@
+cmake_minimum_required(VERSION 3.16)
+project(MAX25StackLite VERSION 1.8.0 LANGUAGES C)
+
+include(GNUInstallDirs)
+enable_testing()
+
+if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
+ set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
+endif()
+
+set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
+set(CMAKE_C_STANDARD 11)
+set(CMAKE_C_STANDARD_REQUIRED ON)
+
+list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")
+include(MAX25Platform)
+include(MAX25Ax25)
+
+option(MAX25_BUILD_CRDOP "Build and install MAX25-SoftModem (CRDOP)" ON)
+option(MAX25_BUILD_MAX25_BCPR "Build stacks/max25-bcpr BayCom/based SER12 (default ON)" ON)
+if(DEFINED MAX25_BUILD_BCPR)
+ set(MAX25_BUILD_MAX25_BCPR "${MAX25_BUILD_BCPR}" CACHE BOOL "Build max25-bcpr (legacy MAX25_BUILD_BCPR)" FORCE)
+endif()
+option(MAX25_BUILD_TERMINAL "Build max25-terminal / max25-client" ON)
+option(MAX25_BUILD_DAEMON "Install max25d" ON)
+
+if(MAX25_BUILD_CRDOP)
+ add_subdirectory(stacks/crdop "${CMAKE_BINARY_DIR}/crdop")
+endif()
+
+if(MAX25_BUILD_MAX25_BCPR)
+ add_subdirectory(stacks/max25-bcpr "${CMAKE_BINARY_DIR}/max25-bcpr")
+endif()
+
+if(MAX25_BUILD_TERMINAL)
+ add_subdirectory(stacks/terminal)
+endif()
+
+if(MAX25_BUILD_DAEMON)
+ execute_process(
+ COMMAND "${CMAKE_COMMAND}" -E chmod u+x
+ "${CMAKE_SOURCE_DIR}/stacks/daemon/max25d"
+ "${CMAKE_SOURCE_DIR}/stacks/daemon/max25d.py"
+ ERROR_QUIET
+ )
+ install(PROGRAMS "${CMAKE_SOURCE_DIR}/stacks/daemon/max25d"
+ DESTINATION "${CMAKE_INSTALL_BINDIR}")
+ install(PROGRAMS "${CMAKE_SOURCE_DIR}/stacks/daemon/max25d.py"
+ DESTINATION "${CMAKE_INSTALL_BINDIR}")
+ install(FILES "${CMAKE_SOURCE_DIR}/stacks/daemon/daemon_log.py"
+ DESTINATION "${CMAKE_INSTALL_BINDIR}")
+ install(FILES "${CMAKE_SOURCE_DIR}/stacks/daemon/kiss_bridge.py"
+ DESTINATION "${CMAKE_INSTALL_BINDIR}")
+ install(FILES "${CMAKE_SOURCE_DIR}/stacks/daemon/ax25_codec.py"
+ DESTINATION "${CMAKE_INSTALL_BINDIR}")
+ install(FILES "${CMAKE_SOURCE_DIR}/stacks/daemon/device_backends.py"
+ DESTINATION "${CMAKE_INSTALL_BINDIR}")
+ install(FILES "${CMAKE_SOURCE_DIR}/stacks/daemon/banlist.py"
+ DESTINATION "${CMAKE_INSTALL_BINDIR}")
+ install(FILES "${CMAKE_SOURCE_DIR}/stacks/daemon/reporting_quality.py"
+ DESTINATION "${CMAKE_INSTALL_BINDIR}")
+ install(FILES "${CMAKE_SOURCE_DIR}/stacks/daemon/privilege_drop.py"
+ DESTINATION "${CMAKE_INSTALL_BINDIR}")
+ install(FILES "${CMAKE_SOURCE_DIR}/stacks/daemon/tx_pace.py"
+ DESTINATION "${CMAKE_INSTALL_BINDIR}")
+ install(FILES "${CMAKE_SOURCE_DIR}/stacks/daemon/paths.py"
+ DESTINATION "${CMAKE_INSTALL_BINDIR}")
+ install(FILES "${CMAKE_SOURCE_DIR}/stacks/daemon/max25_platform.py"
+ DESTINATION "${CMAKE_INSTALL_BINDIR}")
+ install(FILES "${CMAKE_SOURCE_DIR}/stacks/daemon/modular_tcp_server.py"
+ DESTINATION "${CMAKE_INSTALL_BINDIR}")
+endif()
+
+install(PROGRAMS "${CMAKE_SOURCE_DIR}/scripts/max25-ctl"
+ DESTINATION "${CMAKE_INSTALL_BINDIR}")
+install(PROGRAMS "${CMAKE_SOURCE_DIR}/scripts/max25d-session.sh"
+ DESTINATION "${CMAKE_INSTALL_BINDIR}" RENAME max25d-session)
+
+install(PROGRAMS "${CMAKE_SOURCE_DIR}/scripts/run-max25d.sh"
+ DESTINATION "${CMAKE_INSTALL_BINDIR}" RENAME run-max25d)
+install(PROGRAMS "${CMAKE_SOURCE_DIR}/scripts/run-max25-terminal.sh"
+ DESTINATION "${CMAKE_INSTALL_BINDIR}" RENAME run-max25-terminal)
+install(PROGRAMS "${CMAKE_SOURCE_DIR}/scripts/max25-lite-setup.sh"
+ DESTINATION "${CMAKE_INSTALL_BINDIR}" RENAME max25-lite-setup)
+
+install(FILES
+ "${CMAKE_SOURCE_DIR}/share/max25/max25d.lite.ini.example"
+ "${CMAKE_SOURCE_DIR}/share/max25/max25d.ini.example"
+ DESTINATION "${CMAKE_INSTALL_DATADIR}/max25"
+)
+
+if(MAX25_BUILD_CRDOP)
+ install(FILES
+ "${CMAKE_SOURCE_DIR}/share/max25/max25d.lite.crdop.ini.example"
+ DESTINATION "${CMAKE_INSTALL_DATADIR}/max25"
+ )
+endif()
+
+if(MAX25_BUILD_MAX25_BCPR)
+ install(FILES
+ "${CMAKE_SOURCE_DIR}/share/max25/max25d.lite.bcpr.ini.example"
+ DESTINATION "${CMAKE_INSTALL_DATADIR}/max25"
+ )
+ install(PROGRAMS "${CMAKE_SOURCE_DIR}/stacks/max25-bcpr/tools/max25-bcpr-ctl"
+ DESTINATION "${CMAKE_INSTALL_SBINDIR}")
+endif()
+
+if(EXISTS "${CMAKE_SOURCE_DIR}/share/max25/max25d.service.example")
+ install(FILES "${CMAKE_SOURCE_DIR}/share/max25/max25d.service.example"
+ DESTINATION "${CMAKE_INSTALL_DATADIR}/max25")
+endif()
+
+add_custom_target(max25_daemon_smoke
+ COMMAND "${CMAKE_COMMAND}" -E env PYTHONPATH=
+ python3 "${CMAKE_SOURCE_DIR}/stacks/daemon/test_paths.py"
+ COMMAND "${CMAKE_COMMAND}" -E env PYTHONPATH=
+ python3 "${CMAKE_SOURCE_DIR}/stacks/daemon/test_proto.py"
+ COMMAND "${CMAKE_COMMAND}" -E env PYTHONPATH=
+ python3 "${CMAKE_SOURCE_DIR}/stacks/daemon/test_auth.py"
+ COMMAND "${CMAKE_COMMAND}" -E env PYTHONPATH=
+ python3 "${CMAKE_SOURCE_DIR}/stacks/daemon/test_ax25_codec.py"
+ COMMAND "${CMAKE_COMMAND}" -E env PYTHONPATH=
+ python3 "${CMAKE_SOURCE_DIR}/stacks/daemon/test_kiss_bridge.py"
+ COMMAND "${CMAKE_COMMAND}" -E env PYTHONPATH=
+ python3 "${CMAKE_SOURCE_DIR}/stacks/daemon/test_baycom_backend.py"
+ COMMAND "${CMAKE_COMMAND}" -E env PYTHONPATH=
+ python3 "${CMAKE_SOURCE_DIR}/stacks/daemon/test_bcpr_backend.py"
+ COMMAND "${CMAKE_COMMAND}" -E env PYTHONPATH=
+ python3 "${CMAKE_SOURCE_DIR}/stacks/daemon/test_crdop_backend.py"
+ COMMAND "${CMAKE_COMMAND}" -E env PYTHONPATH=
+ python3 "${CMAKE_SOURCE_DIR}/stacks/daemon/test_multi_device.py"
+ COMMAND "${CMAKE_COMMAND}" -E env PYTHONPATH=
+ python3 "${CMAKE_SOURCE_DIR}/stacks/daemon/test_bans.py"
+ COMMAND "${CMAKE_COMMAND}" -E env PYTHONPATH=
+ python3 "${CMAKE_SOURCE_DIR}/stacks/daemon/test_audio_dummy_backend.py"
+ COMMAND "${CMAKE_COMMAND}" -E env PYTHONPATH=
+ python3 "${CMAKE_SOURCE_DIR}/stacks/daemon/test_modular_tcp.py"
+ COMMAND "${CMAKE_COMMAND}" -E env PYTHONPATH=
+ python3 "${CMAKE_SOURCE_DIR}/stacks/daemon/test_max25_platform.py"
+ COMMAND "${CMAKE_COMMAND}" -E env PYTHONPATH=
+ python3 "${CMAKE_SOURCE_DIR}/stacks/daemon/test_serial_watch.py"
+ COMMAND "${CMAKE_COMMAND}" -E env PYTHONPATH=
+ python3 "${CMAKE_SOURCE_DIR}/stacks/daemon/test_daemon_log.py"
+ COMMAND "${CMAKE_COMMAND}" -E env PYTHONPATH=
+ python3 "${CMAKE_SOURCE_DIR}/stacks/daemon/test_reporting.py"
+ COMMAND "${CMAKE_COMMAND}" -E env PYTHONPATH=
+ python3 "${CMAKE_SOURCE_DIR}/stacks/daemon/test_reporting_quality.py"
+ WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
+ COMMENT "max25d M25/1 smoke tests (Lite)"
+)
+
+add_custom_target(max25_wizard_smoke
+ COMMAND bash "${CMAKE_SOURCE_DIR}/scripts/test-wizard.sh"
+ WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
+ COMMENT "max25-lite-setup.sh non-interactive smoke (serial/baycom/crdop)"
+)
+
+add_custom_target(max25_test
+ DEPENDS max25_daemon_smoke max25_wizard_smoke
+)
+
+if(TARGET max25-terminal)
+ add_custom_target(max25_terminal_test
+ COMMAND "${CMAKE_COMMAND}" -E env MAX25_TERMINAL="${CMAKE_BINARY_DIR}/bin/max25-terminal"
+ bash "${CMAKE_SOURCE_DIR}/stacks/terminal/test-terminal.sh"
+ DEPENDS max25-terminal
+ WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
+ COMMENT "max25-terminal TCP probe"
+ )
+ add_dependencies(max25_test max25_terminal_test)
+endif()
+
+if(TARGET crdopc)
+ add_custom_target(crdop_smoke
+ COMMAND "${CMAKE_COMMAND}" -E env CRDOP_BIN="${CMAKE_BINARY_DIR}/bin/crdopc"
+ bash "${CMAKE_SOURCE_DIR}/stacks/crdop/scripts/test-smoke.sh"
+ DEPENDS crdopc
+ WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
+ COMMENT "CRDOP smoke test"
+ )
+ add_dependencies(max25_test crdop_smoke)
+endif()
+
+message(STATUS "MAX25-Stack-Lite → ${CMAKE_BINARY_DIR}/bin (install prefix: ${CMAKE_INSTALL_PREFIX})")
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..11f5296
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,16 @@
+# Contributing
+
+Doc index: [docs/README.md](docs/README.md) (stub — depth docs live in the research vault, linked from there).
+
+```bash
+git clone git@github.com:ngteq/MAX25-Stack-Lite.git && cd MAX25-Stack-Lite
+cmake -S . -B build && cmake --build build
+./scripts/release-check.sh
+```
+
+## Pull requests
+
+- Small, focused diffs; in-scope targets only — `max25-terminal`, `max25d`, CRDOP, `max25-bcpr` (BayCom/based)
+- Out of scope for this fork: HyBBX co-host, web UI, multi-RF orchestration, LAN bridges, native Windows build — see [README.md](README.md)
+- Messages: new-Linux-user plain English, no jargon-only errors
+- Verify: `./scripts/test.sh && ./scripts/release-check.sh`
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f288702
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ <program> Copyright (C) <year> <name of author>
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+<https://www.gnu.org/licenses/>.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+<https://www.gnu.org/licenses/why-not-lgpl.html>.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ef1a0bb
--- /dev/null
+++ b/README.md
@@ -0,0 +1,67 @@
+# MAX25-Stack-Lite · 1.8.0
+
+Minimal and fully functional for **terminal(s)** and **packet radio**.
+
+For **localhost** installation on the **terminal computer only** — modem, daemon, and terminal on the **same machine**. No LAN bridges, no remote terminal.
+
+**Platforms:** **POSIX-like only** — \*Linux · \*BSD · macOS · **Windows → WSL required** · Windows-native build later roadmap
+
+---
+
+## Quick start (max25-terminal path)
+
+**Debian / Ubuntu / Mint:**
+
+```bash
+sudo apt update
+sudo apt install -y git cmake build-essential libncurses-dev whiptail alsa-utils
+sudo usermod -aG dialout "$USER" # log out and back in
+git clone https://github.com/ngteq/MAX25-Stack-Lite.git
+cd MAX25-Stack-Lite
+cmake -B build && cmake --build build
+./scripts/max25-lite-setup.sh
+./scripts/run-max25d.sh
+./scripts/run-max25-terminal.sh -U /run/max25/modem.sock
+```
+
+At the `>` prompt: **CONNECT QST** · **RX before TX** (prove receive before transmit).
+
+---
+
+## Build tools by platform
+
+| Platform | Install (typical) |
+|----------|-------------------|
+| **Debian / Ubuntu / Mint** | `sudo apt install git cmake build-essential libncurses-dev whiptail alsa-utils` |
+| **Fedora / CentOS Stream / RHEL** | `sudo dnf install git cmake gcc gcc-c++ make ncurses-devel newt alsa-utils` |
+| **openSUSE / SUSE** | `sudo zypper install git cmake gcc gcc-c++ make ncurses-devel libncurses6 whiptail alsa-utils` |
+| **Arch Linux** | `sudo pacman -S git cmake base-devel ncurses alsa-utils`; wizard: `dialog` or `whiptail` from AUR |
+| **Gentoo** | `emerge -av dev-vcs/git dev-util/cmake sys-libs/ncurses net-misc/alsa-utils`; wizard: `app-admin/dialog` |
+| **FreeBSD** | `pkg install git cmake gmake ncurses python3 dialog` (audio: native OSS, no extra package) |
+| **macOS** | Xcode CLI + Homebrew: `brew install cmake ncurses python3 dialog`; build from source tree (manual INI until wizard port) |
+| **Windows** | **Use WSL** (Ubuntu/Debian recommended) — follow the **Debian / Ubuntu / Mint** row inside WSL; native Windows build **deferred** (late roadmap, not v1) |
+
+After install: join the serial group on Linux or WSL (`dialout`) and **log out and back in**.
+
+---
+
+## Verify (error-free)
+
+```bash
+cmake --build build --target max25_daemon_smoke
+```
+
+---
+
+## Scope
+
+| In | Out |
+|----|-----|
+| max25-terminal, max25-client | Remote / LAN terminal |
+| max25d + setup wizard (localhost) | HyBBX, web UI |
+| **BayCom/based** (`max25-bcpr`, device **max25e0**) | Multi-RF co-host |
+| CRDOP soft modem | Network bridges |
+| USB KISS without max25d (parallel) | |
+
+---
+
diff --git a/RELEASE-READINESS.md b/RELEASE-READINESS.md
new file mode 100644
index 0000000..eff80d4
--- /dev/null
+++ b/RELEASE-READINESS.md
@@ -0,0 +1,53 @@
+# Release readiness · MAX25-Stack-Lite
+
+Offline and manual release gates for MAX25-Stack-Lite, scoped to `max25-terminal` / `max25-client`, localhost `max25d`, CRDOP, and BayCom/based (`max25-bcpr`) only.
+
+## Offline gate matrix
+
+| Gate | Result |
+|------|--------|
+| `cmake` build + install (`MAX25_BUILD_MAX25_BCPR=ON` default) | Pass target |
+| `./scripts/terminology-check.sh` | Required |
+| `./scripts/release-check.sh` | Pass target |
+| `./scripts/tx-rx-test.sh` (L0 offline, RX before TX) | Pass target |
+| `test_bcpr_backend.py` | Pass |
+| `test_crdop_backend.py` | Pass |
+| `test_paths.py` | Pass |
+| `test_proto.py` / `test_auth.py` | Pass |
+| `stacks/terminal/test-terminal.sh` | Pass |
+
+## Consistency gate matrix
+
+| Gate | Status |
+|------|--------|
+| Version in `VERSION` file (currently `1.8.0`) | Required |
+| Localhost-only TCP (`tcp_host` loopback; no `0.0.0.0`) | Required |
+| Device face BayCom/based = **`max25e0`** (not `bcpr-bc0`) | Required |
+| `MAX25_BUILD_MAX25_BCPR=ON` default on Linux/FreeBSD/BSD | Required — see `cmake/MAX25Platform.cmake` |
+| No `plugins/` tree, no `stacks/tncs/` — devices set directly in `max25d.ini` | Required |
+| HyBBX co-host, web UI, multi-RF orchestration | Out of scope — must stay absent from build/install tree |
+| Canonical start | `scripts/run-max25d.sh` (§0.21 root only when SER12/bcpr needs it) |
+
+## Manual hardware gate matrix
+
+| Gate | Status |
+|------|--------|
+| BayCom/based `max25e0` (max25-bcpr) | active |
+| Live RX before TX (§0.20) | Required — see `scripts/tx-rx-test.sh`, `max25-bcpr-rxtx-smoke.sh` |
+| CRDOP soft modem bench | Optional |
+
+## Tag gate matrix
+
+| Item | Status |
+|------|--------|
+| Annotated tag matching `VERSION` | Operator decision |
+| Push to remote | Operator decision |
+
+## Related
+
+| Goal | Doc |
+|------|-----|
+| Build · wizard | [README.md](README.md) |
+| Doc index | [docs/README.md](docs/README.md) |
+| Changelog | [CHANGELOG.md](CHANGELOG.md) |
+| BayCom/based | [stacks/max25-bcpr/README.md](stacks/max25-bcpr/README.md) |
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..27f9cd3
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+1.8.0
diff --git a/cmake/MAX25Ax25.cmake b/cmake/MAX25Ax25.cmake
new file mode 100644
index 0000000..53193f6
--- /dev/null
+++ b/cmake/MAX25Ax25.cmake
@@ -0,0 +1,19 @@
+# MAX25Ax25.cmake — native AX.25 codec only (no upstream bundle).
+#
+# libax25 / ax25-tools / ax25-apps tarballs under third_party/ax25/ are study
+# reference material. max25d ships stacks/daemon/ax25_codec.py + kiss_bridge.py.
+# Optional host tools (listen, call, kissattach): install distro ax25-apps /
+# ax25-tools or run scripts/build-ax25-deps.sh manually (deprecated for MAX25).
+
+option(MAX25_BUNDLE_AX25
+ "DEPRECATED: build libax25/ax25-tools/ax25-apps from third_party/ax25 tarballs"
+ OFF)
+
+if(MAX25_BUNDLE_AX25)
+ message(WARNING
+ "MAX25_BUNDLE_AX25=ON is deprecated. "
+ "Use distro ax25-apps/ax25-tools or scripts/build-ax25-deps.sh manually. "
+ "max25d uses native ax25_codec.py — no libax25 link required.")
+endif()
+
+message(STATUS "AX.25: native ax25_codec.py (third_party/ax25 study reference only)")
diff --git a/cmake/MAX25Platform.cmake b/cmake/MAX25Platform.cmake
new file mode 100644
index 0000000..c121551
--- /dev/null
+++ b/cmake/MAX25Platform.cmake
@@ -0,0 +1,37 @@
+# MAX25-Stack-Lite platform defaults.
+#
+# Lite ship policy (settled): BayCom/based (stacks/max25-bcpr) is in-scope and
+# default ON on every POSIX-like Lite target — see product-repo-rules.md
+# "CMake ship profile (Lite)" and design-principles.md. This module must not
+# reintroduce the upstream MAX25-Stack default (bcpr opt-in/OFF) here, because
+# CMake's option() in the top-level CMakeLists.txt cannot override a CACHE
+# variable already created by an earlier include() — this module runs first.
+
+set(MAX25_SYSTEM "${CMAKE_SYSTEM_NAME}")
+
+if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
+ set(MAX25_PLATFORM "linux")
+ set(MAX25_DEFAULT_BUILD_TNCS ON)
+ set(MAX25_DEFAULT_BUILD_MAX25_BCPR ON)
+elseif(CMAKE_SYSTEM_NAME STREQUAL "FreeBSD")
+ set(MAX25_PLATFORM "freebsd")
+ set(MAX25_DEFAULT_BUILD_TNCS OFF)
+ set(MAX25_DEFAULT_BUILD_MAX25_BCPR ON)
+elseif(CMAKE_SYSTEM_NAME MATCHES "OpenBSD|NetBSD|DragonFly")
+ set(MAX25_PLATFORM "bsd")
+ set(MAX25_DEFAULT_BUILD_TNCS OFF)
+ set(MAX25_DEFAULT_BUILD_MAX25_BCPR ON)
+else()
+ set(MAX25_PLATFORM "generic")
+ set(MAX25_DEFAULT_BUILD_TNCS OFF)
+ set(MAX25_DEFAULT_BUILD_MAX25_BCPR OFF)
+endif()
+
+if(NOT DEFINED MAX25_BUILD_TNCS)
+ set(MAX25_BUILD_TNCS ${MAX25_DEFAULT_BUILD_TNCS} CACHE BOOL "Build stacks/tncs tools (not shipped in Lite; no-op)")
+endif()
+if(NOT DEFINED MAX25_BUILD_MAX25_BCPR)
+ set(MAX25_BUILD_MAX25_BCPR ${MAX25_DEFAULT_BUILD_MAX25_BCPR} CACHE BOOL "Build stacks/max25-bcpr BayCom/based SER12 (Lite: in-scope, default ON)")
+endif()
+
+message(STATUS "MAX25 platform=${MAX25_PLATFORM} system=${MAX25_SYSTEM}")
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..1d1ecba
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,13 @@
+# MAX25-Stack-Lite — documentation
+
+**Shipped operator entry:** [README.md](../README.md) · `max25-lite-setup` · `share/max25/*.example`
+
+Lite does **not** ship a Stack-style `docs/` manual tree.
+
+| Topic | Location |
+|-------|----------|
+| Build · verify · scope | [README.md](../README.md) |
+| Setup wizard | `scripts/max25-lite-setup.sh` |
+| BayCom/based PC-COM | `stacks/max25-bcpr/README.md` · plain messages via `max25-bcpr-ctl` |
+| CRDOP | `stacks/crdop/docs/` |
+| Full integrator stack | [MAX25-Stack](https://github.com/ngteq/MAX25-Stack) |
diff --git a/include/max25/packet-radio.md b/include/max25/packet-radio.md
new file mode 100644
index 0000000..cc11708
--- /dev/null
+++ b/include/max25/packet-radio.md
@@ -0,0 +1,49 @@
+# Packet radio constants (MAX25)
+
+Compact reference for `max25d` / `max25-terminal`. Depth prose lives in the research vault (project docs), not shipped in this repo.
+
+## AX.25
+
+| Symbol | Value |
+|--------|-------|
+| Call length | 1–6 chars, `A–Z` `0–9` |
+| SSID | 0–15 (`-0` … `-15`) |
+| Address field | 7 bytes encoded |
+| Max digipeaters | 8 |
+| UI control | `0x03` |
+| UI PID | `0xF0` (no layer 3) |
+| CRC | CCITT 0x1021, init 0xFFFF, LE on wire |
+| Max frame | ~330 bytes |
+| Max UI payload (host) | 256 bytes |
+| Broadcast payload hint (1200 baud) | ≤ 48 chars |
+
+## KISS
+
+| Symbol | Value |
+|--------|-------|
+| FEND | `0xC0` |
+| FESC | `0xDB` |
+| TFEND | `0xDC` |
+| TFESC | `0xDD` |
+| CMD DATA | `0x00` |
+| KISS DATA AX.25 | **without FCS** (TNC adds CRC) |
+
+## Serial / radio defaults
+
+| Item | Default |
+|------|---------|
+| Host pacing | 2400 baud equivalent |
+| Terminal width | 80 columns |
+| Input line | 80 chars |
+| TNC2C line | 7E1, RTS/DTR on |
+| AFSK on-air | 1200 baud |
+| G3RUH FSK | 9600 baud |
+| BayCom SER12 netdev | `bcsf0` |
+| USB TNC device | `/dev/ttyUSB0` |
+
+## MAX25 IDs
+
+| Terminal | AX.25 |
+|----------|-------|
+| CALLERID | source / MYCALL |
+| CALLID | destination |
diff --git a/include/max25/protocol.md b/include/max25/protocol.md
new file mode 100644
index 0000000..5381658
--- /dev/null
+++ b/include/max25/protocol.md
@@ -0,0 +1,213 @@
+# M25/1 — MAX25 Terminal ↔ max25d protocol
+
+**Version:** M25/1 (stable for `max25-terminal` / `max25-client`)
+
+Line-oriented UTF-8 text. One command or response per line, terminated by `\n` (LF). Optional `\r` before `\n` is stripped by the daemon.
+
+Developer guide: [../../stacks/terminal/README.md](../../stacks/terminal/README.md)
+
+---
+
+## Transport
+
+| Method | Default | Override |
+|--------|---------|----------|
+| TCP | `0.0.0.0:7325` | `max25d.ini` `[network]`, env `MAX25_HOST` / `MAX25_PORT` |
+| Unix stream | `/run/max25/modem.sock` | `max25d.ini`, env `MAX25_UNIX`; without root use a writable path or TCP |
+
+The official client tries Unix first (if configured), then TCP.
+
+---
+
+## Connection handshake
+
+On connect, **the daemon sends first** (client must not send before reading).
+
+### Without TCP auth (`tcp_password` empty)
+
+```
+OK
+STATUS hardware=<hw> device=<selected> devices=<id1,id2,...> mode=<mode> callerid=<id> callid=<id> ax25_ui=on|off connected=yes|no stack=<state> serial=<state> error=valid|invalid voice=valid|invalid
+```
+
+### With TCP auth (`[network] tcp_password` set — **TCP only**)
+
+Unix socket clients skip auth (local trust).
+
+```
+AUTH required
+AUTH <password>
+OK
+STATUS hardware=…
+```
+
+Client sends the password as a single line: `AUTH <password>` (plain text, v1).
+
+Wrong or missing password → `ERR auth failed` and the connection closes.
+
+| Client flag | Env |
+|-------------|-----|
+| `-P`, `--password` | `MAX25_TCP_PASSWORD` |
+
+`<state>` for `stack=` is typically `running`, `stopped`, or `error`.
+
+Implementations **must buffer** incomplete lines across `read()` calls.
+
+---
+
+## Client → daemon commands
+
+| Command | Description |
+|---------|-------------|
+| `PING` | Keepalive |
+| `GET STATUS` | Query state → `STATUS …` then `OK` |
+| `GET DEVICES` | List enabled devices → one `DEVICE …` line each, then `OK` |
+| `SET DEVICE <id>` | Select TX target device for this session (`SELECT DEVICE` alias) |
+| `SET CALLERID <id>` | Live source callsign (uppercase) |
+| `SET CALLID <id>` | Live destination callsign |
+| `SET AX25_UI on\|off` | Toggle AX.25 UI framing for TX |
+| `CONNECT` | Attach modem session on all enabled devices (required before `SEND`) |
+| `DISCONNECT` | Detach session (daemon keeps running) |
+| `SEND <text>` | Transmit line on session-selected device — payload is remainder of line after `SEND ` (may be empty) |
+| `MONITOR on\|off` | RX-only mode (`SEND` → `ERR monitor-only`) |
+| `BAN <callsign>` | Block AX.25 source — incoming UI frames dropped silently |
+| `UNBAN <callsign>` | Remove source from ban list |
+| `BANS` | List banned callsigns → one `BAN …` line each, then `OK` |
+
+Command keywords are case-sensitive except `SET AX25_UI` flags (`on`/`off` case-insensitive).
+
+### CALLSIGN rules
+
+AX.25 address text (see [packet-radio.md](packet-radio.md)):
+
+- Call body: **1–6** chars `A–Z` `0–9`
+- SSID: optional `-0` … `-15`
+
+Invalid `SET CALLERID` / `SET CALLID` → `ERR invalid CALLERID` / `ERR invalid CALLID`.
+
+---
+
+## Daemon → client responses
+
+| Line | Meaning |
+|------|---------|
+| `OK` | Command succeeded |
+| `ERR <message>` | Command failed |
+| `STATUS hardware=… device=… devices=… mode=… callerid=… callid=… ax25_ui=… connected=… stack=… serial=… error=valid\|invalid voice=valid\|invalid` | State snapshot |
+| `DEVICE id=… hardware=… serial=… stack=… enabled=… error=… voice=…` | One enabled device (`GET DEVICES`) |
+| `RX device=<id> <text>` | Received traffic from `<id>` for display |
+| `RX <text>` | Loopback TX echo (no serial) or legacy single-device |
+| `EVENT connected` | Session attached (`CONNECT`) |
+| `EVENT disconnected` | Session detached (`DISCONNECT`) |
+
+### Multi-line command replies
+
+| Command | Response sequence |
+|---------|-------------------|
+| `PING` | `OK` |
+| `GET STATUS` | `STATUS …` → `OK` |
+| `GET DEVICES` | `DEVICE …` (one per enabled id) → `OK` |
+| `SET DEVICE <id>` / `SELECT DEVICE <id>` | `OK` or `ERR unknown device: …` |
+| `SET CALLERID` / `SET CALLID` / `SET AX25_UI` / `MONITOR` | `OK` or `ERR …` |
+| `CONNECT` | `EVENT connected` → `OK` |
+| `DISCONNECT` | `EVENT disconnected` → `OK` |
+| `SEND <text>` | `RX device=<id> <framed>` → `OK` (sender); other clients get `RX …` only |
+
+With `ax25_ui=on`, framed text looks like: `[AX25 UI <callerid>><callid>] <payload>`.
+
+### Errors
+
+| Condition | Response |
+|-----------|----------|
+| Unknown command | `ERR unknown command: <word>` |
+| `SEND` without `CONNECT` | `ERR not connected` |
+| `SEND` in monitor mode | `ERR monitor-only` |
+| Invalid UTF-8 line | `ERR invalid utf-8` |
+
+---
+
+## Session model
+
+- `max25d` holds **global** `callerid`, `callid`, `ax25_ui` for all clients.
+- `device=` in `STATUS` is the **session TX target** (default: `[devices] default=` or first enabled).
+- `devices=` lists all enabled device ids (comma-separated).
+- `connected` is per-daemon-session state (shared across clients in current implementation).
+- `MONITOR` is per-daemon global flag in current implementation.
+- Hardware lifecycle (`stack=running`) is owned by `max25d` per device, not the terminal.
+- Each enabled device owns one serial port exclusively (one `KissBridge` each).
+
+## Multi-device configuration (legacy syntax, not a Lite operator path)
+
+> Multi-device / multi-host layouts (Main + Secondaries) are a full **MAX25-Stack** feature, out of scope for Lite. The `[devices]` multi-id syntax below is kept for protocol/parser compatibility; Lite's own setup wizard and docs always configure exactly **one** device on **one** localhost machine.
+
+`max25d.ini` `[devices]` section (see `share/max25/max25d.ini.example`):
+
+```ini
+[devices]
+default = tnc2c
+tnc2c = /dev/ttyS4
+```
+
+Legacy multi-id example (deprecated for new sites):
+
+```ini
+pktnc2 = /dev/ttyS5
+```
+
+Legacy single-device configs (`[daemon] device=` + optional `[serial]`) remain valid.
+
+Per-device serial overrides: `[serial.<id>]` sections (baud, line, dtr_rts, kiss_entry).
+
+### Reporting (`[reporting]` in INI)
+
+| Key | Default | Meaning |
+|-----|---------|---------|
+| `error_transmissions` | `yes` | `yes`: `error=valid` when link healthy and last AX.25 decode OK; `no`: always `error=invalid` |
+| `voice_transmissions` | `yes` | `yes`: `voice=valid` when acoustic/CRDOP path ready; `no`: always `voice=invalid` (TNC-only: `voice=valid`) |
+
+Invalid AX.25 UI frames on serial backends emit `EVENT device=<id> error=invalid` when `error_transmissions=yes`.
+
+---
+
+## Example session
+
+```
+← OK
+← STATUS hardware=tncs device=tnc2c devices=tnc2c mode=standalone callerid=CB-0 callid=QST ax25_ui=on connected=no stack=stopped
+→ SET DEVICE tnc2c
+← OK
+→ CONNECT
+← EVENT connected
+← OK
+→ SET CALLERID DG1ABC
+← OK
+→ SEND 73
+← RX device=tnc2c [AX25 UI DG1ABC>QST] 73
+← OK
+→ GET STATUS
+← STATUS hardware=tncs device=tnc2c devices=tnc2c,pktnc2 mode=standalone callerid=DG1ABC callid=QST ax25_ui=on connected=yes stack=stopped
+← OK
+→ DISCONNECT
+← EVENT disconnected
+← OK
+```
+
+(`→` client, `←` daemon)
+
+---
+
+## Reference code
+
+| Component | Path |
+|-----------|------|
+| C client library | `stacks/terminal/max25_proto.c` |
+| Terminal UI | `stacks/terminal/max25_terminal.c` |
+| Daemon server | `stacks/daemon/max25d` |
+| Offline smoke test | `stacks/daemon/test_proto.py` |
+| Multi-device tests | `stacks/daemon/test_multi_device.py` |
+
+---
+
+## Stability
+
+M25/1 is the long-term binding contract for **`max25-terminal` only**. New features should extend this protocol with documented, backward-compatible lines — not introduce a parallel client protocol.
diff --git a/local/README.md b/local/README.md
new file mode 100644
index 0000000..633e98f
--- /dev/null
+++ b/local/README.md
@@ -0,0 +1,25 @@
+# Site-local configuration (not committed)
+
+Your own copies live here. This directory is **gitignored** except this file.
+
+**Never commit** live INI files, passwords, hostnames, or other site-private data. The setup wizard (`max25-lite-setup.sh`) can write here for you when you choose "No" at the save-location screen.
+
+**Localhost only:** MAX25-Stack-Lite runs max25d, the modem, and the terminal on the **same PC** — see the vault [localhost-policy.md](https://github.com/ngteq/MAX25-Stack-Lite) (or the copy shipped with your build). Do not set `tcp_host` to a LAN address.
+
+## Typical files
+
+| File | Written by | Used by |
+|------|------------|---------|
+| `max25d.ini` | wizard or manual copy of `share/max25/max25d.lite*.ini.example` | `run-max25d.sh ./local/max25d.ini` |
+| `crdop.ini` | wizard (CRDOP path) | `crdop -c ./local/crdop.ini` |
+| `max25-bcpr.ini` | wizard (BayCom/based path) | `max25-bcpr-ctl start --ini ./local/max25-bcpr.ini` |
+
+## Quick start (no sudo)
+
+```bash
+./scripts/max25-lite-setup.sh # choose "No" (system config) at the save-location screen
+./scripts/run-max25d.sh ./local/max25d.ini
+./scripts/run-max25-terminal.sh -U /run/max25/modem.sock
+```
+
+`tcp_password` stays empty on Lite — loopback only, no LAN auth to manage.
diff --git a/scripts/build-all.sh b/scripts/build-all.sh
new file mode 100755
index 0000000..571b466
--- /dev/null
+++ b/scripts/build-all.sh
@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+# MainAX25-Stack — build all merged stacks (max25-bcpr default ON).
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$ROOT"
+BUILD_DIR="${BUILD_DIR:-build}"
+BUILD_TYPE="${CMAKE_BUILD_TYPE:-Release}"
+DO_INSTALL=0
+PREFIX="${PREFIX:-/usr/local}"
+
+usage() {
+ cat <<USAGE
+Usage: $0 [--install] [--prefix DIR] [--build-dir DIR]
+
+ Default: cmake configure + build (TERMINAL/DAEMON/BCPR ON).
+ --install also cmake --install to PREFIX
+USAGE
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --install) DO_INSTALL=1; shift ;;
+ --prefix) PREFIX="$2"; shift 2 ;;
+ --build-dir) BUILD_DIR="$2"; shift 2 ;;
+ -h|--help) usage; exit 0 ;;
+ *) echo "unknown: $1" >&2; usage; exit 2 ;;
+ esac
+done
+
+echo "== MainAX25-Stack build-all (${BUILD_DIR}, ${BUILD_TYPE}) =="
+cmake -B "${BUILD_DIR}" \
+ -DCMAKE_BUILD_TYPE="${BUILD_TYPE}" \
+ -DMAX25_BUILD_MAX25_BCPR="${MAX25_BUILD_MAX25_BCPR:-ON}" \
+ -DMAX25_BUILD_TERMINAL=ON \
+ -DMAX25_BUILD_DAEMON=ON \
+ -DMAX25_BUNDLE_AX25=OFF
+cmake --build "${BUILD_DIR}" -j"$(nproc 2>/dev/null || echo 2)"
+
+echo "== plugin discovery =="
+bash scripts/discover-plugins.sh || true
+
+if [[ "$DO_INSTALL" -eq 1 ]]; then
+ echo "== install → ${PREFIX} =="
+ if [[ "$(id -u)" -eq 0 ]]; then
+ cmake --install "${BUILD_DIR}" --prefix "${PREFIX}"
+ else
+ sudo cmake --install "${BUILD_DIR}" --prefix "${PREFIX}"
+ fi
+ echo "Launchers: ${PREFIX}/bin/run-max25d ${PREFIX}/bin/run-max25-terminal"
+ echo "Tests: ${PREFIX}/sbin/max25-tx-rx-test ${PREFIX}/sbin/max25-bcpr-rxtx-smoke.sh"
+fi
+
+echo "== done =="
diff --git a/scripts/build-amiga-terminal.sh b/scripts/build-amiga-terminal.sh
new file mode 100755
index 0000000..f6336b5
--- /dev/null
+++ b/scripts/build-amiga-terminal.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env sh
+# Cross-build max25-terminal for AmigaOS 3.9+ (bebbo-gcc / clib2).
+set -eu
+
+ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
+SDK="${AMIGA_SDK_PATH:-/opt/amiga}"
+
+if [ ! -x "$SDK/bin/m68k-amigaos-gcc" ]; then
+ echo "Amiga SDK not found at $SDK (set AMIGA_SDK_PATH)" >&2
+ exit 1
+fi
+
+make -C "$ROOT/stacks/terminal/amiga" AMIGA_SDK="$SDK" all
+make -C "$ROOT/stacks/terminal/amiga" AMIGA_SDK="$SDK" test
+
+echo "AmigaOS client:"
+echo " $ROOT/stacks/terminal/amiga/max25-terminal"
diff --git a/scripts/build-ax25-deps.sh b/scripts/build-ax25-deps.sh
new file mode 100755
index 0000000..d909cdc
--- /dev/null
+++ b/scripts/build-ax25-deps.sh
@@ -0,0 +1,146 @@
+#!/usr/bin/env bash
+# DEPRECATED — manual build of vendored libax25 / ax25-tools / ax25-apps (Linux).
+#
+# MAX25 default build does NOT invoke this script. Tarballs in third_party/ax25/
+# are study reference only; max25d uses native ax25_codec.py.
+#
+# Use when you need host listen/call/kissattach without distro packages:
+# scripts/build-ax25-deps.sh --prefix /opt/ax25 --need-apps --need-tools
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+VENDOR="${ROOT}/third_party/ax25"
+PATCHES="${VENDOR}/patches"
+
+LIBAX25_VER="0.0.12-rc5"
+TOOLS_VER="0.0.10-rc5"
+APPS_VER="0.0.8-rc5"
+
+usage() {
+ cat <<EOF
+Usage: $0 --prefix DIR [options]
+
+Options:
+ --prefix DIR Install prefix (required)
+ --build-dir DIR Extract/build tree (default: <prefix>/../build)
+ --need-libax25 Build libax25 even if present on host
+ --need-tools Build ax25-tools
+ --need-apps Build ax25-apps
+ -h, --help This help
+
+Environment:
+ MAKE, CC, CXX Passed to upstream make/configure
+EOF
+}
+
+PREFIX=""
+BUILD_DIR=""
+NEED_LIBAX25=0
+NEED_TOOLS=0
+NEED_APPS=0
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --prefix) PREFIX="$2"; shift 2 ;;
+ --build-dir) BUILD_DIR="$2"; shift 2 ;;
+ --need-libax25) NEED_LIBAX25=1; shift ;;
+ --need-tools) NEED_TOOLS=1; shift ;;
+ --need-apps) NEED_APPS=1; shift ;;
+ -h|--help) usage; exit 0 ;;
+ *) echo "Unknown option: $1" >&2; usage; exit 1 ;;
+ esac
+done
+
+[[ -n "$PREFIX" ]] || { echo "error: --prefix required" >&2; exit 1; }
+PREFIX="$(cd "$(dirname "$PREFIX")" && pwd)/$(basename "$PREFIX")"
+BUILD_DIR="${BUILD_DIR:-$(dirname "$PREFIX")/build}"
+BUILD_DIR="$(mkdir -p "$BUILD_DIR" && cd "$BUILD_DIR" && pwd)"
+PREFIX="$(mkdir -p "$PREFIX" && cd "$PREFIX" && pwd)"
+
+verify_tarball() {
+ local file="$1"
+ [[ -f "$file" ]] || { echo "error: missing tarball $file" >&2; exit 1; }
+ if [[ -f "${VENDOR}/SHA256SUMS" ]]; then
+ local base
+ base="$(basename "$file")"
+ (cd "$VENDOR" && sha256sum -c SHA256SUMS 2>/dev/null | grep -q "^${base}: OK") \
+ || echo "WARN: SHA256 mismatch for ${base} — continuing" >&2
+ fi
+}
+
+extract() {
+ local tarball="$1" dir="$2"
+ verify_tarball "$tarball"
+ rm -rf "$dir"
+ tar xzf "$tarball" -C "$BUILD_DIR"
+}
+
+apply_patches() {
+ local srcdir="$1"
+ shift
+ local patch failed=0
+ for patch in "$@"; do
+ [[ -f "$patch" ]] || continue
+ echo "-- patch $(basename "$patch")"
+ if ! patch -d "$srcdir" -p1 -N -s -f < "$patch"; then
+ echo "error: patch failed: $(basename "$patch")" >&2
+ failed=1
+ fi
+ done
+ [[ "$failed" -eq 0 ]]
+}
+
+ax25_link_flags() {
+ if [[ "$NEED_LIBAX25" -eq 1 ]]; then
+ export CPPFLAGS="-I${PREFIX}/include ${CPPFLAGS:-}"
+ export LDFLAGS="-L${PREFIX}/lib -Wl,-rpath,${PREFIX}/lib ${LDFLAGS:-}"
+ elif [[ -d /usr/include/netax25 ]]; then
+ export CPPFLAGS="-I/usr/include ${CPPFLAGS:-}"
+ export LDFLAGS="${LDFLAGS:-}"
+ fi
+}
+
+build_libax25() {
+ local dir="${BUILD_DIR}/libax25-${LIBAX25_VER}"
+ echo "== libax25 ${LIBAX25_VER} -> ${PREFIX}"
+ extract "${VENDOR}/libax25-${LIBAX25_VER}.tar.gz" "$dir"
+ (
+ cd "$dir"
+ ./configure --prefix="$PREFIX"
+ "${MAKE:-make}" -j"$(nproc 2>/dev/null || echo 2)"
+ "${MAKE:-make}" install
+ )
+}
+
+build_ax25_tools() {
+ local dir="${BUILD_DIR}/ax25-tools-${TOOLS_VER}"
+ echo "== ax25-tools ${TOOLS_VER} -> ${PREFIX}"
+ extract "${VENDOR}/ax25-tools-${TOOLS_VER}.tar.gz" "$dir"
+ ax25_link_flags
+ (
+ cd "$dir"
+ ./configure --prefix="$PREFIX"
+ "${MAKE:-make}" -j"$(nproc 2>/dev/null || echo 2)"
+ "${MAKE:-make}" install
+ )
+}
+
+build_ax25_apps() {
+ local dir="${BUILD_DIR}/ax25-apps-${APPS_VER}"
+ echo "== ax25-apps ${APPS_VER} -> ${PREFIX}"
+ extract "${VENDOR}/ax25-apps-${APPS_VER}.tar.gz" "$dir"
+ apply_patches "$dir" "${PATCHES}/ax25-apps-0.0.8-termios.patch"
+ ax25_link_flags
+ (
+ cd "$dir"
+ ./configure --prefix="$PREFIX"
+ "${MAKE:-make}" -j"$(nproc 2>/dev/null || echo 2)"
+ "${MAKE:-make}" install
+ )
+}
+
+[[ "$NEED_LIBAX25" -eq 1 ]] && build_libax25
+[[ "$NEED_TOOLS" -eq 1 ]] && build_ax25_tools
+[[ "$NEED_APPS" -eq 1 ]] && build_ax25_apps
+
+echo "== AX.25 deps ready under ${PREFIX} =="
diff --git a/scripts/build-freebsd.sh b/scripts/build-freebsd.sh
new file mode 100755
index 0000000..9c9ac6c
--- /dev/null
+++ b/scripts/build-freebsd.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+# build-freebsd.sh — MAX25-Stack-Lite FreeBSD build (max25d, max25-bcpr, max25-terminal, CRDOP/OSS)
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$ROOT"
+
+BUILD_TYPE="${CMAKE_BUILD_TYPE:-Release}"
+BUILD_DIR="${BUILD_DIR:-build}"
+
+if [[ "$(uname -s)" != "FreeBSD" ]]; then
+ echo "build-freebsd.sh: FreeBSD only" >&2
+ exit 1
+fi
+
+echo "== MAX25-Stack-Lite FreeBSD build -> ${BUILD_DIR} (${BUILD_TYPE}) =="
+
+cmake -B "${BUILD_DIR}" \
+ -DCMAKE_BUILD_TYPE="${BUILD_TYPE}" \
+ -DMAX25_BUILD_TNCS=OFF \
+ -DMAX25_BUILD_MAX25_BCPR=ON \
+ -DMAX25_BUILD_CRDOP=ON \
+ -DMAX25_BUILD_DAEMON=ON \
+ -DMAX25_BUILD_TERMINAL=ON \
+ -DMAX25_BUNDLE_AX25=OFF \
+ "$@"
+
+cmake --build "${BUILD_DIR}" -j"$(sysctl -n hw.ncpu 2>/dev/null || echo 2)"
+
+echo "== build done: ${BUILD_DIR}/bin =="
diff --git a/scripts/build.sh b/scripts/build.sh
new file mode 100755
index 0000000..87c957d
--- /dev/null
+++ b/scripts/build.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+# MainAX25-Stack (MAX25-Stack) — configure and build via CMake.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$ROOT"
+
+BUILD_TYPE="${CMAKE_BUILD_TYPE:-Release}"
+
+cmake -B build -DCMAKE_BUILD_TYPE="${BUILD_TYPE}"
+cmake --build build -j"$(nproc 2>/dev/null || echo 2)"
diff --git a/scripts/clean.sh b/scripts/clean.sh
new file mode 100755
index 0000000..9447da2
--- /dev/null
+++ b/scripts/clean.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+# MainAX25-Stack (MAX25-Stack) — remove CMake build trees.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$ROOT"
+
+rm -rf build stacks/crdop/build
+echo "Removed build/ and stacks/crdop/build/"
diff --git a/scripts/discover-plugins.sh b/scripts/discover-plugins.sh
new file mode 100755
index 0000000..651b963
--- /dev/null
+++ b/scripts/discover-plugins.sh
@@ -0,0 +1,97 @@
+#!/usr/bin/env bash
+# discover-plugins.sh — list device backends (Lite: configured directly in max25d.ini, no plugins/ tree).
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+MANIFEST="${ROOT}/plugins/manifest.yaml"
+JSON=0
+
+usage() {
+ cat <<'EOF'
+Usage: discover-plugins.sh [--json] [--type TYPE] [--id ID]
+
+ --json Machine-readable output (one JSON object per line)
+ --type TYPE Filter: hardware | device
+ --id ID Show single plugin by id
+EOF
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --json) JSON=1; shift ;;
+ --type) TYPE_FILTER="${2:-}"; shift 2 ;;
+ --id) ID_FILTER="${2:-}"; shift 2 ;;
+ -h|--help) usage; exit 0 ;;
+ *) echo "Unknown option: $1" >&2; usage; exit 1 ;;
+ esac
+done
+
+if [[ ! -f "$MANIFEST" ]]; then
+ echo "discover-plugins: no plugin manifest in MAX25-Stack-Lite — devices are set in max25d.ini directly (see README.md / max25-lite-setup)" >&2
+ exit 1
+fi
+
+emit_plugin() {
+ local type="$1" id="$2" path="$3" extra="${4:-}"
+ local full="${ROOT}/plugins/${path}"
+ local yaml="${full}/plugin.yaml"
+ local label="" status="" hybbx=""
+
+ if [[ -f "$yaml" ]]; then
+ label=$(grep -E '^label:' "$yaml" 2>/dev/null | head -1 | sed 's/^label:[[:space:]]*//' || true)
+ status=$(grep -E '^status:' "$yaml" 2>/dev/null | head -1 | sed 's/^status:[[:space:]]*//' || true)
+ hybbx=$(grep -E '^(hybbx_plugin|plugin):' "$yaml" 2>/dev/null | head -1 | sed 's/^[^:]*:[[:space:]]*//' || true)
+ fi
+
+ if [[ -n "${TYPE_FILTER:-}" && "$type" != "$TYPE_FILTER" ]]; then return; fi
+ if [[ -n "${ID_FILTER:-}" && "$id" != "$ID_FILTER" ]]; then return; fi
+
+ if [[ "$JSON" -eq 1 ]]; then
+ printf '{"type":"%s","id":"%s","path":"plugins/%s","label":"%s","status":"%s","hybbx":"%s"%s}\n' \
+ "$type" "$id" "$path" "$label" "$status" "$hybbx" "$extra"
+ else
+ printf '%-14s %-18s %-30s %s\n' "$type" "$id" "plugins/$path" "${label:-—}"
+ fi
+}
+
+if [[ "$JSON" -eq 0 && -z "${ID_FILTER:-}" ]]; then
+ printf '%-14s %-18s %-30s %s\n' "TYPE" "ID" "PATH" "LABEL"
+ printf '%s\n' "--------------------------------------------------------------------------------"
+fi
+
+# Parse manifest sections (simple line-based — no PyYAML dependency)
+section=""
+while IFS= read -r line || [[ -n "$line" ]]; do
+ line="${line%%#*}"
+ line="${line%"${line##*[![:space:]]}"}"
+ [[ -z "$line" ]] && continue
+
+ case "$line" in
+ betriebsform:) section=""; continue ;;
+ hardware:) section=hardware; continue ;;
+ devices:) section=device; continue ;;
+ schema_version:*|stack:*|description:*) continue ;;
+ esac
+
+ [[ -z "$section" ]] && continue
+
+ if [[ "$line" =~ ^[[:space:]]*-[[:space:]]+id:[[:space:]]*(.+)$ ]]; then
+ cur_id="${BASH_REMATCH[1]}"
+ cur_path=""
+ continue
+ fi
+ if [[ -n "${cur_id:-}" && "$line" =~ ^[[:space:]]+path:[[:space:]]*(.+)$ ]]; then
+ cur_path="${BASH_REMATCH[1]}"
+ emit_plugin "$section" "$cur_id" "$cur_path"
+ cur_id=""
+ fi
+done < "$MANIFEST"
+
+# Also scan for plugin.yaml not in manifest (future auto-gen)
+while IFS= read -r yaml; do
+ dir="$(dirname "$yaml")"
+ rel="${dir#${ROOT}/plugins/}"
+ id="$(grep -E '^id:' "$yaml" | head -1 | sed 's/^id:[[:space:]]*//' || basename "$dir")"
+ type="$(grep -E '^type:' "$yaml" | head -1 | sed 's/^type:[[:space:]]*//' || echo unknown)"
+ # Skip if already emitted via manifest for same id+type would duplicate — acceptable for scaffold
+done < <(find "${ROOT}/plugins" -name plugin.yaml -not -path '*/betriebsform/*' 2>/dev/null | sort)
diff --git a/scripts/install-freebsd.sh b/scripts/install-freebsd.sh
new file mode 100755
index 0000000..3cc89ed
--- /dev/null
+++ b/scripts/install-freebsd.sh
@@ -0,0 +1,111 @@
+#!/usr/bin/env bash
+# install-freebsd.sh — MAX25-Stack-Lite FreeBSD build + install (daemon, max25-bcpr, terminal, CRDOP/OSS)
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+PREFIX="${PREFIX:-/usr/local}"
+ETC="${ETC:-${PREFIX}/etc/max25}"
+INSTALL_DEPS=0
+BUILD_ONLY=0
+BUILD_DIR="${BUILD_DIR:-build}"
+
+usage() {
+ cat <<EOF
+MAX25-Stack-Lite install — FreeBSD
+
+Usage: $0 [options]
+
+Options:
+ --deps Install pkg build/runtime dependencies
+ --build-only Build only, do not install
+ --prefix DIR Install prefix (default: /usr/local)
+ --build-dir D CMake build dir (default: build)
+ -h, --help This help
+
+Build (default): max25d, max25-bcpr, max25-terminal, CRDOP (OSS audio)
+
+After install:
+ sudo mkdir -p /var/run/max25 ${ETC}
+ sudo cp ${PREFIX}/share/max25/max25d.lite.bcpr.ini.example ${ETC}/max25d.ini
+ sudo cp ${PREFIX}/share/max25-bcpr/max25-bcpr.freebsd.ini.example ${ETC}/max25-bcpr.ini
+ sudo max25-bcpr-ctl start --ini ${ETC}/max25-bcpr.ini
+ sudo ${ROOT}/scripts/run-max25d.sh ${ETC}/max25d.ini
+
+Doc index: docs/README.md (stub — depth docs live in the research vault)
+EOF
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --deps) INSTALL_DEPS=1; shift ;;
+ --build-only) BUILD_ONLY=1; shift ;;
+ --prefix) PREFIX="$2"; shift 2 ;;
+ --build-dir) BUILD_DIR="$2"; shift 2 ;;
+ -h|--help) usage; exit 0 ;;
+ *) echo "Unknown option: $1" >&2; usage; exit 1 ;;
+ esac
+done
+
+if [[ "$(uname -s)" != "FreeBSD" ]]; then
+ echo "install-freebsd.sh: FreeBSD only" >&2
+ exit 1
+fi
+
+echo "== MAX25 install: $(uname -s) $(uname -m) → ${PREFIX} =="
+
+if [[ "$INSTALL_DEPS" -eq 1 ]]; then
+ pkgs=(cmake python3 ncurses sox git gmake pkgconf)
+ echo "Installing packages: ${pkgs[*]}"
+ pkg install -y "${pkgs[@]}"
+fi
+
+BUILD_DIR="${BUILD_DIR}" "${ROOT}/scripts/build-freebsd.sh"
+
+if [[ "$BUILD_ONLY" -eq 1 ]]; then
+ echo "== build done (no install) =="
+ exit 0
+fi
+
+echo "-- install to ${PREFIX}"
+if [[ "$(id -u)" -eq 0 ]]; then
+ cmake --install "${ROOT}/${BUILD_DIR}" --prefix "${PREFIX}"
+else
+ sudo cmake --install "${ROOT}/${BUILD_DIR}" --prefix "${PREFIX}"
+fi
+
+_install_ini() {
+ local src="$1" dst="$2"
+ if [[ ! -f "$dst" ]] && [[ -f "$src" ]]; then
+ mkdir -p "$(dirname "$dst")"
+ if [[ "$(id -u)" -eq 0 ]]; then
+ cp "$src" "$dst"
+ else
+ sudo cp "$src" "$dst"
+ fi
+ echo " installed $dst (from example)"
+ fi
+}
+
+if [[ "$(id -u)" -eq 0 ]] || sudo -n true 2>/dev/null; then
+ _install_ini "${ROOT}/share/max25-bcpr/max25-bcpr.freebsd.ini.example" "${ETC}/max25-bcpr.ini"
+ _install_ini "${ROOT}/share/max25/max25d.lite.bcpr.ini.example" "${ETC}/max25d.ini"
+fi
+
+echo ""
+echo "== MAX25-Stack-Lite installed to ${PREFIX} =="
+echo " max25d, max25-terminal → ${PREFIX}/bin"
+echo " max25-bcpr-ctl, max25-bcprd-init → ${PREFIX}/sbin"
+echo " examples → ${PREFIX}/share/max25/"
+echo ""
+echo "Next steps (PC-COM / ExSys cuau2 — edit INI before live RF):"
+echo " pw groupmod dialer -m \$USER"
+echo " sudo mkdir -p /var/run/max25 ${ETC}"
+echo " sudo chown \$USER:dialer /var/run/max25"
+echo " # ${ETC}/max25-bcpr.ini: serial=/dev/cuau2 iobase=0xd090 irq=36"
+echo " # ${ETC}/max25d.ini: [daemon] user=… group=dialer"
+echo " sudo max25-bcpr-ctl start --ini ${ETC}/max25-bcpr.ini"
+echo " sudo ${ROOT}/scripts/run-max25d.sh ${ETC}/max25d.ini"
+echo " max25-terminal -H 127.0.0.1 -p 7325 -d max25e0"
+echo ""
+echo "Optional: CRDOP/OSS → ${PREFIX}/share/crdop/crdop.freebsd.ini.example"
+echo ""
diff --git a/scripts/install-max25.sh b/scripts/install-max25.sh
new file mode 100755
index 0000000..44e3eb4
--- /dev/null
+++ b/scripts/install-max25.sh
@@ -0,0 +1,116 @@
+#!/usr/bin/env bash
+# install-max25.sh — MAX25-Stack-Lite build and install on Linux (daemon, bcpr, terminal, tests).
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+PREFIX="${PREFIX:-/usr/local}"
+INSTALL_DEPS=0
+BUILD_ONLY=0
+BUILD_DIR="${BUILD_DIR:-build}"
+
+usage() {
+ cat <<EOF
+MAX25-Stack-Lite install — Linux
+
+Usage: $0 [options]
+
+Options:
+ --deps Install apt build dependencies
+ --build-only Build only, do not install
+ --prefix DIR Install prefix (default: /usr/local)
+ --build-dir D CMake build dir (default: build)
+ -h, --help This help
+
+After install:
+ run-max25d
+ ./scripts/tx-rx-test.sh --device modem --live # from the source tree; RX before TX (§0.20)
+ run-max25-terminal -U /run/max25/modem.sock
+
+Doc index: docs/README.md (stub — depth docs live in the research vault)
+EOF
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --deps) INSTALL_DEPS=1; shift ;;
+ --build-only) BUILD_ONLY=1; shift ;;
+ --prefix) PREFIX="$2"; shift 2 ;;
+ --build-dir) BUILD_DIR="$2"; shift 2 ;;
+ -h|--help) usage; exit 0 ;;
+ *) echo "Unknown option: $1" >&2; usage; exit 1 ;;
+ esac
+done
+
+if [[ "$(uname -s)" != "Linux" ]]; then
+ echo "install-max25.sh: Linux only" >&2
+ exit 1
+fi
+
+echo "== MAX25 install: $(uname -s) $(uname -m) → ${PREFIX} =="
+
+if [[ "$INSTALL_DEPS" -eq 1 ]]; then
+ if command -v apt-get >/dev/null 2>&1; then
+ sudo apt-get update
+ pkgs=(build-essential make cmake pkg-config git python3 libncurses-dev libasound2-dev)
+ sudo apt-get install -y "${pkgs[@]}" || true
+ fi
+fi
+
+cd "$ROOT"
+echo "-- cmake (BCPR+TERMINAL+DAEMON ON — max25-bcpr default)"
+cmake -B "${BUILD_DIR}" \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DMAX25_BUILD_MAX25_BCPR=ON \
+ -DMAX25_BUILD_TERMINAL=ON \
+ -DMAX25_BUILD_DAEMON=ON \
+ -DMAX25_BUNDLE_AX25=OFF
+cmake --build "${BUILD_DIR}" -j"$(nproc 2>/dev/null || echo 2)"
+
+if [[ "$BUILD_ONLY" -eq 1 ]]; then
+ echo "== build done (no install) =="
+ exit 0
+fi
+
+echo "-- install to ${PREFIX}"
+if [[ "$(id -u)" -eq 0 ]]; then
+ cmake --install "${BUILD_DIR}" --prefix "$PREFIX"
+else
+ sudo cmake --install "${BUILD_DIR}" --prefix "$PREFIX"
+fi
+
+echo ""
+echo "== MAX25 installed to ${PREFIX} =="
+echo " max25d, kiss_bridge.py, max25-terminal, max25-ctl -> ${PREFIX}/bin"
+echo " max25-bcpr-ctl -> ${PREFIX}/sbin (when MAX25_BUILD_MAX25_BCPR=ON)"
+echo " examples -> ${PREFIX}/share/max25/"
+echo ""
+
+echo ""
+echo "Next steps (serial TNC / USB KISS):"
+echo " sudo usermod -aG dialout \$USER # serial access"
+echo " sudo cp share/max25/max25d.lite.ini.example /etc/max25/max25d.ini"
+echo " sudo max25d -c /etc/max25/max25d.ini"
+echo " max25-terminal -U /run/max25/modem.sock"
+echo ""
+echo "BayCom/based (max25-bcpr): built by default (MAX25_BUILD_MAX25_BCPR=ON)."
+echo " See stacks/max25-bcpr/README.md · share/max25-bcpr/max25-bcpr.ini.example"
+echo ""
+ETC="${ETC:-/etc/max25}"
+if [[ "$(id -u)" -eq 0 ]] || sudo -n true 2>/dev/null; then
+ _install_ini() {
+ local src="$1" dst="$2"
+ if [[ ! -f "$dst" ]] && [[ -f "$src" ]]; then
+ mkdir -p "$ETC"
+ if [[ "$(id -u)" -eq 0 ]]; then
+ cp "$src" "$dst"
+ else
+ sudo cp "$src" "$dst"
+ fi
+ echo " installed $dst (from example)"
+ fi
+ }
+ _install_ini "$ROOT/share/max25-bcpr/max25-bcpr.ini.example" "$ETC/max25-bcpr.ini"
+ if [[ -f "$ETC/bcpr.ini" ]] && [[ ! -L "$ETC/max25-bcpr.ini" ]]; then
+ echo " note: legacy $ETC/bcpr.ini kept — paths /tmp/bcpr auto-map to /tmp/max25-bcpr at runtime"
+ fi
+fi
diff --git a/scripts/max25-ctl b/scripts/max25-ctl
new file mode 100755
index 0000000..473b4a2
--- /dev/null
+++ b/scripts/max25-ctl
@@ -0,0 +1,408 @@
+#!/usr/bin/env bash
+# max25-ctl — MAX25-Stack-Lite control entry point (Packet Radio / AX.25, localhost).
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PREFIX=""
+ROOT=""
+
+if [[ -f "${SCRIPT_DIR}/../plugins/manifest.yaml" ]]; then
+ ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
+elif [[ -d "${SCRIPT_DIR}/../share/max25" ]]; then
+ PREFIX="$(cd "${SCRIPT_DIR}/.." && pwd)"
+ ROOT="${MAX25_ROOT:-${PREFIX}}"
+else
+ ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
+fi
+
+stack_script() {
+ local rel="$1"
+ if [[ -f "${ROOT}/stacks/${rel}" ]]; then
+ echo "${ROOT}/stacks/${rel}"
+ return 0
+ fi
+ if [[ -n "${PREFIX}" && -f "${PREFIX}/share/max25/stacks/${rel}" ]]; then
+ echo "${PREFIX}/share/max25/stacks/${rel}"
+ return 0
+ fi
+ return 1
+}
+
+resolve_crdop_bin() {
+ if [[ -n "${CRDOP_BIN:-}" && -x "${CRDOP_BIN}" ]]; then
+ echo "${CRDOP_BIN}"
+ return 0
+ fi
+ if command -v crdopc >/dev/null 2>&1; then
+ command -v crdopc
+ return 0
+ fi
+ if [[ -x "${ROOT}/build/bin/crdopc" ]]; then
+ echo "${ROOT}/build/bin/crdopc"
+ return 0
+ fi
+ return 1
+}
+
+resolve_baycom_ctl() {
+ local ctl=""
+ ctl="$(stack_script "baycom-pr/scripts/baycom-pr-ctl" 2>/dev/null || true)"
+ if [[ -n "${ctl}" && -x "${ctl}" ]]; then
+ echo "${ctl}"
+ return 0
+ fi
+ if command -v baycom-pr-ctl >/dev/null 2>&1; then
+ command -v baycom-pr-ctl
+ return 0
+ fi
+ return 1
+}
+
+MODE="${MAX25_MODE:-standalone}"
+HARDWARE=""
+DEVICE=""
+BAYCOM_INI=""
+BAYCOM_PROFILE=""
+ACTION="${1:-help}"
+shift || true
+
+usage() {
+ cat <<EOF
+max25-ctl — MAX25-Stack-Lite control (Packet Radio / AX.25, localhost)
+
+Usage:
+ max25-ctl <command> [options]
+
+Commands:
+ help This help
+ discover List local device backends
+ build Build the project (cmake -B build && cmake --build build)
+ start Start a hardware/soft-modem path
+ stop Stop a hardware/soft-modem path
+ status Show device status
+ test Offline tests (no root)
+
+Daemon (Linux):
+ stacks/daemon/max25d Launcher (ps shows this path, not python3)
+ stacks/daemon/max25d.py M25/1 daemon implementation
+ stacks/terminal/max25-terminal Operator UI (F10 menu, CALLERID/CALLID)
+
+Options (start/stop/status):
+ --hardware HW modems | soft-modems
+ --device DEV max25e0 (BayCom/based) | baycom-kiss | soft-crdop
+
+Examples:
+ max25-ctl discover
+ max25-ctl build
+ max25-ctl start --hardware modems --device max25e0
+ max25-ctl start --hardware modems --device baycom-kiss
+ max25-ctl start --hardware soft-modems --device soft-crdop
+ max25-ctl test
+
+Lite scope (max25-terminal, max25d, BayCom/based, CRDOP): README.md
+ SET DEVICE <id> → CONNECT → SEND via max25-terminal after max25d is up
+EOF
+}
+
+parse_opts() {
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --mode) MODE="$2"; shift 2 ;;
+ --hardware) HARDWARE="$2"; shift 2 ;;
+ --device) DEVICE="$2"; shift 2 ;;
+ --baycom-ini) BAYCOM_INI="$2"; shift 2 ;;
+ --baycom-profile) BAYCOM_PROFILE="$2"; shift 2 ;;
+ *) echo "Unknown option: $1" >&2; usage; exit 1 ;;
+ esac
+ done
+}
+
+resolve_baycom_ini_path() {
+ local device="${1:-max25e0}"
+ local explicit="${2:-${BAYCOM_INI:-}}"
+ local profile="${3:-${BAYCOM_PROFILE:-}}"
+
+ if [[ -z "${explicit}" && -n "${profile}" ]]; then
+ local paths_py=""
+ if [[ -f "${ROOT}/stacks/daemon/paths.py" ]]; then
+ paths_py="${ROOT}/stacks/daemon/paths.py"
+ elif [[ -n "${PREFIX}" && -f "${PREFIX}/bin/paths.py" ]]; then
+ paths_py="${PREFIX}/bin/paths.py"
+ fi
+ if [[ -n "${paths_py}" ]]; then
+ local prefix_py="None"
+ if [[ -n "${PREFIX}" ]]; then
+ prefix_py="Path('${PREFIX}')"
+ fi
+ explicit="$(python3 -c "
+import sys
+from pathlib import Path
+sys.path.insert(0, '$(dirname "${paths_py}")')
+from paths import resolve_baycom_profile
+p = resolve_baycom_profile('${profile}', '${device}', Path('${ROOT}'), ${prefix_py})
+print(p or '')
+" 2>/dev/null || true)"
+ if [[ -z "${explicit}" ]]; then
+ echo "baycom profile '${profile}' not found (use --baycom-ini PATH)" >&2
+ return 1
+ fi
+ elif [[ "${profile}" == "dual" ]]; then
+ for candidate in \
+ "${ROOT}/stacks/baycom-pr/config/examples/baycom-pr.dual.ini" \
+ "${PREFIX}/share/max25/stacks/baycom-pr/config/examples/baycom-pr.dual.ini"; do
+ if [[ -f "${candidate}" ]]; then
+ explicit="${candidate}"
+ break
+ fi
+ done
+ if [[ -z "${explicit}" ]]; then
+ echo "dual baycom-pr.ini not found — copy stacks/baycom-pr/config/examples/baycom-pr.dual.ini" >&2
+ return 1
+ fi
+ fi
+ fi
+
+ local paths_py=""
+ if [[ -f "${ROOT}/stacks/daemon/paths.py" ]]; then
+ paths_py="${ROOT}/stacks/daemon/paths.py"
+ elif [[ -n "${PREFIX}" && -f "${PREFIX}/bin/paths.py" ]]; then
+ paths_py="${PREFIX}/bin/paths.py"
+ fi
+ if [[ -n "${paths_py}" ]]; then
+ local resolved=""
+ local prefix_py="None"
+ if [[ -n "${PREFIX}" ]]; then
+ prefix_py="Path('${PREFIX}')"
+ fi
+ resolved="$(python3 -c "
+import sys
+from pathlib import Path
+sys.path.insert(0, '$(dirname "${paths_py}")')
+from paths import resolve_baycom_ini
+p = resolve_baycom_ini('${device}', Path('${ROOT}'), ${prefix_py}, '''${explicit}''')
+print(p or '')
+" 2>/dev/null || true)"
+ if [[ -n "${resolved}" && -f "${resolved}" ]]; then
+ echo "${resolved}"
+ return 0
+ fi
+ fi
+
+ if [[ -n "${explicit}" ]]; then
+ return 1
+ fi
+
+ local candidate=""
+ for candidate in \
+ "/etc/baycom/baycom-pr.ini" \
+ "/etc/baycom/baycom-pr-single.ini" \
+ "${ROOT}/local/baycom-pr.ini" \
+ "${ROOT}/share/baycom/baycom-pr.pccom-ttyS0-only.ini.example" \
+ "${PREFIX}/share/max25/baycom/baycom-pr.pccom-ttyS0-only.ini.example" \
+ "${ROOT}/stacks/baycom-pr/config/baycom-pr.ini"; do
+ if [[ -f "${candidate}" ]]; then
+ if [[ "${candidate}" == "/etc/baycom/baycom-pr.ini" ]]; then
+ local dual=""
+ dual="$(python3 -c "
+from pathlib import Path
+import sys
+sys.path.insert(0, '${ROOT}/stacks/daemon')
+try:
+ from paths import is_dual_baycom_ini
+ print('dual' if is_dual_baycom_ini(Path('/etc/baycom/baycom-pr.ini')) else 'single')
+except ImportError:
+ print('single')
+" 2>/dev/null || echo "single")"
+ if [[ "${dual}" == "dual" ]]; then
+ continue
+ fi
+ fi
+ echo "${candidate}"
+ return 0
+ fi
+ done
+ return 1
+}
+
+start_baycom_kernel() {
+ # Kernel baycom-pr removed 2026-07-18 — product path is bcpr.
+ echo "BayCom/based uses max25-bcpr (not old kernel BayCom)." >&2
+ echo "Start PC-COM first: sudo max25-bcpr-ctl start --ini /etc/max25/max25-bcpr.ini" >&2
+ echo "Then max25d — see stacks/max25-bcpr/README.md" >&2
+ echo "Frozen archive: MASTER vault archives/legacy/max25-stack-baycom-kernel-compa/" >&2
+ exit 1
+}
+
+cmd_discover() {
+ if [[ ! -f "${ROOT}/scripts/discover-plugins.sh" ]]; then
+ echo "discover: source checkout required (set MAX25_ROOT)" >&2
+ exit 1
+ fi
+ bash "${ROOT}/scripts/discover-plugins.sh" "$@"
+}
+
+cmd_build() {
+ if [[ ! -f "${ROOT}/scripts/build.sh" ]]; then
+ echo "build: source checkout required (set MAX25_ROOT)" >&2
+ exit 1
+ fi
+ bash "${ROOT}/scripts/build.sh"
+}
+
+cmd_start() {
+ parse_opts "$@"
+ case "$HARDWARE" in
+ tncs)
+ case "$DEVICE" in
+ tnc2c|"")
+ local boot=""
+ boot="$(stack_script "tncs/tnc2c-boot-wait.sh")"
+ if [[ "${MAX25_TNC_PREP:-}" == "recover" ]]; then
+ echo "TNC recover-only (software ladder, no power cycle)"
+ exec "${boot}" --recover-only
+ fi
+ echo "TNC boot-wait rescue: power-cycle TNC while script runs if needed"
+ exec "${boot}"
+ ;;
+ pktnc2)
+ local boot=""
+ boot="$(stack_script "tncs/pktnc2-boot-wait.sh")"
+ if [[ "${MAX25_TNC_PREP:-}" == "recover" ]]; then
+ echo "PK-TNC2 recover-only (software ladder, no power cycle)"
+ exec "${boot}" --recover-only
+ fi
+ exec "${boot}"
+ ;;
+ *) echo "Unknown TNC device: $DEVICE" >&2; exit 1 ;;
+ esac
+ ;;
+ modems)
+ case "$DEVICE" in
+ baycom-kiss|pccom-kiss)
+ echo "baycom-kiss: USB/async KISS — no kernel stack start"
+ echo "Configure max25d [devices] baycom-kiss and [serial.baycom-kiss]; max25d opens serial directly."
+ exit 0
+ ;;
+ bcpr|max25e0|max25e0:bc0|max25e0:bc1|"")
+ echo "BayCom/based PC-COM: sudo max25-bcpr-ctl start -c /etc/max25/max25-bcpr.ini" >&2
+ echo "Then: ./scripts/run-max25d.sh — see stacks/max25-bcpr/README.md" >&2
+ echo "Canonical: scripts/run-max25d.sh" >&2
+ exit 1
+ ;;
+ baycom-par96|baycom-ser12|baycom-a|baycom-b)
+ start_baycom_kernel "${DEVICE}"
+ ;;
+ *)
+ echo "Unknown modem device: $DEVICE (use max25e0 / baycom-kiss)" >&2
+ exit 1
+ ;;
+ esac
+ ;;
+ soft-modems)
+ case "$DEVICE" in
+ soft-crdop|"")
+ local crdop_bin=""
+ crdop_bin="$(resolve_crdop_bin)" || {
+ if [[ -f "${ROOT}/scripts/build.sh" ]]; then
+ echo "Building CRDOP..."
+ bash "${ROOT}/scripts/build.sh"
+ crdop_bin="$(resolve_crdop_bin)" || true
+ fi
+ }
+ if [[ -z "${crdop_bin}" ]]; then
+ echo "crdopc not found in PATH or build/bin" >&2
+ exit 1
+ fi
+ local launcher=""
+ launcher="$(stack_script "crdop/scripts/crdopc" 2>/dev/null || true)"
+ if [[ -z "${launcher}" ]]; then
+ echo "Starting crdopc (HyBBX crdop plugin attaches to TCP :8515)"
+ exec env CRDOP_BIN="${crdop_bin}" crdopc
+ fi
+ echo "Starting crdopc (HyBBX crdop plugin attaches to TCP :8515)"
+ CRDOP_BIN="${crdop_bin}" exec "${launcher}"
+ ;;
+ *)
+ echo "Unknown soft-modem device: $DEVICE" >&2
+ exit 1
+ ;;
+ esac
+ ;;
+ "")
+ echo "Specify --hardware modems|soft-modems" >&2
+ exit 1
+ ;;
+ *)
+ echo "Unknown hardware: $HARDWARE" >&2
+ exit 1
+ ;;
+ esac
+}
+
+cmd_stop() {
+ parse_opts "$@"
+ if [[ "$HARDWARE" == "modems" || -z "$HARDWARE" ]]; then
+ case "$DEVICE" in
+ baycom-kiss)
+ echo "baycom-kiss: no baycom-pr-ctl stop (USB serial owned by max25d)"
+ ;;
+ *)
+ local baycom_ctl=""
+ baycom_ctl="$(resolve_baycom_ctl 2>/dev/null || true)"
+ if [[ -n "${baycom_ctl}" ]]; then
+ local ini=""
+ ini="$(resolve_baycom_ini_path "${DEVICE:-baycom-ser12}" "${BAYCOM_INI}" 2>/dev/null || true)"
+ if [[ -n "${ini}" ]]; then
+ sudo "${baycom_ctl}" -c "${ini}" stop 2>/dev/null || true
+ else
+ sudo "${baycom_ctl}" stop 2>/dev/null || true
+ fi
+ fi
+ ;;
+ esac
+ fi
+ echo "TNC serial: release port (stop other userspace serial owner; fuser on device)"
+}
+
+cmd_status() {
+ parse_opts "$@"
+ echo "MAX25 mode: $MODE"
+ if [[ -f "${ROOT}/scripts/discover-plugins.sh" ]]; then
+ bash "${ROOT}/scripts/discover-plugins.sh" --type device || true
+ else
+ echo "discover: source checkout required (set MAX25_ROOT)"
+ fi
+ local baycom_ctl=""
+ baycom_ctl="$(resolve_baycom_ctl 2>/dev/null || true)"
+ if [[ -n "${baycom_ctl}" && "${DEVICE:-}" != "baycom-kiss" ]]; then
+ local ini=""
+ ini="$(resolve_baycom_ini_path "${DEVICE:-baycom-ser12}" "${BAYCOM_INI}" 2>/dev/null || true)"
+ if [[ -n "${ini}" ]]; then
+ sudo "${baycom_ctl}" -c "${ini}" status 2>/dev/null || echo "baycom-pr: not running or no root"
+ else
+ sudo "${baycom_ctl}" status 2>/dev/null || echo "baycom-pr: not running or no root"
+ fi
+ fi
+}
+
+cmd_test() {
+ if [[ ! -d "${ROOT}/build" ]]; then
+ if [[ ! -f "${ROOT}/scripts/build.sh" ]]; then
+ echo "test: source checkout with build/ required (set MAX25_ROOT)" >&2
+ exit 1
+ fi
+ bash "${ROOT}/scripts/build.sh"
+ fi
+ cmake --build "${ROOT}/build" --target max25_test
+}
+
+case "$ACTION" in
+ help|-h|--help) usage ;;
+ discover) cmd_discover "$@" ;;
+ build) cmd_build ;;
+ start) cmd_start "$@" ;;
+ stop) cmd_stop "$@" ;;
+ status) cmd_status "$@" ;;
+ test) cmd_test ;;
+ *) echo "Unknown command: $ACTION" >&2; usage; exit 1 ;;
+esac
diff --git a/scripts/max25-lite-setup.sh b/scripts/max25-lite-setup.sh
new file mode 100755
index 0000000..412d83e
--- /dev/null
+++ b/scripts/max25-lite-setup.sh
@@ -0,0 +1,517 @@
+#!/usr/bin/env bash
+# MAX25-Stack-Lite — semi-automatic setup wizard (dialog / whiptail)
+# Detects serial + audio devices, writes max25d.ini (+ crdop.ini for CRDOP path).
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+UI_TITLE="MAX25-Stack-Lite setup"
+UI_BACKTITLE="MAX25-Stack-Lite | local packet-radio setup"
+UI_WIDTH=72
+UI_HEIGHT=18
+TMP_FILES=()
+
+UI=""
+if command -v whiptail >/dev/null 2>&1; then
+ UI=whiptail
+elif command -v dialog >/dev/null 2>&1; then
+ UI=dialog
+else
+ echo "max25-lite-setup: install whiptail or dialog, then run this command again." >&2
+ exit 1
+fi
+
+cleanup() {
+ local file
+ for file in "${TMP_FILES[@]}"; do
+ rm -f -- "$file"
+ done
+}
+trap cleanup EXIT HUP INT TERM
+
+require_terminal_size() {
+ local columns lines
+ [[ -t 1 && -t 2 ]] || return 0
+ columns="$(tput cols 2>/dev/null || true)"
+ lines="$(tput lines 2>/dev/null || true)"
+ if [[ "$columns" =~ ^[0-9]+$ && "$lines" =~ ^[0-9]+$ ]] \
+ && ((columns < UI_WIDTH || lines < UI_HEIGHT)); then
+ printf '%s\n' \
+ "max25-lite-setup: terminal is ${columns}x${lines}; need at least ${UI_WIDTH}x${UI_HEIGHT}." \
+ "Resize the terminal and run max25-lite-setup again." >&2
+ exit 1
+ fi
+}
+
+show_help() {
+ "$UI" --backtitle "$UI_BACKTITLE" --title "Setup help" --ok-label "Back" \
+ --msgbox "Use Up/Down to move, Space to mark a radio-list choice, and Enter to continue.
+
+Cancel or Esc stops the wizard without writing configuration files.
+
+The wizard writes local-only configuration. It does not start the modem, key a radio, or transmit.
+
+For serial and BayCom/based paths, select the device connected to this computer. For CRDOP, select the local capture/playback device." \
+ "$UI_HEIGHT" "$UI_WIDTH"
+}
+
+msgbox() {
+ "$UI" --backtitle "$UI_BACKTITLE" --title "$UI_TITLE" --ok-label "Continue" \
+ --msgbox "$1" 14 "$UI_WIDTH"
+}
+
+yesno() {
+ local prompt="$1" rc
+ while :; do
+ if "$UI" --backtitle "$UI_BACKTITLE" --title "$UI_TITLE" \
+ --yes-label "Yes" --no-label "No" --help-button --help-label "Help" \
+ --yesno "$prompt" 10 "$UI_WIDTH"; then
+ return 0
+ else
+ rc=$?
+ fi
+ if [[ "$rc" -eq 2 ]]; then
+ show_help
+ continue
+ fi
+ return "$rc"
+ done
+}
+
+inputbox() {
+ local prompt="$1" default="${2:-}" value rc
+ while :; do
+ if value=$("$UI" --backtitle "$UI_BACKTITLE" --title "$UI_TITLE" \
+ --ok-label "Continue" --cancel-label "Cancel" --help-button --help-label "Help" \
+ --inputbox "$prompt" 10 "$UI_WIDTH" "$default" 3>&1 1>&2 2>&3); then
+ printf '%s\n' "$value"
+ return 0
+ else
+ rc=$?
+ fi
+ if [[ "$rc" -eq 2 ]]; then
+ show_help
+ continue
+ fi
+ return "$rc"
+ done
+}
+
+menu() {
+ local prompt="$1" value rc
+ shift
+ while :; do
+ if value=$("$UI" --backtitle "$UI_BACKTITLE" --title "$UI_TITLE" \
+ --ok-label "Continue" --cancel-label "Cancel" --help-button --help-label "Help" \
+ --menu "$prompt" "$UI_HEIGHT" "$UI_WIDTH" 10 "$@" 3>&1 1>&2 2>&3); then
+ printf '%s\n' "$value"
+ return 0
+ else
+ rc=$?
+ fi
+ if [[ "$rc" -eq 2 ]]; then
+ show_help
+ continue
+ fi
+ return "$rc"
+ done
+}
+
+radiolist() {
+ local prompt="$1" value rc
+ shift
+ while :; do
+ if value=$("$UI" --backtitle "$UI_BACKTITLE" --title "$UI_TITLE" \
+ --ok-label "Continue" --cancel-label "Cancel" --help-button --help-label "Help" \
+ --radiolist "$prompt" "$UI_HEIGHT" "$UI_WIDTH" 10 "$@" 3>&1 1>&2 2>&3); then
+ printf '%s\n' "$value"
+ return 0
+ else
+ rc=$?
+ fi
+ if [[ "$rc" -eq 2 ]]; then
+ show_help
+ continue
+ fi
+ return "$rc"
+ done
+}
+
+require_terminal_size
+
+list_serial_ports() {
+ local -a ports=()
+ shopt -s nullglob
+ for pat in /dev/ttyUSB* /dev/ttyACM* /dev/ttyS*; do
+ [[ -e "$pat" ]] || continue
+ ports+=("$pat")
+ done
+ shopt -u nullglob
+ if ((${#ports[@]} == 0)); then
+ ports=("/dev/ttyUSB0")
+ fi
+ printf '%s\n' "${ports[@]}"
+}
+
+list_alsa_cards() {
+ if command -v arecord >/dev/null 2>&1; then
+ arecord -l 2>/dev/null | awk '/card [0-9]+:/ {c=$2; gsub(":", "", c); desc=$0; gsub(/^.*card [0-9]+: /, "", desc); gsub(/,.*/, "", desc); printf "hw:%s,0 %s\n", c, desc}' || true
+ fi
+ if command -v aplay >/dev/null 2>&1; then
+ aplay -l 2>/dev/null | awk '/card [0-9]+:/ {c=$2; gsub(":", "", c); desc=$0; gsub(/^.*card [0-9]+: /, "", desc); gsub(/,.*/, "", desc); printf "hw:%s,0 %s (playback)\n", c, desc}' || true
+ fi
+}
+
+msgbox "Welcome to MAX25-Stack-Lite setup.
+
+This wizard detects serial and audio devices on this computer, writes max25d.ini, and shows how to start max25d and max25-terminal locally.
+
+It changes files only after you choose a save location. Use Help for key hints, or Cancel to leave without saving."
+
+MODEM_KIND="$(menu "Packet radio modem path:" \
+ serial "Serial TNC / USB KISS (T-Modem, TNC2)" \
+ baycom "BayCom/based PC-COM (SER12 on COM/ttyS)" \
+ crdop "CRDOP sound-card soft modem (MAX25-SoftModem)" \
+ )" || exit 0
+
+DEVICE_ID="tnc2c"
+HARDWARE="tncs"
+SERIAL_PATH=""
+CRDOP_CAPTURE=""
+CRDOP_PLAYBACK=""
+WRITE_CRDOP_INI=0
+WRITE_BCPR_INI=0
+BCPR_INI_DEST=""
+
+if [[ "$MODEM_KIND" == "baycom" ]]; then
+ DEVICE_ID="max25e0"
+ HARDWARE="tncs"
+ WRITE_BCPR_INI=1
+ mapfile -t PORTS < <(list_serial_ports)
+ menu_args=()
+ idx=0
+ for p in "${PORTS[@]}"; do
+ menu_args+=("$p" "serial port" "$((idx == 0 ? 1 : 0))")
+ idx=$((idx + 1))
+ done
+ SERIAL_PATH="$(radiolist "Select PC-COM serial port (often /dev/ttyS0):" "${menu_args[@]}")" || exit 0
+elif [[ "$MODEM_KIND" == "serial" ]]; then
+ mapfile -t PORTS < <(list_serial_ports)
+ menu_args=()
+ idx=0
+ for p in "${PORTS[@]}"; do
+ menu_args+=("$p" "serial port" "$((idx == 0 ? 1 : 0))")
+ idx=$((idx + 1))
+ done
+ SERIAL_PATH="$(radiolist "Select serial device:" "${menu_args[@]}")" || exit 0
+ if [[ "$SERIAL_PATH" == /dev/ttyACM* ]]; then
+ if yesno "Device looks like USB KISS (T-Modem). Use device id tmodem instead of tnc2c?"; then
+ DEVICE_ID="tmodem"
+ fi
+ fi
+else
+ HARDWARE="soft-modems"
+ DEVICE_ID="soft-crdop"
+ WRITE_CRDOP_INI=1
+ mapfile -t ALSA_LINES < <(list_alsa_cards)
+ if ((${#ALSA_LINES[@]} == 0)); then
+ msgbox "No ALSA cards listed (arecord/aplay). You can edit ~/.config/crdop/crdop.ini later."
+ CRDOP_CAPTURE="hw:0,0"
+ CRDOP_PLAYBACK="hw:0,0"
+ else
+ menu_args=()
+ idx=0
+ for line in "${ALSA_LINES[@]}"; do
+ dev="${line%% *}"
+ label="${line#* }"
+ menu_args+=("$dev" "$label" "$((idx == 0 ? 1 : 0))")
+ idx=$((idx + 1))
+ done
+ sel="$(radiolist "Select capture/playback ALSA device (hw:N,0):" "${menu_args[@]}")" || exit 0
+ CRDOP_CAPTURE="$sel"
+ CRDOP_PLAYBACK="$sel"
+ fi
+fi
+
+CALLERID="$(inputbox "Your AX.25 caller ID (e.g. CB-0):" "CB-0")" || exit 0
+CALLID="$(inputbox "Default destination callsign for CONNECT tests:" "QST")" || exit 0
+
+if yesno "Write system config /etc/max25/max25d.ini? (No = ./local/max25d.ini in repo)"; then
+ INI_DEST="/etc/max25/max25d.ini"
+ CRDOP_INI_DEST="${HOME}/.config/crdop/crdop.ini"
+ BCPR_INI_DEST="/etc/max25/max25-bcpr.ini"
+ NEED_SUDO=1
+else
+ INI_DEST="${ROOT}/local/max25d.ini"
+ CRDOP_INI_DEST="${ROOT}/local/crdop.ini"
+ BCPR_INI_DEST="${ROOT}/local/max25-bcpr.ini"
+ NEED_SUDO=0
+fi
+
+if [[ "$NEED_SUDO" -eq 1 ]]; then
+ if ! command -v sudo >/dev/null 2>&1; then
+ msgbox "System configuration needs sudo, but sudo is not available.
+
+Choose No at the save-location screen to write local configuration in this repository."
+ exit 1
+ fi
+ sudo mkdir -p "$(dirname "$INI_DEST")"
+ mkdir -p "$(dirname "$CRDOP_INI_DEST")"
+else
+ mkdir -p "$(dirname "$INI_DEST")"
+fi
+
+write_max25d_ini() {
+ local dest="$1"
+ if [[ "$MODEM_KIND" == "baycom" ]]; then
+ cat >"$dest" <<EOF
+; MAX25-Stack-Lite — generated by max25-lite-setup.sh (BayCom/based)
+
+[features]
+max25_bcpr = yes
+
+[daemon]
+mode = standalone
+hardware = tncs
+device = max25e0
+
+[network]
+tcp_host = 127.0.0.1
+tcp_port = 7325
+unix_socket = /run/max25/modem.sock
+tcp_password =
+
+[modem]
+callerid = ${CALLERID}
+callid = ${CALLID}
+ax25_ui = yes
+
+[reporting]
+error_transmissions = yes
+voice_transmissions = yes
+data_passes = 3
+data_quality_min = 50
+data_pass_seconds = 20
+
+[stack]
+auto_start = yes
+
+[devices]
+default = max25e0
+max25e0 = max25-bcpr:bc0
+
+[device.max25e0]
+kiss_link = /tmp/max25-bcpr/kiss-bc0
+max25_bcpr_ini = ${BCPR_INI_DEST}
+EOF
+ elif [[ "$MODEM_KIND" == "serial" ]]; then
+ cat >"$dest" <<EOF
+; MAX25-Stack-Lite — generated by max25-lite-setup.sh
+
+[daemon]
+mode = standalone
+hardware = ${HARDWARE}
+device = ${DEVICE_ID}
+
+[network]
+tcp_host = 127.0.0.1
+tcp_port = 7325
+unix_socket = /run/max25/modem.sock
+; Lite: localhost only — terminal on same host as max25d
+tcp_password =
+
+[modem]
+callerid = ${CALLERID}
+callid = ${CALLID}
+ax25_ui = yes
+
+[reporting]
+error_transmissions = yes
+voice_transmissions = yes
+data_passes = 3
+data_quality_min = 50
+data_pass_seconds = 20
+
+[stack]
+auto_start = yes
+serial_watch = yes
+serial_watch_interval = 60
+stack_recover_only = yes
+serial_bootwait_escalate = yes
+serial_bootwait_escalate_after = 3
+
+[devices]
+default = ${DEVICE_ID}
+${DEVICE_ID} = ${SERIAL_PATH}
+
+[serial.${DEVICE_ID}]
+baud = 19200
+line = 8n1
+dtr_rts = yes
+kiss_entry = kiss_on
+EOF
+ else
+ cat >"$dest" <<EOF
+; MAX25-Stack-Lite — generated by max25-lite-setup.sh (CRDOP)
+
+[daemon]
+mode = standalone
+hardware = soft-modems
+device = soft-crdop
+
+[network]
+tcp_host = 127.0.0.1
+tcp_port = 7325
+unix_socket = /run/max25/modem.sock
+; Lite: localhost only — terminal on same host as max25d
+tcp_password =
+
+[modem]
+callerid = ${CALLERID}
+callid = ${CALLID}
+ax25_ui = yes
+
+[reporting]
+error_transmissions = yes
+voice_transmissions = yes
+data_passes = 3
+data_quality_min = 50
+data_pass_seconds = 20
+
+[stack]
+auto_start = yes
+
+[devices]
+default = soft-crdop
+soft-crdop = crdop:default
+
+[device.soft-crdop]
+host = 127.0.0.1
+port = 8515
+listen = yes
+EOF
+ fi
+}
+
+write_crdop_ini() {
+ local dest="$1"
+ mkdir -p "$(dirname "$dest")"
+ cat >"$dest" <<EOF
+; MAX25-SoftModem (CRDOP) — generated by max25-lite-setup.sh
+
+[profile]
+radio_profile = cb
+
+[modem]
+speed_baud = 1200
+duplex = half
+max_speed_baud = 19200
+
+[host]
+port = 8515
+
+[mycall]
+call = ${CALLERID}
+
+[audio]
+backend = alsa-kernel
+no_pulse = yes
+capture = ${CRDOP_CAPTURE}
+playback = ${CRDOP_PLAYBACK}
+
+[coupling]
+mode = line
+EOF
+}
+
+write_bcpr_ini() {
+ local dest="$1"
+ mkdir -p "$(dirname "$dest")"
+ cat >"$dest" <<EOF
+; max25-bcpr — BayCom/based PC-COM (generated by max25-lite-setup.sh)
+
+[max25-bcpr]
+dry_run = no
+state_dir = /tmp/max25-bcpr
+user = max25
+group = max25
+
+[bc0]
+enabled = yes
+serial = ${SERIAL_PATH}
+iobase = 0x3f8
+irq = 4
+mode = ser12*
+kiss_link = /tmp/max25-bcpr/kiss-bc0
+baud = 1200
+tx_delay = 35
+tx_tail = 2
+slottime = 10
+ppersist = 40
+fulldup = no
+EOF
+}
+
+if [[ "$NEED_SUDO" -eq 1 ]]; then
+ tmp="$(mktemp)"
+ TMP_FILES+=("$tmp")
+ write_max25d_ini "$tmp"
+ sudo cp "$tmp" "$INI_DEST"
+ if [[ "$WRITE_CRDOP_INI" -eq 1 ]]; then
+ tmp="$(mktemp)"
+ TMP_FILES+=("$tmp")
+ write_crdop_ini "$tmp"
+ cp "$tmp" "$CRDOP_INI_DEST"
+ fi
+ if [[ "$WRITE_BCPR_INI" -eq 1 ]]; then
+ tmp="$(mktemp)"
+ TMP_FILES+=("$tmp")
+ write_bcpr_ini "$tmp"
+ sudo cp "$tmp" "$BCPR_INI_DEST"
+ fi
+else
+ mkdir -p "${ROOT}/local"
+ write_max25d_ini "$INI_DEST"
+ if [[ "$WRITE_CRDOP_INI" -eq 1 ]]; then
+ write_crdop_ini "$CRDOP_INI_DEST"
+ fi
+ if [[ "$WRITE_BCPR_INI" -eq 1 ]]; then
+ write_bcpr_ini "$BCPR_INI_DEST"
+ fi
+fi
+
+NEXT_STEPS="Configuration written:
+
+ max25d.ini → ${INI_DEST}"
+if [[ "$WRITE_CRDOP_INI" -eq 1 ]]; then
+ NEXT_STEPS="${NEXT_STEPS}
+ crdop.ini → ${CRDOP_INI_DEST}"
+fi
+if [[ "$WRITE_BCPR_INI" -eq 1 ]]; then
+ NEXT_STEPS="${NEXT_STEPS}
+ max25-bcpr.ini → ${BCPR_INI_DEST}"
+fi
+NEXT_STEPS="${NEXT_STEPS}
+
+Build (if not done):
+ cmake -B build && cmake --build build
+ sudo cmake --install build
+
+BayCom/based only — start bcpr first (needs root on Linux):
+ sudo max25-bcpr-ctl start --ini ${BCPR_INI_DEST}
+
+Start daemon:
+ ${ROOT}/scripts/run-max25d.sh ${INI_DEST}
+
+CRDOP only — start soft modem (separate terminal):
+ max25-ctl start --hardware soft-modems --device soft-crdop
+ (or: crdop -c ${CRDOP_INI_DEST})
+
+Terminal + CONNECT:
+ ${ROOT}/scripts/run-max25-terminal.sh -U /run/max25/modem.sock
+ At prompt: CONNECT ${CALLID}
+ Or F10 menu → Connect
+
+RX before TX: confirm receive/noise/SQ before live transmit."
+
+"$UI" --backtitle "$UI_BACKTITLE" --title "Setup complete" --ok-label "Done" \
+ --msgbox "$NEXT_STEPS" "$UI_HEIGHT" "$UI_WIDTH"
diff --git a/scripts/max25-terminal b/scripts/max25-terminal
new file mode 100755
index 0000000..71a689e
--- /dev/null
+++ b/scripts/max25-terminal
@@ -0,0 +1,3 @@
+#!/usr/bin/env bash
+# Alias → canonical launcher (stale stacks/terminal ELF avoided).
+exec "$(cd "$(dirname "$0")" && pwd)/run-max25-terminal.sh" "$@"
diff --git a/scripts/max25d-session.sh b/scripts/max25d-session.sh
new file mode 100755
index 0000000..701d23e
--- /dev/null
+++ b/scripts/max25d-session.sh
@@ -0,0 +1,160 @@
+#!/usr/bin/env bash
+# max25d-session — run max25d inside tmux or GNU screen (operator attach/detach).
+#
+# max25d-session start [--tmux|--screen] [-c /etc/max25/max25d.ini]
+# max25d-session attach [--tmux|--screen]
+# max25d-session status
+# max25d-session stop
+#
+# Environment overrides:
+# MAX25D_SESSION session name (default: max25d)
+# MAX25D_SESSION_BACKEND tmux | screen (default: tmux)
+# MAX25D_INI config path
+set -euo pipefail
+
+SESSION_NAME="${MAX25D_SESSION:-max25d}"
+BACKEND="${MAX25D_SESSION_BACKEND:-tmux}"
+CONFIG="${MAX25D_INI:-/etc/max25/max25d.ini}"
+MAX25D_BIN="${MAX25D_BIN:-max25d}"
+
+usage() {
+ cat <<'EOF'
+max25d-session — persistent max25d in tmux or screen
+
+Commands:
+ start [--tmux|--screen] [-c ini] start detached session
+ attach [--tmux|--screen] attach to running session
+ status show session + max25d hint
+ stop kill session
+
+Examples:
+ max25d-session start -c /etc/max25/max25d.ini
+ max25d-session attach
+ MAX25D_SESSION_BACKEND=screen max25d-session start
+
+EOF
+}
+
+have_tmux() { command -v tmux >/dev/null 2>&1; }
+have_screen() { command -v screen >/dev/null 2>&1; }
+
+pick_backend() {
+ case "${1:-$BACKEND}" in
+ tmux)
+ if ! have_tmux; then
+ echo "max25d-session: tmux not installed" >&2
+ exit 1
+ fi
+ echo tmux
+ ;;
+ screen)
+ if ! have_screen; then
+ echo "max25d-session: screen not installed" >&2
+ exit 1
+ fi
+ echo screen
+ ;;
+ *)
+ echo "max25d-session: unknown backend '$1' (use tmux or screen)" >&2
+ exit 1
+ ;;
+ esac
+}
+
+session_running() {
+ local b="$1"
+ case "$b" in
+ tmux) tmux has-session -t "$SESSION_NAME" 2>/dev/null ;;
+ screen) screen -ls | grep -q "[[:space:]]*[0-9]*\.${SESSION_NAME}[[:space:]]" ;;
+ esac
+}
+
+cmd_start() {
+ local b
+ b="$(pick_backend "$BACKEND")"
+ if session_running "$b"; then
+ echo "max25d-session: session '$SESSION_NAME' already running ($b)" >&2
+ echo " attach: max25d-session attach --$b" >&2
+ exit 1
+ fi
+ local cmd="${MAX25D_BIN} -c ${CONFIG}"
+ case "$b" in
+ tmux)
+ tmux new-session -d -s "$SESSION_NAME" \
+ "${MAX25D_BIN} -c ${CONFIG}; echo; echo max25d exited \$?; read -r _"
+ ;;
+ screen)
+ screen -dmS "$SESSION_NAME" bash -lc "${cmd}"
+ ;;
+ esac
+ echo "max25d-session: started ($b) name=$SESSION_NAME config=$CONFIG"
+ echo "max25d-session: attach with: max25d-session attach --$b"
+}
+
+cmd_attach() {
+ local b
+ b="$(pick_backend "$BACKEND")"
+ if ! session_running "$b"; then
+ echo "max25d-session: no session '$SESSION_NAME' ($b)" >&2
+ exit 1
+ fi
+ case "$b" in
+ tmux) exec tmux attach -t "$SESSION_NAME" ;;
+ screen) exec screen -r "$SESSION_NAME" ;;
+ esac
+}
+
+cmd_status() {
+ local b
+ b="$(pick_backend "$BACKEND")"
+ if session_running "$b"; then
+ echo "max25d-session: running backend=$b name=$SESSION_NAME"
+ case "$b" in
+ tmux) tmux list-panes -t "$SESSION_NAME" -F 'pane #{pane_index}: #{pane_current_command}' 2>/dev/null || true ;;
+ screen) screen -ls | grep "${SESSION_NAME}" || true ;;
+ esac
+ else
+ echo "max25d-session: not running (backend=$b name=$SESSION_NAME)"
+ fi
+ if command -v pgrep >/dev/null 2>&1 && pgrep -x max25d >/dev/null 2>&1; then
+ echo "max25d-session: max25d process: $(pgrep -xa max25d || true)"
+ fi
+}
+
+cmd_stop() {
+ local b
+ b="$(pick_backend "$BACKEND")"
+ if ! session_running "$b"; then
+ echo "max25d-session: not running" >&2
+ exit 0
+ fi
+ case "$b" in
+ tmux) tmux kill-session -t "$SESSION_NAME" ;;
+ screen) screen -S "$SESSION_NAME" -X quit ;;
+ esac
+ echo "max25d-session: stopped ($b) name=$SESSION_NAME"
+}
+
+CMD="${1:-}"
+shift || true
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --tmux) BACKEND=tmux; shift ;;
+ --screen) BACKEND=screen; shift ;;
+ -c|--config)
+ CONFIG="${2:?config path required}"
+ shift 2
+ ;;
+ -h|--help) usage; exit 0 ;;
+ *) echo "max25d-session: unknown option: $1" >&2; usage >&2; exit 1 ;;
+ esac
+done
+
+case "$CMD" in
+ start) cmd_start ;;
+ attach) cmd_attach ;;
+ status) cmd_status ;;
+ stop) cmd_stop ;;
+ ""|-h|--help) usage ;;
+ *) echo "max25d-session: unknown command: $CMD" >&2; usage >&2; exit 1 ;;
+esac
diff --git a/scripts/release-check.sh b/scripts/release-check.sh
new file mode 100755
index 0000000..223c6b6
--- /dev/null
+++ b/scripts/release-check.sh
@@ -0,0 +1,258 @@
+#!/usr/bin/env bash
+# release-check.sh — MAX25-Stack-Lite offline release gates (no root, no live RF TX).
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$ROOT"
+
+PASS=0
+FAIL=0
+WARN=0
+
+ok() { echo "OK: $*"; PASS=$((PASS + 1)); }
+fail() { echo "FAIL: $*" >&2; FAIL=$((FAIL + 1)); }
+warn() { echo "WARN: $*"; WARN=$((WARN + 1)); }
+
+echo "======== MAX25-Stack-Lite release-check ($(tr -d '[:space:]' < VERSION 2>/dev/null || echo '?')) ========"
+
+# --- version ---
+if [[ -f VERSION ]]; then
+ VER="$(tr -d '[:space:]' < VERSION)"
+ if [[ "$VER" == "1.8.0" ]]; then
+ ok "VERSION=${VER}"
+ elif [[ -n "$VER" ]]; then
+ fail "VERSION=${VER} (expected 1.8.0)"
+ else
+ fail "VERSION file empty"
+ fi
+else
+ fail "VERSION file missing"
+fi
+
+# --- required docs (Lite ships a stub docs/, depth lives in the research vault) ---
+for doc in README.md CONTRIBUTING.md CHANGELOG.md docs/README.md local/README.md stacks/daemon/README.md stacks/max25-bcpr/README.md include/max25/protocol.md include/max25/packet-radio.md; do
+ [[ -f "$doc" ]] && ok "doc $doc" || fail "missing $doc"
+done
+
+# --- scripts present + executable (Lite scope only) ---
+for script in scripts/max25-lite-setup.sh scripts/test-wizard.sh scripts/run-max25d.sh scripts/run-max25-terminal.sh scripts/max25-ctl scripts/discover-plugins.sh scripts/build.sh scripts/build-all.sh scripts/clean.sh scripts/test.sh scripts/release-check.sh scripts/tx-rx-test.sh scripts/terminology-check.sh scripts/install-max25.sh; do
+ if [[ -x "$script" ]]; then
+ ok "executable $script"
+ else
+ fail "not executable: $script"
+ fi
+done
+
+# --- public terminology gate (§0.16 / §0.19) ---
+if [[ -x scripts/terminology-check.sh ]]; then
+ if scripts/terminology-check.sh; then
+ ok "terminology-check"
+ else
+ fail "terminology-check"
+ fi
+else
+ fail "scripts/terminology-check.sh missing or not executable"
+fi
+
+# --- daemon core files ---
+if [[ -x stacks/daemon/max25d ]] && [[ -f stacks/daemon/max25d.py ]]; then
+ ok "executable stacks/daemon/max25d launcher + max25d.py"
+else
+ fail "stacks/daemon/max25d launcher or max25d.py missing"
+fi
+for f in kiss_bridge.py ax25_codec.py privilege_drop.py; do
+ [[ -f "stacks/daemon/${f}" ]] && ok "stacks/daemon/${f}" || fail "missing stacks/daemon/${f}"
+done
+if python3 stacks/daemon/test_ax25_codec.py >/dev/null 2>&1; then
+ ok "ax25_codec unit tests"
+else
+ fail "ax25_codec unit tests"
+fi
+
+# --- Lite INI examples (localhost-only model) ---
+for ini in share/max25/max25d.ini.example share/max25/max25d.lite.ini.example share/max25/max25d.lite.bcpr.ini.example share/max25/max25d.lite.crdop.ini.example share/max25-bcpr/max25-bcpr.ini.example stacks/max25-bcpr/share/max25-bcpr.ini.example stacks/crdop/share/crdop.ini.example; do
+ [[ -f "$ini" ]] && ok "ini $ini" || fail "missing $ini"
+done
+
+# --- localhost TCP in shipped max25d examples ---
+for ini in share/max25/max25d.ini.example share/max25/max25d.lite.ini.example share/max25/max25d.lite.bcpr.ini.example share/max25/max25d.lite.crdop.ini.example; do
+ if grep -Eq '^[[:space:]]*tcp_host[[:space:]]*=[[:space:]]*0\.0\.0\.0[[:space:]]*$' "$ini"; then
+ fail "ini $ini has tcp_host=0.0.0.0 (Lite localhost-only)"
+ elif grep -Eq '^[[:space:]]*tcp_host[[:space:]]*=[[:space:]]*127\.0\.0\.1[[:space:]]*$' "$ini"; then
+ ok "ini $ini tcp_host=127.0.0.1"
+ else
+ fail "ini $ini missing tcp_host=127.0.0.1"
+ fi
+done
+
+if [[ -f share/clients/index.yaml ]] && [[ -f share/clients/tnc2c.yaml ]]; then
+ ok "share/clients registry"
+else
+ warn "share/clients/ (index.yaml / tnc2c.yaml) missing (informational only in Lite)"
+fi
+
+# --- out-of-scope surfaces must stay inert (HyBBX co-host, hardware TNC stacks, plugin tree) ---
+if [[ -d plugins ]]; then
+ fail "plugins/ present — Lite configures devices directly in max25d.ini, no plugin tree expected"
+else
+ ok "no plugins/ tree (Lite: direct INI device config)"
+fi
+if [[ -d stacks/tncs ]]; then
+ fail "stacks/tncs/ present — TNC2C/pktnc2 hardware stack is out of scope for Lite"
+else
+ ok "no stacks/tncs/ (hardware TNC stack out of scope for Lite)"
+fi
+if grep -rq 'hybbx' CMakeLists.txt 2>/dev/null; then
+ fail "CMakeLists.txt references hybbx — HyBBX co-host build hooks are out of scope for Lite"
+else
+ ok "CMakeLists.txt free of hybbx references"
+fi
+if [[ -f share/hybbx/README.md ]]; then
+ ok "share/hybbx/README.md scope note present (kept for upstream sync, not a Lite path)"
+else
+ warn "share/hybbx/ present without a scope note (see design-principles.md sync policy)"
+fi
+
+# --- build artifacts (CMake, Lite target set) ---
+BUILD_DIR="build"
+if ! cmake -B "${BUILD_DIR}" -DCMAKE_BUILD_TYPE=Release >/dev/null 2>&1; then
+ BUILD_DIR="build-default"
+ cmake -B "${BUILD_DIR}" -DCMAKE_BUILD_TYPE=Release >/dev/null 2>&1 \
+ || fail "cmake configure"
+fi
+if cmake --build "${BUILD_DIR}" -j"$(nproc 2>/dev/null || echo 2)" >/dev/null 2>&1; then
+ ok "cmake build (${BUILD_DIR}/bin/)"
+else
+ fail "cmake build"
+fi
+[[ -x "${BUILD_DIR}/bin/max25-terminal" ]] && ok "max25-terminal built" || fail "max25-terminal build"
+if [[ -x "${BUILD_DIR}/bin/max25-bcprd" ]] && [[ -x "${BUILD_DIR}/bin/max25-bcprd-init" ]]; then
+ ok "max25-bcprd + max25-bcprd-init built (default ON in Lite)"
+else
+ fail "max25-bcprd / max25-bcprd-init not built (expected default ON — see cmake/MAX25Platform.cmake)"
+fi
+[[ -f stacks/crdop/share/crdop.ini.example ]] && ok "CRDOP scaffold" || fail "CRDOP scaffold missing"
+if [[ -x "${BUILD_DIR}/bin/audio-dummyd" ]]; then
+ ok "audio-dummyd binary"
+else
+ ok "audio-dummyd not in build dir (launcher uses source tree path)"
+fi
+
+# --- install tree hygiene: no vendor/ARDOP artifacts, no out-of-scope leakage ---
+INSTALL_DIR="${BUILD_DIR}/install-release-check"
+rm -rf "${INSTALL_DIR}"
+if cmake --install "${BUILD_DIR}" --prefix "${INSTALL_DIR}" >/dev/null 2>&1; then
+ ok "cmake --install prefix"
+ if find "${INSTALL_DIR}" \( -path '*/vendor/ardopcf/*' -o -name 'ardopcf' -o -name 'crdopc' \) 2>/dev/null | grep -q .; then
+ fail "ARDOP vendor artifacts in install tree (forbidden in release)"
+ else
+ ok "install tree free of ARDOP vendor binaries/sources"
+ fi
+ if find "${INSTALL_DIR}" -type d -iname 'hybbx' 2>/dev/null | grep -q .; then
+ fail "hybbx/ present in install tree (out of scope for Lite)"
+ else
+ ok "install tree free of hybbx/ directories"
+ fi
+else
+ warn "cmake --install skipped (non-fatal for dev trees)"
+fi
+
+# --- offline tests ---
+if [[ -x "${BUILD_DIR}/bin/max25-bcprd" ]]; then
+ if "${BUILD_DIR}/bin/max25-bcprd" -c stacks/max25-bcpr/share/max25-bcpr.ini.example --dry-run --once >/dev/null 2>&1; then
+ ok "max25-bcpr dry-run once"
+ else
+ fail "max25-bcpr dry-run once"
+ fi
+else
+ warn "max25-bcpr dry-run skipped (not built)"
+fi
+
+if python3 stacks/daemon/test_bcpr_backend.py >/dev/null 2>&1; then
+ ok "bcpr backend unit tests"
+else
+ fail "bcpr backend unit tests"
+fi
+
+if python3 stacks/daemon/test_crdop_backend.py >/dev/null 2>&1; then
+ ok "crdop backend unit tests"
+else
+ fail "crdop backend unit tests"
+fi
+
+if python3 stacks/daemon/test_paths.py >/dev/null 2>&1; then
+ ok "daemon path layout unit tests"
+else
+ fail "daemon path layout unit tests"
+fi
+
+if bash scripts/test-wizard.sh >/dev/null 2>&1; then
+ ok "wizard non-interactive smoke (serial/baycom/crdop)"
+else
+ fail "wizard non-interactive smoke"
+fi
+
+if bash scripts/discover-plugins.sh --type device >/dev/null 2>&1; then
+ ok "discover-plugins.sh --type device"
+else
+ warn "discover-plugins.sh --type device (no manifest — expected in Lite, see script output)"
+fi
+
+chmod +x stacks/daemon/max25d stacks/daemon/max25d.py 2>/dev/null || true
+[[ -x stacks/daemon/max25d ]] && [[ -f stacks/daemon/max25d.py ]] && ok "max25d ready" || fail "max25d"
+if [[ -L "${BUILD_DIR}/bin/max25-client" ]] || [[ -x "${BUILD_DIR}/bin/max25-client" ]]; then
+ ok "max25-client symlink"
+else
+ warn "max25-client symlink missing until cmake --install"
+fi
+if MAX25_TERMINAL="${PWD}/${BUILD_DIR}/bin/max25-terminal" \
+ bash stacks/terminal/test-terminal.sh >/dev/null 2>&1; then
+ ok "max25-terminal TCP probe"
+else
+ fail "max25-terminal TCP probe"
+fi
+
+if python3 stacks/daemon/test_proto.py >/dev/null 2>&1; then
+ ok "max25d protocol smoke"
+else
+ fail "max25d protocol smoke"
+fi
+
+if python3 stacks/daemon/test_auth.py >/dev/null 2>&1; then
+ ok "max25d TCP auth smoke"
+else
+ fail "max25d TCP auth smoke"
+fi
+
+# --- RX before TX gate (§0.20) — offline check only, no live RF TX here ---
+if bash scripts/tx-rx-test.sh >/dev/null 2>&1; then
+ ok "tx-rx-test L0 offline (RX before TX posture)"
+else
+ fail "tx-rx-test L0 offline"
+fi
+
+if python3 stacks/daemon/test_serial_watch.py >/dev/null 2>&1; then
+ ok "max25d serial watch tests"
+else
+ fail "max25d serial watch tests"
+fi
+
+# --- AmigaOS terminal (optional cross-build) ---
+if [[ -x /opt/amiga/bin/m68k-amigaos-gcc ]]; then
+ if bash scripts/build-amiga-terminal.sh >/dev/null 2>&1; then
+ ok "max25-terminal AmigaOS cross-build"
+ else
+ fail "max25-terminal AmigaOS cross-build"
+ fi
+else
+ warn "Amiga SDK not at /opt/amiga; skip Amiga terminal build (optional)"
+fi
+
+# --- CI workflow ---
+[[ -f .github/workflows/ci.yml ]] && ok "ci workflow" || warn "missing .github/workflows/ci.yml"
+
+echo "======== release-check: ${PASS} passed, ${FAIL} failed, ${WARN} warnings ========"
+if [[ "$FAIL" -gt 0 ]]; then
+ exit 1
+fi
+exit 0
diff --git a/scripts/run-max25-terminal.sh b/scripts/run-max25-terminal.sh
new file mode 100755
index 0000000..21f45da
--- /dev/null
+++ b/scripts/run-max25-terminal.sh
@@ -0,0 +1,62 @@
+#!/usr/bin/env bash
+# Canonical max25-terminal launcher — tree or PREFIX/bin.
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
+
+pick() {
+ local c
+ # Prefer installed binary beside this script
+ if [[ -x "${SCRIPT_DIR}/max25-terminal" && ! -d "${SCRIPT_DIR}/../stacks/terminal" ]]; then
+ c="${SCRIPT_DIR}/max25-terminal"
+ if "$c" -h 2>&1 | grep -q -- '-d, --device'; then
+ echo "$c"
+ return 0
+ fi
+ fi
+ local TREE=""
+ if [[ -d "${SCRIPT_DIR}/../stacks/terminal" ]]; then
+ TREE="$(cd "${SCRIPT_DIR}/.." && pwd)"
+ fi
+ for c in \
+ ${TREE:+"${TREE}/build/bin/max25-terminal"} \
+ ${TREE:+"${TREE}/build-max25-bcpr/bin/max25-terminal"} \
+ "${ROOT}/build-max25-bcpr/bin/max25-terminal" \
+ "${ROOT}/build/bin/max25-terminal" \
+ /usr/local/bin/max25-terminal
+ do
+ [[ -n "$c" && -x "$c" ]] || continue
+ if "$c" -h 2>&1 | grep -q -- '-d, --device'; then
+ echo "$c"
+ return 0
+ fi
+ done
+ if command -v max25-terminal >/dev/null 2>&1; then
+ c="$(command -v max25-terminal)"
+ if "$c" -h 2>&1 | grep -q -- '-d, --device'; then
+ echo "$c"
+ return 0
+ fi
+ fi
+ return 1
+}
+
+BIN="$(pick)" || {
+ echo "run-max25-terminal: no current max25-terminal binary found (or it is missing -d/--device)" >&2
+ echo " build it: cmake -B build && cmake --build build --target max25-terminal" >&2
+ echo " install: scripts/install-max25.sh or cmake --install build" >&2
+ echo " Do not run stacks/terminal/max25-terminal leftovers." >&2
+ exit 127
+}
+
+SOCK="${MAX25_UNIX:-/run/max25/modem.sock}"
+if [[ ! -S "$SOCK" ]] && [[ "${1:-}" != "-h" ]] && [[ "${1:-}" != "--help" ]]; then
+ if [[ " $* " != *" -T "* ]] && [[ " $* " != *" --tcp-only "* ]]; then
+ echo "run-max25-terminal: no socket $SOCK — start max25d first:" >&2
+ echo " run-max25d # or: ${SCRIPT_DIR}/run-max25d.sh" >&2
+ exit 1
+ fi
+fi
+
+exec "$BIN" "$@"
diff --git a/scripts/run-max25d.sh b/scripts/run-max25d.sh
new file mode 100755
index 0000000..232caf4
--- /dev/null
+++ b/scripts/run-max25d.sh
@@ -0,0 +1,99 @@
+#!/usr/bin/env bash
+# Canonical max25d start — escalate to root only when hardware needs it.
+# Works from source tree (scripts/) and from PREFIX/bin after install.
+# Root needed: ttyS / USB serial / ioperm / /dev/port / max25-bcpr SER12 (max25e0).
+# Escalation: already root → sudo -n → sudo → su → pkexec.
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+if [[ -x "${SCRIPT_DIR}/max25d" && ! -d "${SCRIPT_DIR}/../stacks/daemon" ]]; then
+ DAEMON="${SCRIPT_DIR}/max25d"
+ TREE=""
+else
+ TREE="$(cd "${SCRIPT_DIR}/.." && pwd)"
+ DAEMON="${TREE}/stacks/daemon/max25d"
+fi
+
+if [[ ! -x "$DAEMON" ]]; then
+ echo "run-max25d: missing $DAEMON" >&2
+ exit 1
+fi
+
+# Match real daemon only (not agent shells whose argv text mentions max25d).
+if pgrep -x max25d >/dev/null 2>&1 \
+ || pgrep -f -- '/max25d(\.py)?[[:space:]]+-c[[:space:]]' >/dev/null 2>&1; then
+ echo "run-max25d: max25d already running" >&2
+ pgrep -af -- '/max25d(\.py)?[[:space:]]+-c[[:space:]]' | head -5 || true
+ pgrep -ax max25d | head -5 || true
+ ls -la /run/max25/modem.sock /var/run/max25/modem.sock 2>/dev/null \
+ || echo "run-max25d: WARNING no modem.sock under /run/max25 or /var/run/max25" >&2
+ exit 0
+fi
+
+# Accept path or max25d-style "-c path".
+INI="${1:-}"
+if [[ "$INI" == "-c" || "$INI" == "--config" ]]; then
+ INI="${2:-}"
+fi
+if [[ -z "$INI" ]]; then
+ if [[ -n "$TREE" && -f "${TREE}/local/max25d.ini" ]]; then
+ INI="${TREE}/local/max25d.ini"
+ elif [[ -f "${PWD}/local/max25d.ini" ]]; then
+ INI="${PWD}/local/max25d.ini"
+ elif [[ -f /etc/max25/max25d.ini ]]; then
+ INI=/etc/max25/max25d.ini
+ elif [[ -f /usr/local/etc/max25/max25d.ini ]]; then
+ INI=/usr/local/etc/max25/max25d.ini
+ else
+ echo "run-max25d: no INI (pass path or create local/max25d.ini / /etc/max25/max25d.ini / /usr/local/etc/max25/max25d.ini)" >&2
+ exit 1
+ fi
+fi
+if [[ ! -f "$INI" ]]; then
+ echo "run-max25d: INI not found: $INI" >&2
+ exit 1
+fi
+
+needs_root() {
+ local f="$1"
+ [[ -f "$f" ]] || return 1
+ if grep -Eiq '^[[:space:]]*(max25[-_]bcpr|bcpr|baycom|pccom)[[:space:]]*=[[:space:]]*yes' "$f"; then return 0; fi
+ if grep -Eiq '^[[:space:]]*(max25e0|max25e0:bc[01]|tnc2c|pktnc2|baycom-kiss|pccom-kiss)[[:space:]]*=' "$f"; then return 0; fi
+ if grep -Eiq '^[[:space:]]*device[[:space:]]*=[[:space:]]*(max25e0|tnc2c|pktnc2)' "$f"; then return 0; fi
+ if grep -Eqi '/dev/(ttyS|ttyUSB|ttyACM|port)\b' "$f"; then return 0; fi
+ if grep -Eiq '^[[:space:]]*unix_socket[[:space:]]*=[[:space:]]*/(var/)?run/max25/' "$f"; then return 0; fi
+ return 1
+}
+
+if [[ "$(id -u)" -ne 0 ]]; then
+ if needs_root "$INI"; then
+ if command -v sudo >/dev/null 2>&1; then
+ if sudo -n true 2>/dev/null; then
+ echo "run-max25d: hardware path — escalating via sudo -n" >&2
+ exec sudo -n "$0" "$INI"
+ fi
+ echo "run-max25d: hardware path — escalating via sudo (password may be required)" >&2
+ exec sudo "$0" "$INI"
+ fi
+ if command -v su >/dev/null 2>&1; then
+ echo "run-max25d: hardware path — escalating via su" >&2
+ exec su -c "$(printf '%q ' "$0" "$INI")"
+ fi
+ if command -v pkexec >/dev/null 2>&1; then
+ echo "run-max25d: hardware path — escalating via pkexec" >&2
+ exec pkexec "$0" "$INI"
+ fi
+ echo "run-max25d: PC-COM or serial hardware needs admin — run: sudo $0 $INI" >&2
+ exit 1
+ fi
+ echo "run-max25d: starting unprivileged (uid=$(id -u))" >&2
+else
+ echo "run-max25d: running as root (uid=0)" >&2
+fi
+
+if [[ "$(id -u)" -eq 0 ]]; then
+ mkdir -p /run/max25 /var/run/max25
+fi
+echo "run-max25d: ini=$INI"
+exec "$DAEMON" -c "$INI"
diff --git a/scripts/terminology-check.sh b/scripts/terminology-check.sh
new file mode 100755
index 0000000..96c01fb
--- /dev/null
+++ b/scripts/terminology-check.sh
@@ -0,0 +1,41 @@
+#!/usr/bin/env bash
+# terminology-check.sh — release gate for AGENT-INDEX §0.16 / §0.19
+# Fail on forbidden product terms in shipped markdown (not tests/archives).
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+ERR=0
+
+check() {
+ local pat="$1" msg="$2"
+ local hits
+ hits="$(grep -RInE --include='*.md' "$pat" README.md CHANGELOG.md RELEASE-READINESS.md docs share/clients plugins 2>/dev/null \
+ | grep -viE 'Never |never |forbidden|LEGACY|removed|prefer bcpr|BayCom/based' || true)"
+ if [[ -n "$hits" ]]; then
+ echo "FAIL: $msg"
+ echo "$hits" | head -40
+ ERR=1
+ else
+ echo "OK: $msg"
+ fi
+}
+
+check '\bbcpr-bc0\b' 'product device id bcpr-bc0 (use max25e0)'
+check '\bbcpr-bc1\b' 'product device id bcpr-bc1 (use max25e0:bc1)'
+# product device face for SER12 — allow historical "removed" lines
+if grep -RInE --include='*.md' 'device = baycom-ser12|`baycom-ser12` \|' README.md docs 2>/dev/null \
+ | grep -vE 'removed|LEGACY|kernel baycom|not a kernel|Never' | grep -q .; then
+ echo "FAIL: product baycom-ser12 as active device face"
+ grep -RInE --include='*.md' 'device = baycom-ser12|`baycom-ser12` \|' README.md docs 2>/dev/null | head -20 || true
+ ERR=1
+else
+ echo "OK: no active baycom-ser12 product device face in README/docs"
+fi
+check '\bKonverter\b' 'Konverter in shipped docs'
+check '\bAX25SRV\b|\bax25srv\b' 'AX25SRV in shipped docs'
+
+if [[ "$ERR" -ne 0 ]]; then
+ exit 1
+fi
+echo "terminology-check: PASS"
+exit 0
diff --git a/scripts/test-wizard.sh b/scripts/test-wizard.sh
new file mode 100755
index 0000000..e75a401
--- /dev/null
+++ b/scripts/test-wizard.sh
@@ -0,0 +1,113 @@
+#!/usr/bin/env bash
+# test-wizard.sh — non-interactive smoke test for max25-lite-setup.sh.
+#
+# Drives the wizard through all three modem paths (serial, baycom, crdop)
+# using a mock `whiptail` on PATH that answers canned responses, then checks
+# the generated max25d.ini / crdop.ini / max25-bcpr.ini files are well formed.
+# No sudo, no hardware, no PTT — local ./local/ destination only.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+WIZARD="${ROOT}/scripts/max25-lite-setup.sh"
+WORKDIR="$(mktemp -d)"
+ERR=0
+
+ok() { echo "PASS: $*"; }
+fail() { echo "FAIL: $*" >&2; ERR=1; }
+
+cleanup() { rm -rf "$WORKDIR"; }
+trap cleanup EXIT
+
+mock_bin="${WORKDIR}/bin"
+mkdir -p "$mock_bin"
+
+# Mock whiptail: reads canned answers from $WHIPTAIL_ANSWERS (newline list, one
+# per --menu/--radiolist/--inputbox/--yesno call, in call order). --msgbox is a
+# no-op (informational only). Mirrors the 3>&1 1>&2 2>&3 capture convention:
+# the "answer" goes to this script's stderr; exit 0 means OK/Yes.
+cat >"${mock_bin}/whiptail" <<'MOCK'
+#!/usr/bin/env bash
+mode=""
+for a in "$@"; do
+ case "$a" in
+ --msgbox) mode=msgbox ;;
+ --yesno) mode=yesno ;;
+ --inputbox) mode=inputbox ;;
+ --menu) mode=menu ;;
+ --radiolist) mode=radiolist ;;
+ esac
+done
+[[ "$mode" == "msgbox" ]] && exit 0
+answers_file="${WHIPTAIL_ANSWERS:?WHIPTAIL_ANSWERS not set}"
+answer="$(head -n1 "$answers_file")"
+tail -n +2 "$answers_file" > "${answers_file}.next"
+mv "${answers_file}.next" "$answers_file"
+if [[ "$mode" == "yesno" ]]; then
+ [[ "$answer" == "yes" ]] && exit 0 || exit 1
+fi
+echo "$answer" >&2
+exit 0
+MOCK
+chmod +x "${mock_bin}/whiptail"
+
+# Mock arecord/aplay: force the "no ALSA cards" branch deterministically,
+# independent of the host's actual sound hardware, so the CRDOP path always
+# takes the same code path in this smoke test.
+for tool in arecord aplay; do
+ cat >"${mock_bin}/${tool}" <<'MOCK'
+#!/usr/bin/env bash
+exit 1
+MOCK
+ chmod +x "${mock_bin}/${tool}"
+done
+
+run_path() {
+ local label="$1" answers="$2" ini_glob="$3" want_grep="$4"
+ local answers_file="${WORKDIR}/answers-${label}"
+ printf '%s\n' "$answers" > "$answers_file"
+ rm -rf "${ROOT}/local/max25d.ini" "${ROOT}/local/crdop.ini" "${ROOT}/local/max25-bcpr.ini"
+ if PATH="${mock_bin}:${PATH}" WHIPTAIL_ANSWERS="$answers_file" \
+ bash "$WIZARD" </dev/null >"${WORKDIR}/out-${label}.log" 2>&1; then
+ if [[ -f "${ROOT}/local/max25d.ini" ]] && grep -q "$want_grep" "${ROOT}/local/max25d.ini"; then
+ ok "wizard ${label} path: max25d.ini written (${want_grep})"
+ else
+ fail "wizard ${label} path: max25d.ini missing or unexpected content"
+ cat "${WORKDIR}/out-${label}.log" >&2
+ fi
+ else
+ fail "wizard ${label} path: exited non-zero"
+ cat "${WORKDIR}/out-${label}.log" >&2
+ fi
+ rm -rf "${ROOT}/local/max25d.ini" "${ROOT}/local/crdop.ini" "${ROOT}/local/max25-bcpr.ini"
+}
+
+# serial: menu=serial, radiolist=/dev/ttyUSB0, callerid, callid, save-location=no (local)
+run_path "serial" "serial
+/dev/ttyUSB0
+CB-0
+QST
+no" "local/max25d.ini" "device = tnc2c"
+
+# baycom: menu=baycom, radiolist=/dev/ttyS0 (or whatever first port is), callerid, callid, no
+run_path "baycom" "baycom
+$(bash -c '
+ shopt -s nullglob
+ ports=(/dev/ttyUSB* /dev/ttyACM* /dev/ttyS*)
+ shopt -u nullglob
+ echo "${ports[0]:-/dev/ttyUSB0}"
+')
+CB-0
+QST
+no" "local/max25d.ini" "device = max25e0"
+
+# crdop: menu=crdop, [radiolist ALSA device only if arecord/aplay found any — assume
+# none in CI so wizard shows an informational msgbox and uses hw:0,0 defaults], callerid, callid, no
+run_path "crdop" "crdop
+CB-0
+QST
+no" "local/max25d.ini" "device = soft-crdop"
+
+if [[ "$ERR" -eq 0 ]]; then
+ echo "OK: wizard non-interactive smoke (serial / baycom / crdop)"
+fi
+exit "$ERR"
diff --git a/scripts/test.sh b/scripts/test.sh
new file mode 100755
index 0000000..24a83d7
--- /dev/null
+++ b/scripts/test.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+# test.sh — MAX25-Stack-Lite offline tests via CMake targets.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$ROOT"
+
+if [[ ! -d build ]]; then
+ bash "${ROOT}/scripts/build.sh"
+fi
+
+cmake --build build --target max25_test
diff --git a/scripts/tx-rx-test.sh b/scripts/tx-rx-test.sh
new file mode 100755
index 0000000..cdfbdca
--- /dev/null
+++ b/scripts/tx-rx-test.sh
@@ -0,0 +1,358 @@
+#!/usr/bin/env bash
+# tx-rx-test.sh — unified TX/RX release test (BayCom/based max25-bcpr + classic TNC).
+# Default: L0 offline only (CI / make test). Live/TX are operator-gated.
+# Public: BayCom/based / max25-bcpr. Never Konverter/converter.
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# Tree: scripts/ → repo root. Install: PREFIX/bin or PREFIX/sbin → no stacks/.
+if [[ -d "${SCRIPT_DIR}/../stacks/max25-bcpr" ]]; then
+ ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
+ cd "$ROOT"
+ _BCPR_CTL_DEFAULT="${ROOT}/stacks/max25-bcpr/tools/max25-bcpr-ctl"
+ _BCPR_INI_DEFAULT="${ROOT}/stacks/max25-bcpr/share/max25-bcpr.ini.example"
+else
+ ROOT=""
+ cd "${PWD:-/}"
+ _BCPR_CTL_DEFAULT="$(command -v max25-bcpr-ctl 2>/dev/null || true)"
+ _BCPR_INI_DEFAULT="/etc/max25/max25-bcpr.ini"
+fi
+
+LEVEL=0
+DEVICE=all # all | modem | tnc
+DO_LIVE=0
+DO_TX=0
+FORCE_TX=0
+SECONDS_LIVE=15
+TX_SECONDS=3
+BCPR_INI="${BCPR_INI:-$_BCPR_INI_DEFAULT}"
+SOCK="${MAX25_SOCK:-/run/max25/modem.sock}"
+ERR=0
+BCPR_CTL="${BCPR_CTL:-$_BCPR_CTL_DEFAULT}"
+if [[ -z "$BCPR_CTL" ]] || [[ ! -x "$BCPR_CTL" ]]; then
+ if command -v max25-bcpr-ctl >/dev/null 2>&1; then
+ BCPR_CTL="$(command -v max25-bcpr-ctl)"
+ elif [[ -n "$ROOT" && -x "$ROOT/stacks/max25-bcpr/tools/max25-bcpr-ctl" ]]; then
+ BCPR_CTL="$ROOT/stacks/max25-bcpr/tools/max25-bcpr-ctl"
+ fi
+fi
+BCPR_SMOKE="${BCPR_SMOKE:-}"
+if [[ -z "$BCPR_SMOKE" ]]; then
+ if [[ -n "$ROOT" && -x "$ROOT/stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh" ]]; then
+ BCPR_SMOKE="$ROOT/stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh"
+ elif command -v max25-bcpr-rxtx-smoke.sh >/dev/null 2>&1; then
+ BCPR_SMOKE="$(command -v max25-bcpr-rxtx-smoke.sh)"
+ elif [[ -x /usr/local/sbin/max25-bcpr-rxtx-smoke.sh ]]; then
+ BCPR_SMOKE=/usr/local/sbin/max25-bcpr-rxtx-smoke.sh
+ fi
+fi
+
+usage() {
+ cat <<'USAGE'
+Usage: tx-rx-test.sh [--level N] [--device all|modem|tnc] [--live] [--tx] [--seconds N] [--tx-seconds N] [-c bcpr.ini]
+
+ L0 offline TX/RX proofs (default) — CI / release-check / ctest
+ L1 soft preflight (no attach / no RF)
+ L2 live attach (modem: max25-bcprd timed; tnc: max25d sock status)
+ L3 live RX listen (same as L2 + RX readiness)
+ L4 TX (--tx required; PTT / RF risk; default ~3s MCR key)
+
+ --live implies at least L2. --tx requires --live (L4).
+ --tx-seconds: modem PTT key target (default 3; proven ≈376B info).
+ Default: NO TX. Hard time caps. Safe for make test / CI.
+
+ Static rule (§0.20): live --tx is refused unless RX was detected in this
+ run (modem: Soft-DCD/noise → state_dir/dcd-bc* or rx-activity-bc*;
+ TNC: live CONNECT/RX path OK first). Override only with --force-tx
+ (against policy — debug only).
+USAGE
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --level) LEVEL="$2"; shift 2 ;;
+ --device) DEVICE="$2"; shift 2 ;;
+ --live) DO_LIVE=1; shift ;;
+ --tx) DO_TX=1; shift ;;
+ --force-tx) FORCE_TX=1; shift ;;
+ --seconds) SECONDS_LIVE="$2"; shift 2 ;;
+ --tx-seconds) TX_SECONDS="$2"; shift 2 ;;
+ -c) BCPR_INI="$2"; shift 2 ;;
+ -h|--help) usage; exit 0 ;;
+ *) echo "ERROR: unknown arg: $1"; usage; exit 2 ;;
+ esac
+done
+
+case "$DEVICE" in
+ all|modem|tnc) ;;
+ *) echo "ERROR: --device must be all|modem|tnc"; exit 2 ;;
+esac
+
+if [[ "$DO_TX" -eq 1 && "$DO_LIVE" -eq 0 ]]; then
+ echo "ERROR: --tx requires --live"
+ exit 2
+fi
+
+if [[ "$DO_LIVE" -eq 1 && "$LEVEL" -lt 2 ]]; then
+ LEVEL=2
+fi
+if [[ "$DO_TX" -eq 1 ]]; then
+ LEVEL=4
+fi
+
+if [[ "$SECONDS_LIVE" -lt 1 ]]; then
+ SECONDS_LIVE=1
+fi
+if [[ "$SECONDS_LIVE" -gt 60 ]]; then
+ echo "WARN: capping --seconds from $SECONDS_LIVE to 60"
+ SECONDS_LIVE=60
+fi
+if [[ "$TX_SECONDS" -lt 1 ]]; then
+ TX_SECONDS=1
+fi
+if [[ "$TX_SECONDS" -gt 12 ]]; then
+ echo "WARN: capping --tx-seconds from $TX_SECONDS to 12"
+ TX_SECONDS=12
+fi
+need_live=$((TX_SECONDS + 5))
+if [[ "$DO_TX" -eq 1 && "$SECONDS_LIVE" -lt "$need_live" ]]; then
+ echo "NOTE: raising --seconds from $SECONDS_LIVE to $need_live for --tx-seconds $TX_SECONDS"
+ SECONDS_LIVE=$need_live
+fi
+
+log() { printf '%s\n' "$*"; }
+ok() { log "PASS: $*"; }
+fail() { log "FAIL: $*"; ERR=1; }
+stage() { log ""; log "=== $* ==="; }
+want_modem() { [[ "$DEVICE" == "all" || "$DEVICE" == "modem" ]]; }
+want_tnc() { [[ "$DEVICE" == "all" || "$DEVICE" == "tnc" ]]; }
+
+run_l0() {
+ stage "L0 offline TX/RX"
+ if [[ -n "$ROOT" && -f "$ROOT/stacks/daemon/test_tx_rx_offline.py" ]]; then
+ if python3 "$ROOT/stacks/daemon/test_tx_rx_offline.py"; then
+ ok "test_tx_rx_offline (TNC + bcpr host)"
+ else
+ fail "test_tx_rx_offline"
+ fi
+ else
+ ok "test_tx_rx_offline skipped (installed layout — use bcpr-rxtx-smoke)"
+ fi
+
+ if want_tnc; then
+ if [[ -f stacks/tncs/test_tnc_serial_recovery.py ]]; then
+ if python3 stacks/tncs/test_tnc_serial_recovery.py >/dev/null; then
+ ok "tnc serial recovery unit"
+ else
+ fail "tnc serial recovery unit"
+ fi
+ else
+ ok "tnc serial recovery unit skipped (stacks/tncs/ not shipped in this build — Lite: modem/BayCom-based only)"
+ fi
+ if python3 stacks/daemon/test_kiss_bridge.py >/dev/null; then
+ ok "kiss_bridge unit (TNC KISS)"
+ else
+ fail "kiss_bridge unit"
+ fi
+ fi
+
+ if want_modem; then
+ # Lite default tree builds max25-bcpr (MAX25_BUILD_MAX25_BCPR=ON). Do not
+ # auto-rebuild it here for L0 in CI / release-check — only smoke when
+ # binaries already exist or MAX25_BCPR_ALLOW_BUILD=1.
+ bcprd=""
+ for cand in \
+ "${BCPR_BUILD_DIR:-}/bin/max25-bcprd" \
+ "${BUILD_DIR:-}/bin/max25-bcprd" \
+ build/bin/max25-bcprd \
+ build-default/bin/max25-bcprd \
+ "build-max25-bcpr/bin/max25-bcprd" \
+ "/tmp/max25-build-max25-bcpr-${USER:-user}/bin/max25-bcprd"; do
+ if [[ -n "$cand" && -x "$cand" ]]; then
+ bcprd="$cand"
+ break
+ fi
+ done
+ if [[ -z "$bcprd" && "${MAX25_BCPR_ALLOW_BUILD:-0}" != "1" ]]; then
+ ok "max25-bcpr L0 skipped (not built — run cmake --build with MAX25_BUILD_MAX25_BCPR=ON, default in Lite)"
+ elif [[ -x stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh ]]; then
+ if [[ -n "$bcprd" ]]; then
+ if MAX25_BCPR_NO_AUTOBUILD=1 bash stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh -c "$BCPR_INI"; then
+ ok "max25-bcpr-rxtx-smoke L0 (BayCom/based)"
+ else
+ fail "max25-bcpr-rxtx-smoke L0"
+ fi
+ else
+ if bash stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh -c "$BCPR_INI"; then
+ ok "max25-bcpr-rxtx-smoke L0 (BayCom/based)"
+ else
+ fail "max25-bcpr-rxtx-smoke L0"
+ fi
+ fi
+ else
+ fail "max25-bcpr-rxtx-smoke.sh missing"
+ fi
+ fi
+}
+
+run_l1() {
+ stage "L1 soft preflight"
+ if want_modem; then
+ if [[ -n "${BCPR_CTL:-}" && -x "$BCPR_CTL" ]]; then
+ set +e
+ "$BCPR_CTL" -c "$BCPR_INI" preflight
+ pf_rc=$?
+ set -e
+ if [[ "$pf_rc" -eq 0 ]]; then
+ ok "max25-bcpr-ctl preflight"
+ else
+ if [[ "$DO_LIVE" -eq 1 ]]; then
+ fail "max25-bcpr-ctl preflight (required for --live modem)"
+ else
+ log "NOTE: bcpr preflight rc=$pf_rc — soft at L1 without --live"
+ fi
+ fi
+ else
+ fail "max25-bcpr-ctl missing (install max25-bcpr or set BCPR_CTL)"
+ fi
+ fi
+ if want_tnc; then
+ if [[ ! -d stacks/tncs ]]; then
+ ok "tnc preflight skipped (stacks/tncs/ not shipped in this build — Lite: modem/BayCom-based only)"
+ elif [[ -f share/clients/tnc2c.yaml ]] && [[ -f stacks/tncs/tnc_serial_recovery.py ]]; then
+ ok "tnc client profile + recovery module present"
+ else
+ fail "tnc profile/recovery missing"
+ fi
+ fi
+}
+
+live_tnc_sock() {
+ local mode="$1" # rx|tx
+ local sock="$SOCK"
+ if [[ ! -S "$sock" ]]; then
+ if [[ -S /tmp/max25/modem.sock ]]; then
+ sock=/tmp/max25/modem.sock
+ else
+ fail "no max25d unix socket ($SOCK) — start max25d with TNC first"
+ return 1
+ fi
+ fi
+ log "using unix socket $sock (mode=$mode)"
+ python3 - "$sock" "$mode" <<'PY' || return 1
+import socket, sys, time
+
+sock_path, mode = sys.argv[1], sys.argv[2]
+
+class LineReader:
+ def __init__(self, s):
+ self.s = s
+ self.buf = b""
+ def read(self, timeout=8.0):
+ self.s.settimeout(timeout)
+ while b"\n" not in self.buf:
+ chunk = self.s.recv(4096)
+ if not chunk:
+ raise RuntimeError("connection closed")
+ self.buf += chunk
+ line, self.buf = self.buf.split(b"\n", 1)
+ return line.decode("utf-8", errors="replace")
+
+s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+s.connect(sock_path)
+r = LineReader(s)
+hello = r.read()
+assert hello == "OK", hello
+status = r.read()
+assert status.startswith("STATUS "), status
+s.sendall(b"CONNECT\n")
+ev = r.read()
+assert ev == "EVENT connected", ev
+assert r.read() == "OK"
+if mode == "tx":
+ s.sendall(b"SEND TXRXTEST\n")
+ rx = r.read()
+ assert rx.startswith("RX "), rx
+ assert r.read() == "OK"
+ print("TNC live TX: SEND OK (host path)")
+else:
+ # RX readiness: connected + status shows device
+ s.sendall(b"GET STATUS\n")
+ st = r.read()
+ assert "device=" in st, st
+ assert r.read() == "OK"
+ print("TNC live RX: CONNECT + STATUS OK (listen ready)")
+s.close()
+PY
+}
+
+run_live() {
+ stage "L2/L3 live (${SECONDS_LIVE}s) device=$DEVICE"
+ if want_modem; then
+ local smoke_args=(-c "$BCPR_INI" --live --seconds "$SECONDS_LIVE")
+ if [[ "$DO_TX" -eq 1 ]]; then
+ smoke_args+=(--tx --tx-seconds "$TX_SECONDS")
+ if [[ "${FORCE_TX:-0}" -eq 1 ]]; then
+ smoke_args+=(--force-tx)
+ fi
+ log "WARN: modem --tx asserts PTT (~${TX_SECONDS}s; watch LED/wattmeter) — only after RX (§0.20)"
+ fi
+ _smoke="${BCPR_SMOKE:-stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh}"
+ if [[ ! -x "$_smoke" ]]; then
+ fail "max25-bcpr-rxtx-smoke.sh missing (install max25-bcpr or set BCPR_SMOKE)"
+ elif bash "$_smoke" "${smoke_args[@]}"; then
+ ok "bcpr live smoke (BayCom/based)"
+ else
+ fail "bcpr live smoke"
+ fi
+ fi
+ if want_tnc; then
+ local TNC_RX_OK=0
+ if live_tnc_sock rx; then
+ ok "tnc live CONNECT (RX ready)"
+ TNC_RX_OK=1
+ else
+ fail "tnc live CONNECT"
+ fi
+ if [[ "$DO_TX" -eq 1 ]]; then
+ if [[ "$TNC_RX_OK" -ne 1 && "${FORCE_TX:-0}" -eq 0 ]]; then
+ fail "TNC TX blocked (§0.20): RX/CONNECT failed"
+ else
+ log "WARN: TNC --tx sends one UI frame over air if radio keyed"
+ if live_tnc_sock tx; then
+ ok "tnc live CONNECT+SEND"
+ else
+ fail "tnc live CONNECT+SEND"
+ fi
+ fi
+ fi
+ fi
+}
+
+# --- main ---
+log "tx-rx-test level=$LEVEL device=$DEVICE live=$DO_LIVE tx=$DO_TX tx_seconds=$TX_SECONDS"
+run_l0
+
+if [[ "$LEVEL" -ge 1 ]]; then
+ run_l1
+fi
+
+if [[ "$LEVEL" -ge 2 || "$DO_LIVE" -eq 1 ]]; then
+ run_live
+fi
+
+stage "summary"
+if [[ "$ERR" -eq 0 ]]; then
+ if [[ "$DO_TX" -eq 1 ]]; then
+ ok "TX/RX complete through L4 (live + TX)"
+ elif [[ "$DO_LIVE" -eq 1 ]]; then
+ ok "TX/RX complete through L2/L3 (live RX, no TX)"
+ elif [[ "$LEVEL" -ge 1 ]]; then
+ ok "TX/RX L0+L1 complete"
+ else
+ ok "TX/RX L0 offline complete — use --live / --tx for hardware"
+ fi
+ exit 0
+fi
+fail "one or more stages failed"
+exit 1
diff --git a/share/clients/README.md b/share/clients/README.md
new file mode 100644
index 0000000..0a2bfc7
--- /dev/null
+++ b/share/clients/README.md
@@ -0,0 +1,79 @@
+# Terminal client profiles (YAML)
+
+Per-modem **max25-terminal** profiles for devices that need settings beyond a BayCom/based **bcpr** or TNC env catalog.
+
+| Who reads this | Purpose |
+|----------------|---------|
+| **max25-terminal** | Connection target, default `SET DEVICE`, session IDs, UI pacing |
+| **Operator** | `max25-terminal -U /run/max25/modem.sock` — device from max25d `[devices] default`, or `-d <id>` / F10→7 / `SET DEVICE` (id from YAML `device.id`) |
+| **max25d** | **Not** loaded from here — use `max25d.ini`, `bcpr.ini`, or env files |
+
+## BayCom/based (bcpr) — client YAML as operator reference
+
+SER12 PC-COM class hardware runs via **bcpr** (`stacks/max25-bcpr/`), not a kernel driver. Configure `bcpr.ini` / max25d `[features] max25_bcpr=yes` and device **`max25e0`** (`max25e0 = max25-bcpr:bc0`). No separate client YAML is required — use max25d default or `SET DEVICE max25e0`.
+
+Terminal clients attach to **max25d** after `max25-bcprd` / max25d bring-up. See [../../stacks/max25-bcpr/README.md](../../stacks/max25-bcpr/README.md).
+
+Registry of all catalog IDs (with and without client YAML): [`index.yaml`](index.yaml).
+
+## Schema (`schema_version: 1`)
+
+```yaml
+schema_version: 1
+id: tnc2c # max25d device id or catalog id
+label: Human name
+type: client-profile # client-profile | catalog-only
+
+connection: # terminal → max25d (never raw serial)
+ host: 127.0.0.1
+ port: 7325
+ unix_socket: /run/max25/modem.sock
+
+device: # M25/1 SET DEVICE on connect
+ id: tnc2c
+ hardware: tncs
+
+session: # optional defaults (SET CALLERID / CALLID / AX25_UI)
+ callerid: CB-0
+ callid: QST
+ ax25_ui: true
+
+terminal: # HyBBX-aligned UI
+ pacing_baud: 2400
+ line_width: 80
+ payload_max: 256
+
+max25d: # pointers for daemon/site config (informational)
+ ini_example: share/max25/max25d.ini.example
+ device_section: serial.tnc2c
+ env_file: stacks/tncs/tnc2c-serial.env
+ boot_wait: stacks/tncs/tnc2c-boot-wait.sh
+
+serial: # max25d-owned serial (client does not open)
+ default_device: /dev/ttyS4
+ baud: 19200
+ line: 8n1
+ dtr_rts: true
+ kiss_entry: kiss_on
+```
+
+KISS-async and CRDOP profiles use `serial:` or `crdop:` instead of BayCom catalog fields.
+
+## Files
+
+| File | Modem / device |
+|------|----------------|
+| [`tnc2c.yaml`](tnc2c.yaml) | Landolt TNC2C |
+| (none — use `max25e0`) | BayCom/based SER12 via max25-bcpr (PC-COM) |
+| [`pktnc2.yaml`](pktnc2.yaml) | PK-TNC2 / TNC-2 (TheFirmware)
+| [`kiss-serial-usb.yaml`](kiss-serial-usb.yaml) | KISS on USB (`modems.ini` catalog) |
+| [`kiss-serial-rs232.yaml`](kiss-serial-rs232.yaml) | KISS on RS-232 |
+| [`baycom-kiss.yaml`](baycom-kiss.yaml) | MAX25 device plugin (USB KISS) |
+| [`soft-crdop.yaml`](soft-crdop.yaml) | CRDOP sound-card modem |
+| [`index.yaml`](index.yaml) | Full modem database map |
+
+## See also
+
+- [../../include/max25/protocol.md](../../include/max25/protocol.md) — M25/1 protocol
+- [../../stacks/terminal/README.md](../../stacks/terminal/README.md) — operator UI
+- [stacks/max25-bcpr/share/max25-bcpr.ini.example](../../stacks/max25-bcpr/share/max25-bcpr.ini.example) — bcpr INI example
diff --git a/share/clients/baycom-kiss.yaml b/share/clients/baycom-kiss.yaml
new file mode 100644
index 0000000..b849c2a
--- /dev/null
+++ b/share/clients/baycom-kiss.yaml
@@ -0,0 +1,46 @@
+schema_version: 1
+id: baycom-kiss
+label: BayCom KISS Serial (MAX25 device)
+type: client-profile
+status: scaffold
+
+connection:
+ host: 127.0.0.1
+ port: 7325
+ unix_socket: /run/max25/modem.sock
+ tcp_only: false
+
+device:
+ id: baycom-kiss
+ hardware: modems
+
+session:
+ callerid: CB-0
+ callid: QST
+ ax25_ui: true
+
+terminal:
+ pacing_baud: 2400
+ line_width: 80
+ payload_max: 256
+
+max25d:
+ ini_example: share/max25/max25d.ini.example
+ devices_key: baycom-kiss
+ backend: kiss-raw-serial
+
+serial:
+ default_device: /dev/ttyUSB0
+ baud: 9600
+ line: 8n1
+ dtr_rts: false
+
+catalog_profiles:
+ - share/clients/kiss-serial-usb.yaml
+ - share/clients/kiss-serial-rs232.yaml
+
+docs:
+ - plugins/devices/baycom-kiss/plugin.yaml
+ - stacks/baycom-pr/docs/GETTING-STARTED.md
+
+notes: Device plugin wrapper — pick USB or RS-232 catalog profile above.
diff --git a/share/clients/index.yaml b/share/clients/index.yaml
new file mode 100644
index 0000000..099269f
--- /dev/null
+++ b/share/clients/index.yaml
@@ -0,0 +1,39 @@
+# MAX25 client profile registry
+# Maps MAX25 device plugins to terminal client configs.
+# Kernel baycom-pr / modems.ini catalog removed 2026-07-18 — SER12 product path is bcpr.
+
+schema_version: 1
+plugin_registry: plugins/manifest.yaml
+
+devices:
+ - id: tnc2c
+ label: Landolt TNC2C
+ hardware: tncs
+ client_config: share/clients/tnc2c.yaml
+ status: active
+
+ - id: pktnc2
+ label: PK-TNC2 / TNC-2 (TheFirmware)
+ hardware: tncs
+ client_config: share/clients/pktnc2.yaml
+ status: planned
+
+ - id: baycom-kiss
+ label: BayCom KISS serial (USB/async)
+ hardware: modems
+ client_config: share/clients/baycom-kiss.yaml
+ status: active
+
+ - id: tmodem
+ label: T-Modem-c1224 USB KISS half-TNC
+ hardware: tncs
+ client_config: share/clients/tmodem.yaml
+ status: prototype
+
+ - id: soft-crdop
+ label: CRDOP sound-card modem
+ hardware: soft-modems
+ client_config: share/clients/soft-crdop.yaml
+ status: active
+
+# BayCom/based SER12: configure max25d max25e0 / max25e0:bc1 — no separate client YAML required.
diff --git a/share/clients/kiss-serial-rs232.yaml b/share/clients/kiss-serial-rs232.yaml
new file mode 100644
index 0000000..6a22148
--- /dev/null
+++ b/share/clients/kiss-serial-rs232.yaml
@@ -0,0 +1,55 @@
+schema_version: 1
+id: kiss-serial-rs232
+label: KISS modem on RS-232 serial
+type: client-profile
+catalog: kiss-serial-rs232
+status: active
+
+connection:
+ host: 127.0.0.1
+ port: 7325
+ unix_socket: /run/max25/modem.sock
+ tcp_only: false
+
+device:
+ id: baycom-kiss
+ hardware: modems
+
+session:
+ callerid: CB-0
+ callid: QST
+ ax25_ui: true
+
+terminal:
+ pacing_baud: 2400
+ line_width: 80
+ payload_max: 256
+
+max25d:
+ ini_example: share/max25/max25d.ini.example
+ device_section: serial.baycom-kiss
+ backend: kiss-raw-serial
+ baycom_pr:
+ catalog: kiss-serial-rs232
+ kiss_link: /var/run/baycom-pr/kiss
+
+serial:
+ default_device: /dev/ttyS1
+ baud: 9600
+ line: 8n1
+ dtr_rts: false
+ kiss_entry: none
+
+rf:
+ radio_baud: 1200
+
+hybbx:
+ plugin: baycom
+ backend: kiss
+
+docs:
+ - stacks/baycom-pr/config/modems.ini
+
+notes: |
+ TNC2, OpenTracker, KISS firmware on RS-232 or USB-serial adapter.
+ Self-powered TNC preferred; port-powered units need adequate TXD/DTR current.
diff --git a/share/clients/kiss-serial-usb.yaml b/share/clients/kiss-serial-usb.yaml
new file mode 100644
index 0000000..120bb36
--- /dev/null
+++ b/share/clients/kiss-serial-usb.yaml
@@ -0,0 +1,57 @@
+schema_version: 1
+id: kiss-serial-usb
+label: KISS modem on USB (ttyUSB / ttyACM)
+type: client-profile
+catalog: kiss-serial-usb
+status: active
+
+connection:
+ host: 127.0.0.1
+ port: 7325
+ unix_socket: /run/max25/modem.sock
+ tcp_only: false
+
+device:
+ id: baycom-kiss
+ hardware: modems
+
+session:
+ callerid: CB-0
+ callid: QST
+ ax25_ui: true
+
+terminal:
+ pacing_baud: 2400
+ line_width: 80
+ payload_max: 256
+
+max25d:
+ ini_example: share/max25/max25d.ini.example
+ device_section: serial.baycom-kiss
+ backend: kiss-raw-serial
+ baycom_pr:
+ catalog: kiss-serial-usb
+ kiss_link: /var/run/baycom-pr/kiss
+
+serial:
+ default_device: /dev/ttyUSB0
+ baud: 9600
+ line: 8n1
+ dtr_rts: false
+ kiss_entry: none
+
+rf:
+ radio_baud: 1200
+
+hybbx:
+ plugin: baycom
+ backend: kiss
+
+docs:
+ - stacks/baycom-pr/config/modems.ini
+ - stacks/baycom-pr/docs/GETTING-STARTED.md
+
+notes: |
+ No kernel baycom_ser_fdx — async USB serial KISS.
+ max25d or baycom-pr kiss bridge owns the USB device; terminal uses M25/1 only.
+ Adapter must supply stable USB power (see stacks/baycom-pr/docs/archive/HARDWARE.md).
diff --git a/share/clients/pktnc2.yaml b/share/clients/pktnc2.yaml
new file mode 100644
index 0000000..bf97834
--- /dev/null
+++ b/share/clients/pktnc2.yaml
@@ -0,0 +1,54 @@
+schema_version: 1
+id: pktnc2
+label: PK-TNC2 / TNC-2 (TheFirmware)
+type: client-profile
+status: planned
+
+connection:
+ host: 127.0.0.1
+ port: 7325
+ unix_socket: /run/max25/modem.sock
+ tcp_only: false
+
+device:
+ id: pktnc2
+ hardware: tncs
+
+session:
+ callerid: CB-0
+ callid: QST
+ ax25_ui: true
+
+terminal:
+ pacing_baud: 2400
+ line_width: 80
+ payload_max: 256
+
+max25d:
+ ini_example: share/max25/max25d.ini.example
+ device_section: serial.pktnc2
+ env_file: stacks/tncs/pktnc2-serial.env
+ boot_wait: stacks/tncs/pktnc2-boot-wait.sh
+
+serial:
+ default_device: /dev/ttyS5
+ baud: 9600
+ line: 8n1
+ dtr_rts: false
+ kiss_entry: auto
+
+rf:
+ radio_baud: 1200
+ band: cb
+
+hybbx:
+ ini_example: share/hybbx/pktnc2-host.ini.example
+ plugin: packet_radio
+ profile: tnc2
+ aliases: [pktnc2, pk-tnc2, tapr, thefirmware]
+
+docs:
+ - stacks/tncs/docs/TNC2C-REFERENCE.md
+ - plugins/devices/pktnc2/plugin.yaml
+
+notes: Probe serial port after hardware inventory — adjust default_device in local env.
diff --git a/share/clients/soft-crdop.yaml b/share/clients/soft-crdop.yaml
new file mode 100644
index 0000000..81ae5a2
--- /dev/null
+++ b/share/clients/soft-crdop.yaml
@@ -0,0 +1,66 @@
+schema_version: 1
+id: soft-crdop
+label: MAX25-SoftModem (CRDOP)
+type: client-profile
+status: active
+
+connection:
+ host: 127.0.0.1
+ port: 7325
+ unix_socket: /run/max25/modem.sock
+ tcp_only: false
+
+device:
+ id: soft-crdop
+ hardware: soft-modems
+
+session:
+ callerid: CB-0
+ callid: QST
+ ax25_ui: true
+
+terminal:
+ pacing_baud: 1200
+ line_width: 80
+ payload_max: 256
+
+max25d:
+ ini_example: share/max25/max25d.ini.example
+ device_section: device.soft-crdop
+ ctl_start: stacks/crdop/scripts/crdopc
+ default_ini: stacks/crdop/share/crdop.ini.example
+
+softmodem:
+ product: MAX25-SoftModem
+ phase: development_and_testing
+ protocol: max25-softmodem
+ baud_primary: 1200
+ baud_max: 19200
+ duplex: [half, full]
+ ax25_packet_radio: acoustically_compatible
+ use_like_hardware_modem: true
+ audio_required: true
+ coupling: [line_level, acoustic]
+ host: 127.0.0.1
+ control_port: 8515
+ data_port: 8516
+ listen: true
+
+hybbx:
+ ini_example: share/hybbx/crdop-host.ini.example
+ plugin: crdop
+
+docs:
+ - stacks/crdop/docs/INDEX.md
+ - stacks/crdop/README.md
+ - stacks/crdop/docs/SOFTMODEM.md
+ - stacks/crdop/ROADMAP.md
+ - plugins/devices/soft-crdop/plugin.yaml
+
+notes: |
+ MAX25-SoftModem (CRDOP) — MAX25-Stack STANDARD (built unless MAX25_BUILD_CRDOP=OFF).
+ Development and test phase. 1200 baud+, half/full duplex.
+ Acoustically AX.25/Packet-Radio compatible with TNCs and BayCom modems.
+ Sound card IN/OUT + radio — use like a hardware modem in max25d.
+ Good sound card REQUIRED (accurate tones; stricter at higher baud).
+ Open docs for DIY hardware interfaces.
diff --git a/share/clients/tmodem.yaml b/share/clients/tmodem.yaml
new file mode 100644
index 0000000..162b5b8
--- /dev/null
+++ b/share/clients/tmodem.yaml
@@ -0,0 +1,42 @@
+schema_version: 1
+id: tmodem
+label: T-Modem-c1224 USB KISS half-TNC
+type: client-profile
+status: prototype
+
+connection:
+ host: 127.0.0.1
+ port: 7325
+ unix_socket: /run/max25/modem.sock
+ tcp_only: false
+
+device:
+ id: tmodem
+ hardware: tncs
+
+session:
+ callerid: CB-0
+ callid: QST
+ ax25_ui: true
+
+terminal:
+ pacing_baud: 1200
+ line_width: 80
+ payload_max: 256
+
+max25d:
+ ini_example: share/max25/max25d.tmodem.ini.example
+ devices_key: tmodem
+ backend: kiss-raw-serial
+
+serial:
+ default_device: /dev/ttyACM0
+ baud: 115200
+ line: 8n1
+ dtr_rts: false
+
+docs:
+ - plugins/devices/tmodem/plugin.yaml
+ - docs/DEV/TMODEM-C1224.md
+
+notes: Not BayCom/based. Feature [features] tmodem=yes required.
diff --git a/share/clients/tnc2c.yaml b/share/clients/tnc2c.yaml
new file mode 100644
index 0000000..90c6e9c
--- /dev/null
+++ b/share/clients/tnc2c.yaml
@@ -0,0 +1,53 @@
+schema_version: 1
+id: tnc2c
+label: Landolt TNC2C
+type: client-profile
+status: active
+
+connection:
+ host: 127.0.0.1
+ port: 7325
+ unix_socket: /run/max25/modem.sock
+ tcp_only: false
+
+device:
+ id: tnc2c
+ hardware: tncs
+
+session:
+ callerid: CB-0
+ callid: QST
+ ax25_ui: true
+
+terminal:
+ pacing_baud: 2400
+ line_width: 80
+ payload_max: 256
+
+max25d:
+ ini_example: share/max25/max25d.ini.example
+ device_section: serial.tnc2c
+ env_file: stacks/tncs/tnc2c-serial.env
+ boot_wait: stacks/tncs/tnc2c-boot-wait.sh
+ health: stacks/tncs/tnc2c-health.sh
+
+serial:
+ default_device: /dev/ttyS4
+ baud: 19200
+ line: 8n1
+ dtr_rts: true
+ kiss_entry: kiss_on
+
+rf:
+ modem: tcm3105
+ radio_baud: 2400
+ band: cb
+
+hybbx:
+ ini_example: share/hybbx/tnc2c-host.ini.example
+ plugin: packet_radio
+ profile: tnc2c
+
+docs:
+ - stacks/tncs/docs/TNC2C-OPERATIONS.md
+ - plugins/devices/tnc2c/plugin.yaml
diff --git a/share/hybbx/README.md b/share/hybbx/README.md
new file mode 100644
index 0000000..6694bb5
--- /dev/null
+++ b/share/hybbx/README.md
@@ -0,0 +1,7 @@
+# share/hybbx/ — not a Lite operator path
+
+These INI examples describe **HyBBX co-host** and **TNC2C/pktnc2 hardware** attach, which are full **MAX25-Stack** features. Both are **out of scope** for MAX25-Stack-Lite (localhost `max25d` + `max25-terminal`/`max25-client`, CRDOP, BayCom/based).
+
+Kept only so the fork stays in sync with upstream `share/`; not built, not referenced by Lite's own scripts (`max25-lite-setup.sh`, `run-max25d.sh`, `run-max25-terminal.sh`), and not part of the Lite install tree.
+
+For Lite configuration, use `share/max25/max25d.lite*.ini.example` and `share/max25-bcpr/*.ini.example` instead.
diff --git a/share/hybbx/baycom-kiss-host.ini.example b/share/hybbx/baycom-kiss-host.ini.example
new file mode 100644
index 0000000..9fbefb9
--- /dev/null
+++ b/share/hybbx/baycom-kiss-host.ini.example
@@ -0,0 +1,38 @@
+; HyBBX INI — co-host / secondary RF (BayCom/based via MAX25 KISS prep)
+; max25d: mode=hybbx-main | hybbx-cohost | hybbx-host
+; [features] baycom=yes max25_bcpr=yes
+; max25e0 = max25-bcpr:bc0 → kiss_link /tmp/max25-bcpr/kiss-bc0
+; HyBBX opens KISS after max25d prep — max25d does not hold the fd.
+; Copy to hybbx.ini on RF host; circuit_host points at Main hub when split.
+
+[instance]
+standalone = yes
+
+[networks]
+baycom = yes
+ax25 = no
+
+[max25]
+check = yes
+host = 127.0.0.1
+port = 7325
+
+[circuit]
+enabled = yes
+; circuit_host on transport rows when linking to remote Main
+
+[transport.baycom1]
+backend = kiss
+device = /tmp/max25-bcpr/kiss-bc0
+serial_baud = 1200
+radio_baud = 1200
+txdelay = 35
+slot = 10
+persist = 128
+txtail = 2
+circuit_host = main.example.com
+circuit_port = 7323
+link_id = max25-pccom-kiss
+link_password = changeme
+link_role = link
+frequency_mhz = 27.205
diff --git a/share/hybbx/crdop-host.ini.example b/share/hybbx/crdop-host.ini.example
new file mode 100644
index 0000000..f3d50c7
--- /dev/null
+++ b/share/hybbx/crdop-host.ini.example
@@ -0,0 +1,17 @@
+; MainAX25-Stack (MAX25-Stack) — HyBBX Secondary host (CRDOP / soft-crdop)
+; Merge into HyBBX hybbx.ini on Secondary node.
+; Prerequisite: crdopc running (max25-ctl start --hardware soft-modems --device soft-crdop)
+
+[networks]
+crdop = yes
+
+[transport.crdop1]
+enabled = yes
+modem_host = 127.0.0.1
+modem_port = 8515
+mycall = CB-0
+listen = yes
+circuit_host = main.example.com
+circuit_port = 7323
+link_id = max25-crdop
+link_password = changeme
diff --git a/share/hybbx/pktnc2-host.ini.example b/share/hybbx/pktnc2-host.ini.example
new file mode 100644
index 0000000..3675a87
--- /dev/null
+++ b/share/hybbx/pktnc2-host.ini.example
@@ -0,0 +1,28 @@
+; MainAX25-Stack (MAX25-Stack) — HyBBX Secondary host (PK-TNC2 / TNC-2)
+; Prerequisite: max25d auto_start or ./scripts/max25-ctl start --hardware tncs --device pktnc2
+; Standalone HyBBX (no max25d): set kiss_entry = auto instead of none
+
+[networks]
+ax25 = yes
+
+[transport.packet_radio1]
+enabled = yes
+tnc = tnc2
+protocol = kiss
+device_type = serial
+device = /dev/ttyUSB0
+baud = 9600
+serial_line = 8n1
+rts_dtr = no
+kiss_entry = none
+radio_baud = 1200
+radio_band = cb
+radio_duplex = half
+persist = 255
+circuit_host = main.example.com
+circuit_port = 7323
+link_id = max25-pktnc2
+link_password = changeme
+
+[max25]
+check = yes
diff --git a/share/hybbx/service-dual.ini.example b/share/hybbx/service-dual.ini.example
new file mode 100644
index 0000000..febf3c2
--- /dev/null
+++ b/share/hybbx/service-dual.ini.example
@@ -0,0 +1,37 @@
+; MainAX25-Stack (MAX25-Stack) — service mode dual BayCom (HyBBX)
+; Prerequisite: bcpr dual max25e0:bc0/bc1 running (see docs/BAYCOM.md · stacks/max25-bcpr/)
+; Merge into Secondary hybbx.ini — unique link_id per transport on Main.
+
+[networks]
+ax25 = no
+baycom = yes
+
+; BayCom radio A — modem.a / bcsf0 / kiss-a
+[transport.baycom1]
+backend = kernel
+mode = ser12*
+interface = bcsf0
+iobase = 0x3f8
+irq = 4
+radio_baud = 1200
+txdelay = 35
+circuit_host = main.example.com
+circuit_port = 7323
+link_id = max25-baycom-a
+link_password = changeme
+frequency_mhz = 27.205
+
+; BayCom radio B — modem.b / bcsf1 / kiss-b
+[transport.baycom2]
+backend = kernel
+mode = ser12*
+interface = bcsf1
+iobase = 0xe080
+irq = 30
+radio_baud = 1200
+txdelay = 35
+circuit_host = main.example.com
+circuit_port = 7323
+link_id = max25-baycom-b
+link_password = changeme
+frequency_mhz = 27.205
diff --git a/share/hybbx/tnc2c-host.ini.example b/share/hybbx/tnc2c-host.ini.example
new file mode 100644
index 0000000..84986e3
--- /dev/null
+++ b/share/hybbx/tnc2c-host.ini.example
@@ -0,0 +1,38 @@
+; MainAX25-Stack — HyBBX Secondary host (TNC2C)
+; Merge into HyBBX hybbx.ini on Secondary node.
+; Prerequisite: max25d auto_start or ./scripts/max25-ctl start --hardware tncs --device tnc2c
+; Stack template (onboard ttyS4): stacks/tncs/hybbx-tnc2c.ini
+;
+; MAX25 owns serial recovery + KISS entry — HyBBX attach-only (kiss_entry = none).
+
+[networks]
+ax25 = yes
+
+[transport.packet_radio1]
+enabled = yes
+tnc = tnc2c
+protocol = kiss
+device_type = serial
+; Onboard UART: /dev/ttyS4 · USB host: /dev/ttyUSB0 or /dev/serial/by-id/...
+device = /dev/ttyS4
+baud = 19200
+serial_line = 8n1
+rts_dtr = yes
+kiss_entry = none
+modem = tcm3105
+radio_baud = 2400
+clock_mhz = 4.9
+radio_band = cb
+radio_duplex = half
+frequency_mhz = 27.235
+txdelay = 50
+persist = 255
+slot = 10
+circuit_host = main.example.com
+circuit_port = 7323
+link_id = max25-tnc2c
+link_password = changeme
+mycall = CB-0
+
+[max25]
+check = yes
diff --git a/share/max25-bcpr/max25-bcpr.freebsd.ini.example b/share/max25-bcpr/max25-bcpr.freebsd.ini.example
new file mode 100644
index 0000000..c16bf3a
--- /dev/null
+++ b/share/max25-bcpr/max25-bcpr.freebsd.ini.example
@@ -0,0 +1,34 @@
+; max25-bcpr — FreeBSD AM1 / ExSys OXuPCI952 (cuau2 @ 0xd090)
+; Copy: cp share/max25-bcpr/max25-bcpr.ini.example /usr/local/etc/max25/max25-bcpr.ini
+; Live: dry_run=no · max25-bcpr-ctl start --ini /usr/local/etc/max25/max25-bcpr.ini
+
+[max25-bcpr]
+dry_run = yes
+state_dir = /tmp/max25-bcpr
+; user = akb
+; group = dialer
+; ptt_wd = yes
+; ptt_wd_key_ms = 14500
+; ptt_wd_pause_ms = 500
+; txd_bias = pulse
+
+[bc0]
+enabled = yes
+serial = /dev/cuau2
+iobase = 0xd090
+irq = 36
+mode = ser12*
+kiss_link = /tmp/max25-bcpr/kiss-bc0
+baud = 1200
+tx_delay = 35
+tx_tail = 2
+slottime = 10
+ppersist = 40
+fulldup = no
+
+; [bc1]
+; enabled = no
+; serial = /dev/cuau3
+; iobase = 0xd080
+; irq = 36
+; kiss_link = /tmp/max25-bcpr/kiss-bc1
diff --git a/share/max25-bcpr/max25-bcpr.ini.example b/share/max25-bcpr/max25-bcpr.ini.example
new file mode 100644
index 0000000..b75a91a
--- /dev/null
+++ b/share/max25-bcpr/max25-bcpr.ini.example
@@ -0,0 +1,44 @@
+; max25-bcpr — BayCom/based PC-COM SER12 (userspace, MAX25)
+; Public mark: BayCom/based. Never Konverter/converter.
+; Product: BayCom/based PC-COM SER12 (userspace, MAX25). Built by default (MAX25_BUILD_MAX25_BCPR=ON).
+; Hardware: TCM3105-class AFSK modem chip (bits↔tones + PTT). Not a TNC/digi/BBX.
+; Host (max25-bcprd / max25d) owns HDLC, KISS, bit clock → device max25e0 (:bcN forks).
+; Internal C API may still use bcpr_* symbols — product face is max25-bcpr only.
+;
+; Quick start (offline, after -DMAX25_BUILD_MAX25_BCPR=ON):
+; max25-bcprd -c stacks/max25-bcpr/share/max25-bcpr.ini.example --dry-run --once
+; Live: copy to /etc/max25/max25-bcpr.ini or ./local/max25-bcpr.ini; set dry_run=no.
+
+[max25-bcpr]
+dry_run = yes
+state_dir = /tmp/max25-bcpr
+; FlexNet SER12 PTT watchdog (cal / long key): unkey ~500 ms every ~14.5 s
+; ptt_wd = yes
+; ptt_wd_key_ms = 14500
+; ptt_wd_pause_ms = 500
+; TXD pump: pulse = Sailer THR 0x00 (default); steady = UART break (TFPCX-class)
+; txd_bias = pulse
+; Software TOT (max25d [tot] syncs these on stack start; override per site if needed)
+; tot = yes
+; tot_max_key_sec = 25
+; tot_min_gap_ms = 1500
+; tot_max_consecutive = 3
+; tot_max_bursts = 8
+
+[bc0]
+enabled = yes
+serial = /dev/ttyS0
+; iobase / irq — set to real values for live use
+mode = ser12*
+kiss_link = /tmp/max25-bcpr/kiss-bc0
+baud = 1200
+tx_delay = 35
+tx_tail = 2
+slottime = 10
+ppersist = 40
+fulldup = no
+
+; [bc1]
+; enabled = no
+; serial = /dev/ttyS1
+; kiss_link = /tmp/max25-bcpr/kiss-bc1
diff --git a/share/max25/max25d.ini.example b/share/max25/max25d.ini.example
new file mode 100644
index 0000000..1cffbbc
--- /dev/null
+++ b/share/max25/max25d.ini.example
@@ -0,0 +1,46 @@
+; MAX25-Stack-Lite — one radio, one terminal (example)
+; Localhost only: max25d + max25-terminal on the SAME PC. No LAN / no remote terminal.
+; Copy: sudo cp share/max25/max25d.lite.ini.example /etc/max25/max25d.ini
+; Edit only the device path for your modem.
+
+[daemon]
+mode = standalone
+hardware = tncs
+device = tnc2c
+
+[network]
+tcp_host = 127.0.0.1
+tcp_port = 7325
+unix_socket = /run/max25/modem.sock
+; Empty = no AUTH on loopback smoke tests; set before LAN exposure
+tcp_password =
+
+[modem]
+callerid = CB-0
+callid = QST
+ax25_ui = yes
+
+[reporting]
+error_transmissions = yes
+voice_transmissions = yes
+data_passes = 3
+data_quality_min = 50
+data_pass_seconds = 20
+
+[stack]
+auto_start = yes
+serial_watch = yes
+serial_watch_interval = 60
+stack_recover_only = yes
+serial_bootwait_escalate = yes
+serial_bootwait_escalate_after = 3
+
+[devices]
+default = tnc2c
+tnc2c = /dev/ttyUSB0
+
+[serial.tnc2c]
+baud = 19200
+line = 8n1
+dtr_rts = yes
+kiss_entry = kiss_on
diff --git a/share/max25/max25d.lite.bcpr.ini.example b/share/max25/max25d.lite.bcpr.ini.example
new file mode 100644
index 0000000..792e602
--- /dev/null
+++ b/share/max25/max25d.lite.bcpr.ini.example
@@ -0,0 +1,42 @@
+; MAX25-Stack-Lite — BayCom/based PC-COM (max25-bcpr / max25e0)
+; Localhost only: max25d + max25-terminal on the SAME PC.
+; Copy: sudo cp share/max25/max25d.lite.bcpr.ini.example /etc/max25/max25d.ini
+; sudo cp stacks/max25-bcpr/share/max25-bcpr.ini.example /etc/max25/max25-bcpr.ini
+; Start bcpr first: sudo max25-bcpr-ctl start --ini /etc/max25/max25-bcpr.ini
+
+[features]
+max25_bcpr = yes
+
+[daemon]
+mode = standalone
+hardware = tncs
+device = max25e0
+
+[network]
+tcp_host = 127.0.0.1
+tcp_port = 7325
+unix_socket = /run/max25/modem.sock
+tcp_password =
+
+[modem]
+callerid = CB-0
+callid = QST
+ax25_ui = yes
+
+[reporting]
+error_transmissions = yes
+voice_transmissions = yes
+data_passes = 3
+data_quality_min = 50
+data_pass_seconds = 20
+
+[stack]
+auto_start = yes
+
+[devices]
+default = max25e0
+max25e0 = max25-bcpr:bc0
+
+[device.max25e0]
+kiss_link = /tmp/max25-bcpr/kiss-bc0
+max25_bcpr_ini = /etc/max25/max25-bcpr.ini
diff --git a/share/max25/max25d.lite.crdop.ini.example b/share/max25/max25d.lite.crdop.ini.example
new file mode 100644
index 0000000..f47cf6a
--- /dev/null
+++ b/share/max25/max25d.lite.crdop.ini.example
@@ -0,0 +1,40 @@
+; MAX25-Stack-Lite — CRDOP sound-card modem (example)
+; Localhost only: max25d, crdop, and terminal on the SAME PC.
+; Copy: sudo cp share/max25/max25d.lite.crdop.ini.example /etc/max25/max25d.ini
+; Also: cp stacks/crdop/share/crdop.ini.example ~/.config/crdop/crdop.ini
+; Wizard: ./scripts/max25-lite-setup.sh
+
+[daemon]
+mode = standalone
+hardware = soft-modems
+device = soft-crdop
+
+[network]
+tcp_host = 127.0.0.1
+tcp_port = 7325
+unix_socket = /run/max25/modem.sock
+tcp_password =
+
+[modem]
+callerid = CB-0
+callid = QST
+ax25_ui = yes
+
+[reporting]
+error_transmissions = yes
+voice_transmissions = yes
+data_passes = 3
+data_quality_min = 50
+data_pass_seconds = 20
+
+[stack]
+auto_start = yes
+
+[devices]
+default = soft-crdop
+soft-crdop = crdop:default
+
+[device.soft-crdop]
+host = 127.0.0.1
+port = 8515
+listen = yes
diff --git a/share/max25/max25d.lite.ini.example b/share/max25/max25d.lite.ini.example
new file mode 100644
index 0000000..c7c17b9
--- /dev/null
+++ b/share/max25/max25d.lite.ini.example
@@ -0,0 +1,44 @@
+; MAX25-Stack-Lite — one radio, one terminal (example)
+; Localhost only: max25d + max25-terminal on the SAME PC. No LAN / no remote terminal.
+; Copy: sudo cp share/max25/max25d.lite.ini.example /etc/max25/max25d.ini
+
+[daemon]
+mode = standalone
+hardware = tncs
+device = tnc2c
+
+[network]
+tcp_host = 127.0.0.1
+tcp_port = 7325
+unix_socket = /run/max25/modem.sock
+tcp_password =
+
+[modem]
+callerid = CB-0
+callid = QST
+ax25_ui = yes
+
+[reporting]
+error_transmissions = yes
+voice_transmissions = yes
+data_passes = 3
+data_quality_min = 50
+data_pass_seconds = 20
+
+[stack]
+auto_start = yes
+serial_watch = yes
+serial_watch_interval = 60
+stack_recover_only = yes
+serial_bootwait_escalate = yes
+serial_bootwait_escalate_after = 3
+
+[devices]
+default = tnc2c
+tnc2c = /dev/ttyUSB0
+
+[serial.tnc2c]
+baud = 19200
+line = 8n1
+dtr_rts = yes
+kiss_entry = kiss_on
diff --git a/share/max25/max25d.service.example b/share/max25/max25d.service.example
new file mode 100644
index 0000000..e3281fb
--- /dev/null
+++ b/share/max25/max25d.service.example
@@ -0,0 +1,13 @@
+[Unit]
+Description=MAX25 daemon (MainAX25-Stack)
+After=network.target
+
+[Service]
+Type=simple
+ExecStart=/usr/local/bin/max25d -c /etc/max25/max25d.ini
+Restart=on-failure
+RuntimeDirectory=max25
+# Unix socket: /run/max25/modem.sock (writable when RuntimeDirectory=max25)
+
+[Install]
+WantedBy=multi-user.target
diff --git a/share/max25/serial/README.md b/share/max25/serial/README.md
new file mode 100644
index 0000000..04c5310
--- /dev/null
+++ b/share/max25/serial/README.md
@@ -0,0 +1,22 @@
+# Per-device serial env (max25d / boot-wait)
+
+Install copies for site-specific paths:
+
+```bash
+sudo mkdir -p /etc/max25
+sudo cp share/max25/serial/tnc2c-serial.env.example /etc/max25/tnc2c-serial.env
+# edit device path and baud
+```
+
+Search order (`paths.py`): `/etc/max25/` → `local/` → `share/max25/serial/` → `stacks/tncs/`.
+
+**Other device families** use different site config (same `[devices]` pattern in `max25d.ini`):
+
+| Hardware | Site config (not serial env) |
+|----------|------------------------------|
+| BayCom/based (`max25e0` / bcpr) | `share/max25-bcpr/max25-bcpr.ini.example` → `/etc/max25/max25-bcpr.ini` |
+| CRDOP (`soft-crdop`) | `stacks/crdop/share/crdop.ini.example` |
+
+Device model overview: [../../README.md](../../README.md) (scope) · [../../stacks/max25-bcpr/README.md](../../stacks/max25-bcpr/README.md) (BayCom/based).
+
+Terminal client profiles (connection only): [`../clients/`](../clients/README.md).
diff --git a/share/max25/serial/pccom-kiss-serial.env.example b/share/max25/serial/pccom-kiss-serial.env.example
new file mode 100644
index 0000000..6fbbe00
--- /dev/null
+++ b/share/max25/serial/pccom-kiss-serial.env.example
@@ -0,0 +1,9 @@
+# PC-COM / USB KISS serial — copy to /etc/max25/pccom-kiss-serial.env
+# Used by max25d devices pccom-kiss and baycom-kiss (kiss-raw-serial backend).
+# Stable path preferred: /dev/serial/by-id/usb-...
+PCCOM_KISS_DEV=/dev/ttyUSB0
+PCCOM_KISS_BAUD=9600
+PCCOM_KISS_LINE=8n1
+PCCOM_KISS_DTR_RTS=no
+PCCOM_KISS_RADIO_BAUD=1200
+HYBBX_SERIAL_LINE=8n1
diff --git a/share/max25/serial/pktnc2-serial.env.example b/share/max25/serial/pktnc2-serial.env.example
new file mode 100644
index 0000000..ba25cc7
--- /dev/null
+++ b/share/max25/serial/pktnc2-serial.env.example
@@ -0,0 +1,5 @@
+# PK-TNC2 serial — copy to /etc/max25/pktnc2-serial.env
+# Example: onboard UART /dev/ttyS5
+PKTNC2_DEV=/dev/ttyS5
+PKTNC2_BAUD=9600
+PKTNC2_LINE=8n1
diff --git a/share/max25/serial/tnc2c-serial.env.example b/share/max25/serial/tnc2c-serial.env.example
new file mode 100644
index 0000000..10b2576
--- /dev/null
+++ b/share/max25/serial/tnc2c-serial.env.example
@@ -0,0 +1,8 @@
+# TNC2C serial — copy to /etc/max25/tnc2c-serial.env
+# Example: onboard UART /dev/ttyS4
+TNC2C_DEV=/dev/ttyS4
+TNC2C_BAUD=19200
+TNC2C_LINE=8n1
+TNC2C_RADIO_BAUD=2400
+TNC2C_MODEM=tcm3105
+HYBBX_SERIAL_LINE=8n1
diff --git a/share/systemd/README.md b/share/systemd/README.md
new file mode 100644
index 0000000..db812b8
--- /dev/null
+++ b/share/systemd/README.md
@@ -0,0 +1,9 @@
+# systemd units (product)
+
+| Unit | Role |
+|------|------|
+| `max25d.service` | Product max25d (optional example) |
+
+Experimental orch units are **not** shipped in this freigegeben tree.
+
+Install example: copy to `/etc/systemd/system/`, adjust `WorkingDirectory` / `ExecStart` / `Environment` to the site PREFIX and `./local/` INI (never commit secrets).
diff --git a/share/systemd/max25d.service b/share/systemd/max25d.service
new file mode 100644
index 0000000..b2118a0
--- /dev/null
+++ b/share/systemd/max25d.service
@@ -0,0 +1,32 @@
+# max25d — example systemd unit (adjust paths to site install)
+# Install: sudo cp share/systemd/max25d.service /etc/systemd/system/
+# sudo systemctl daemon-reload && sudo systemctl enable --now max25d.service
+#
+# Site INI — operator overrides via drop-in or EnvironmentFile under ./local/
+# (never commit secrets).
+
+[Unit]
+Description=MAX25-Stack max25d control plane
+Documentation=https://github.com/ngteq/MAX25-Stack
+After=network-online.target multi-user.target
+Wants=network-online.target
+ConditionPathExists=/usr/local/bin/run-max25d
+
+[Service]
+Type=simple
+User=root
+Group=root
+WorkingDirectory=/usr/local
+Environment=MAX25_INI=/etc/max25/max25d.ini
+EnvironmentFile=-/etc/max25/max25d.env
+ExecStart=/usr/local/bin/run-max25d -c ${MAX25_INI}
+Restart=on-failure
+RestartSec=5
+TimeoutStartSec=120
+KillMode=control-group
+StandardOutput=journal
+StandardError=journal
+SyslogIdentifier=max25d
+
+[Install]
+WantedBy=multi-user.target
diff --git a/stacks/crdop/.github/workflows/build.yml b/stacks/crdop/.github/workflows/build.yml
new file mode 100644
index 0000000..a9d6465
--- /dev/null
+++ b/stacks/crdop/.github/workflows/build.yml
@@ -0,0 +1,54 @@
+name: Build
+
+on:
+ push:
+ branches: [main, master]
+ pull_request:
+ branches: [main, master]
+
+jobs:
+ linux-gcc:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install deps
+ run: sudo apt-get update && sudo apt-get install -y build-essential cmake libasound2-dev pkg-config libcmocka-dev
+ - name: Build and test
+ run: |
+ chmod +x scripts/*.sh
+ CRDOP_BUILD_TESTS=ON CRDOP_RUN_TESTS=ON ./scripts/build-crdop.sh
+
+ linux-clang:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install deps
+ run: sudo apt-get update && sudo apt-get install -y clang cmake libasound2-dev pkg-config libcmocka-dev
+ - name: Build and test (Clang)
+ run: |
+ chmod +x scripts/*.sh
+ CC=clang CRDOP_BUILD_TESTS=ON CRDOP_RUN_TESTS=ON ./scripts/build-crdop.sh
+
+ linux-aarch64-cross:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install cross toolchain
+ run: sudo apt-get update && sudo apt-get install -y cmake gcc-aarch64-linux-gnu libasound2-dev:arm64 pkg-config
+ - name: Cross-build ARM64
+ run: |
+ chmod +x scripts/*.sh
+ CRDOP_TOOLCHAIN=aarch64-linux-gnu ./scripts/build-crdop.sh
+
+ windows-cross:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install MinGW
+ run: sudo apt-get update && sudo apt-get install -y cmake mingw-w64
+ - name: Cross-build Windows x64
+ run: |
+ chmod +x scripts/*.sh
+ CRDOP_TOOLCHAIN=x86_64-w64-mingw32 ./scripts/build-crdop.sh
+ - name: Verify exe
+ run: test -x build/crdopc.exe
diff --git a/stacks/crdop/.gitignore b/stacks/crdop/.gitignore
new file mode 100644
index 0000000..715a31d
--- /dev/null
+++ b/stacks/crdop/.gitignore
@@ -0,0 +1,13 @@
+build/
+build-*/
+*.o
+*.a
+*.so
+*.exe
+*.log
+.DS_Store
+vendor/ardopcf/
+vendor/ardopcf/.crdop-patches-applied
+
+# vault/agent — never ship
+AGENT-INDEX.md
diff --git a/stacks/crdop/CMakeLists.txt b/stacks/crdop/CMakeLists.txt
new file mode 100644
index 0000000..27ab174
--- /dev/null
+++ b/stacks/crdop/CMakeLists.txt
@@ -0,0 +1,55 @@
+cmake_minimum_required(VERSION 3.16)
+# Product label CRDOP-CUR999 (pre-release until v0.5). CMake project() requires a
+# numeric VERSION; 0.0.999 is the build-time fallback. Human-facing strings live
+# in VERSION (CUR999) and include/crdop/version.h (CRDOP-CUR999 / CUR999).
+project(crdop VERSION 0.0.999 LANGUAGES C)
+
+set(CMAKE_C_STANDARD 17)
+set(CMAKE_C_STANDARD_REQUIRED ON)
+
+include(GNUInstallDirs)
+
+if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
+ set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
+endif()
+
+option(CRDOP_VENDOR_ARDOPCF
+ "Dev-only: build legacy ARDOP (ardopcf) vendor — OFF by default; never installed in MAX25 releases"
+ OFF)
+
+set(CRDOP_ROOT "${CMAKE_CURRENT_SOURCE_DIR}")
+set(VENDOR_DIR "${CRDOP_ROOT}/vendor/ardopcf")
+
+function(crdop_install_scaffold)
+ install(FILES
+ "${CRDOP_ROOT}/share/crdop.ini.example"
+ "${CRDOP_ROOT}/share/crdop-dual.ini.example"
+ "${CRDOP_ROOT}/share/crdop-amateur.ini.example"
+ "${CRDOP_ROOT}/share/crdop.freebsd.ini.example"
+ DESTINATION ${CMAKE_INSTALL_DATADIR}/crdop
+ )
+ install(FILES "${CRDOP_ROOT}/VERSION" DESTINATION ${CMAKE_INSTALL_DATADIR}/crdop)
+ install(PROGRAMS "${CRDOP_ROOT}/scripts/crdopc"
+ DESTINATION ${CMAKE_INSTALL_BINDIR} RENAME crdop)
+ install(PROGRAMS
+ "${CRDOP_ROOT}/tools/max25-signal-sniffer"
+ "${CRDOP_ROOT}/tools/audio-dummyd"
+ DESTINATION ${CMAKE_INSTALL_BINDIR}
+ )
+ install(DIRECTORY "${CRDOP_ROOT}/lib/"
+ DESTINATION ${CMAKE_INSTALL_DATADIR}/crdop/lib
+ FILES_MATCHING PATTERN "*.py")
+endfunction()
+
+if(CRDOP_VENDOR_ARDOPCF AND EXISTS "${VENDOR_DIR}/Makefile")
+ include("${CRDOP_ROOT}/cmake/CRDOPVendorArdopcf.cmake")
+ crdop_install_scaffold()
+ message(STATUS "CRDOP ${PROJECT_VERSION} → crdopc (legacy vendor/ardopcf)")
+elseif(CRDOP_VENDOR_ARDOPCF)
+ message(WARNING "CRDOP: CRDOP_VENDOR_ARDOPCF=ON but vendor/ardopcf missing — scaffold only")
+ crdop_install_scaffold()
+ message(STATUS "CRDOP: scaffold only (native modem in development)")
+else()
+ crdop_install_scaffold()
+ message(STATUS "CRDOP: scaffold only (native Eigenentwicklung; -DCRDOP_VENDOR_ARDOPCF=ON for legacy vendor build)")
+endif()
diff --git a/stacks/crdop/LICENSE b/stacks/crdop/LICENSE
new file mode 100644
index 0000000..3488377
--- /dev/null
+++ b/stacks/crdop/LICENSE
@@ -0,0 +1,22 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ CRDOP (MAX25-SoftModem) — subproject of MainAX25-Stack (MAX25-Stack)
+ Copyright (C) 2026 ngteq
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+---
+
+Full license text: [../../LICENSE](../../LICENSE) (repository root).
diff --git a/stacks/crdop/NOTICE.md b/stacks/crdop/NOTICE.md
new file mode 100644
index 0000000..43c302f
--- /dev/null
+++ b/stacks/crdop/NOTICE.md
@@ -0,0 +1,9 @@
+# Third-party notices
+
+**CRDOP (MAX25-SoftModem)** — Copyright (c) 2026 ngteq, **GNU GPL v3** ([LICENSE](LICENSE), [../../LICENSE](../../LICENSE))
+
+In-house sound-card modem subproject of MainAX25-Stack. Sound-card based; kernel ALSA via MAX25 sound-proxy. Project rule: [docs/CRDOP.md](../../docs/CRDOP.md).
+
+Operators must comply with local radio regulations.
+
+Third-party **ARDOP** host software (optional, separate from CRDOP): [plugins/external/ardop/README.md](../../plugins/external/ardop/README.md).
diff --git a/stacks/crdop/README.md b/stacks/crdop/README.md
new file mode 100644
index 0000000..aeb701d
--- /dev/null
+++ b/stacks/crdop/README.md
@@ -0,0 +1,36 @@
+# CRDOP — MAX25-SoftModem
+
+**CRDOP** = stack acronym for **MAX25-SoftModem** (device id `soft-crdop`).
+
+Sound-card software modem subproject of MAX25-Stack.
+
+**Stack version:** MAX25-Stack **v1.0.0** · **Dev track:** `CUR999` in `$SRC/stacks/crdop/VERSION`.
+
+**Canonical project rule:** [docs/CRDOP.md](../../docs/CRDOP.md)
+**License:** GNU GPL v3 — [LICENSE](LICENSE)
+
+## Summary
+
+| | |
+|---|---|
+| Type | MAX25-Stack **subproject** (`stacks/crdop/`) |
+| Interface | Sound card IN/OUT → kernel ALSA → MAX25 sound-proxy |
+| Goal | **AX.25 / KISS / TNC / modem compatible** software modem |
+| Mission | **Hardware solutions** (primary) + **pure software** on computers |
+| Build | Default ON (`MAX25_BUILD_CRDOP=ON`) |
+
+Read [docs/CRDOP.md](../../docs/CRDOP.md) before changing modem, audio, or plugin code.
+
+## Technical docs
+
+**Index:** [docs/INDEX.md](docs/INDEX.md)
+
+| Doc | Topic |
+|-----|--------|
+| [docs/MAX25-USAGE.md](docs/MAX25-USAGE.md) | MAX25-Stack operator guide |
+| [docs/DEVELOPER.md](docs/DEVELOPER.md) | Source tree, modules, tests |
+| [docs/HARDWARE-INTERFACE.md](docs/HARDWARE-INTERFACE.md) | Generic radio interface spec |
+| [docs/SOFTMODEM.md](docs/SOFTMODEM.md) | Baud, duplex, acoustic AX.25 |
+| [docs/AUDIO-ARCHITECTURE.md](docs/AUDIO-ARCHITECTURE.md) | Kernel ALSA only, no PulseAudio |
+| [docs/CONFIG.md](docs/CONFIG.md) | INI / launcher |
+| [ROADMAP.md](ROADMAP.md) | P0/P1/P2 milestones |
diff --git a/stacks/crdop/ROADMAP.md b/stacks/crdop/ROADMAP.md
new file mode 100644
index 0000000..0ae1aa4
--- /dev/null
+++ b/stacks/crdop/ROADMAP.md
@@ -0,0 +1,445 @@
+# MAX25-SoftModem (CRDOP) — development roadmap
+
+> **Static rule:** **CRDOP** = stack acronym for **MAX25-SoftModem** (device id `soft-crdop`). Authoritative everywhere CRDOP is named in MAX25-Stack.
+
+**Stack version:** MAX25-Stack **v1.0.0** · **Dev track:** `CUR999` in `$SRC/stacks/crdop/VERSION`
+
+**Stack dependency:** CRDOP sources live in `stacks/crdop/` and are **standalone-capable** in principle, but **MAX25-Stack is required for integration** (build, `max25d`, INI, operator tooling) until **CRDOP-v1.0.0** (or later) marks a mature standalone release. Current stack product: **MAX25-Stack-v1.0.0**.
+
+**Nature:** MAX25 in-house development — open development and test program for the kernel-ALSA sound-card modem. CRDOP is the standard modem; ARDOP is a separate optional plugin.
+
+**Research synthesis:** 2026-07-13 — modem and integration priorities synthesized into this ROADMAP and linked in-repo docs below. **No duplicate re-copy** of external notes into this tree.
+
+---
+
+## Protocol taxonomy (research catalog)
+
+| Class | Description | CRDOP relation |
+|-------|-------------|----------------|
+| **A** | Classic AX.25 packet (HDLC, frequency-toggle line code) | **P0/P1/P2 targets** — 1200 AFSK, G3RUH, 300 HF |
+| **B** | AX.25 + FEC extension | **Reference** — FX.25 informs FEC block design |
+| **C** | New L2 (not AX.25 on-air) | **Reference** — IL2P lessons (no bit-stuffing, RS FEC) |
+| **D** | HF/VHF data modems (Winlink ecosystem) | **Out of scope** on-air — separate ARDOP-plugin registry |
+| **E** | Keyboard/chat modes (PSK31, RTTY, …) | **Out of scope** — boundary only |
+
+---
+
+## MAX25-Stack integration (until CRDOP-v1.0.0+)
+
+| Topic | Policy |
+|-------|--------|
+| **Source tree** | All CRDOP sources in `stacks/crdop/` — standalone CMake possible; **operator integration via MAX25-Stack** until mature **CRDOP-v1.0.0** (or later) |
+| **Build** | `MAX25_BUILD_CRDOP=ON` default — installs launcher, `lib/`, tools, INI |
+| **Daemon** | `max25d` → `CrdopTcpBackend` / `AudioDummyBackend` — see [docs/MAX25-USAGE.md](docs/MAX25-USAGE.md) |
+| **AX.25 runtime** | Native `ax25_codec.py` in MAX25 — no libax25 bundle in releases |
+| **HyBBX** | External consumer — serial ownership boundary; CRDOP via host TCP attach |
+| **Docs entry** | [docs/INDEX.md](docs/INDEX.md) · project rule [docs/CRDOP.md](../../docs/CRDOP.md) |
+
+HyBBX boundary: [docs/HYBBX.md](../../docs/HYBBX.md) · [docs/PLUGINS-DEVICE-MODEL.md](../../docs/PLUGINS-DEVICE-MODEL.md)
+
+---
+
+## MAX25-Stack DEV-Level gate
+
+> Stack DEV-Levels are approximate (*ca.*) — see [docs/V2.0.0-SCOPE.md](../../docs/V2.0.0-SCOPE.md#dev-levels-roadmap-stack-wide). CRDOP modulation milestones (P0/P1/P2 below) are **subproject delivery** labels, not stack DEV-Levels.
+
+**DEV-Level 1 (*ca.* current — stack priority):** modular TCP/IP + Linux/FreeBSD platform compat. CRDOP work in DEV-Level 1 is **minimal/native only** — existing backend, bench DSP, enough OSS path for FreeBSD TCP/IP hub. **Do not expand CRDOP scope** ahead of DEV-Level 1.
+
+**DEV-Level 4 (*ca.*):** CRDOP expansion (OSS polish, G3RUH, acoustic validation, hardware docs). Modulation milestones below remain valid but are **scheduled for DEV-Level 4** unless required to unblock DEV-Level 1 platform work.
+
+**WebSocket** is **DEV-Level 3** — separate from CRDOP expansion.
+
+Stack roadmap: [docs/V2.0.0-SCOPE.md](../../docs/V2.0.0-SCOPE.md#dev-levels-roadmap-stack-wide).
+
+---
+
+## Current phase: development and test
+
+| Area | Status |
+|------|--------|
+| MAX25-Stack standard build/install (`MAX25_BUILD_CRDOP=ON`) | **Done** |
+| `max25d` `crdop-tcp` backend + offline tests | **Done** |
+| INI scaffold, launcher (`scripts/crdopc`), HyBBX attach examples | **Done** |
+| Native modem DSP — 1200 baud Bell 202 AFSK (`stacks/crdop/lib/`) | **In progress** |
+| Acoustic AX.25 validation vs reference 1200 AFSK paths | **In progress** |
+| Half- and full-duplex operator paths + FEC strategy | **In progress** |
+| Open hardware interface documentation | **In progress** |
+| G3RUH 9600–19200 (direct FSK rig path) | **Planned** (DEV-Level 4 / P1) |
+| 300 baud HF AFSK | **Optional** (P2) |
+| Speeds >19200 baud | **Out of scope** |
+
+---
+
+## Modulation priority matrix
+
+Synthesized from research catalog — implementation order for CRDOP native DSP.
+
+| Priority | Mode | Modulation | Baud | RF path | Bands | CRDOP target |
+|----------|------|------------|------|---------|-------|--------------|
+| **P0** | AX.25 Packet | **AFSK Bell 202** (1200/2200 Hz) | **1200** | Audio AFSK (Mic/Spkr/Line) | **CB, VHF/UHF** | **Primary on-air** |
+| **P0** | FEC + duplex profiles | Short frames, frequent TX | 1200 | same | CB, VHF | **Strategy** (see below) |
+| **P1** | AX.25 Packet | **G3RUH FSK/GMSK** (scrambled baseband) | **9600–19200** | **Direct FSK** (varactor + discriminator) | VHF/UHF backbone | **Hard max 19200** |
+| **P2** | AX.25 Packet | AFSK/FSK tone pairs | **300** | SSB audio (USB/LSB) | HF | **Optional** |
+| — | 2400/4800 AFSK | V.26/V.27 class | 2400–4800 | Audio | VHF | Low priority incremental |
+| 📋 | FX.25 / IL2P | FEC wrappers / new L2 | 1200+ | Audio or FSK PHY | VHF/HF | **Reference only** — inform FEC design |
+| ❌ | VARA / PACTOR / WINMOR | Proprietary DSP | various | Audio / SSB / hardware | HF/VHF/CB | **Out of scope** |
+| 📋 | ARDOP | 4FSK/PSK/QAM Winlink modes | adaptiv | Soundcard SSB | HF/VHF | **Separate plugin** — not CRDOP |
+
+**Rule:** CRDOP on-air goal is **AX.25-compatible PHY + framing** at P0/P1/P2. Winlink-class protocols (VARA, PACTOR, ARDOP OTA) are **not** CRDOP targets.
+
+---
+
+## RF path matrix
+
+What CRDOP implements per physical interface — hardware choice drives baud ceiling.
+
+| RF path | Connection | Practical max baud | CB | Amateur (VHF/UHF) | Amateur (HF) | CRDOP milestone |
+|---------|------------|-------------------|-----|-------------------|--------------|-----------------|
+| **Audio AFSK** | Mic IN / Speaker OUT / Line | ~4800 theory; **1200 field** | ✅ K24/K25 | ✅ APRS, digipeater access | — | **P0 bench–on-air** |
+| **Audio wide** | Line, de-emphasis off | up to ~9600 (difficult) | rare | experimental | — | not primary |
+| **Direct FSK** | Varactor TX + FM discriminator RX | **9600–19200** | ❌ | ✅ backbone, sat | ❌ | **P1** (DEV-Level 4) |
+| **SSB audio** | USB/LSB suppressed carrier | **300** packet; kHz-BW modes separate | ❌ | 10 m APRS exception | ✅ | **P2 optional** |
+
+**CB note:** 9600+ G3RUH requires direct RF modulation and wide IF — standard CB FM rigs are **not** 9k6-ready. CRDOP does **not** target high baud on 27 MHz.
+
+---
+
+## Band context
+
+### CB (27 MHz CEPT)
+
+| Item | Detail |
+|------|--------|
+| **Primary channel** | **K24** (27.235 MHz) — Packet QSO, APRS-like traffic |
+| **Net / forward** | **K25** (27.245 MHz) — nets, data traffic; proprietary FM-narrow DSP modes appear in practice (reference only — not CRDOP OTA) |
+| **Legal (brief)** | EN 300 433 / ECC (11)03 — voice service primary; FM max **4 W** ERP typical; **data tolerated or explicit** on K24/K25 in several EU administrations. Operator responsibility. |
+| **CRDOP mode** | **1200 AFSK Bell 202**, half-duplex, `persist=255`, short UI frames |
+| **Why not 9600+** | CB rigs lack direct FSK port and wide-IF path; FM voice channel ≈3 kHz — fits 1200/2200 Hz, not G3RUH |
+
+### VHF/UHF (2 m, 70 cm, …)
+
+| Application | Baud | Modulation | CRDOP |
+|-------------|------|------------|-------|
+| **APRS**, packet BBS, digipeater **access** | 1200 | AFSK Bell 202 | **P0** — acoustic/line |
+| Digipeater **backbone**, satellite | 9600–19200 | G3RUH FSK | **P1** — requires data-port radio |
+| IL2P | 1200/2400 | BPSK/QPSK audio | 📋 reference |
+| FX.25 | 1200+ | AX.25 + RS FEC wrapper | 📋 reference for FEC block sizing |
+
+### HF (160 m – 10 m)
+
+| Item | Detail |
+|------|--------|
+| **Standard packet** | **300 baud** AFSK/FSK, **200 Hz shift** |
+| **Common tone pairs** | 1600/1800 Hz (common default), 2110/2310 Hz, others — dial frequency must match pair |
+| **10 m exception** | 1200 AFSK on FM/USB for APRS — same PHY as VHF |
+| **Winlink boundary** | Proprietary HF mail modems = **separate protocol ecosystems** — not CRDOP OTA goals |
+| **CRDOP** | **P2 optional** — 300 bd after P0/P1 stable; SSB USB/LSB audio path |
+
+### Satellite / space (reference)
+
+| Application | Baud | Modulation | CRDOP |
+|-------------|------|------------|-------|
+| Legacy FM packet | 1200 | AFSK Bell 202 | P0 PHY same |
+| Pacsat / wide-IF links | 9600–38400+ | G3RUH FSK/GMSK | P1 extended; >19200 out of scope |
+
+---
+
+## Layer model
+
+CRDOP splits **host integration** from **on-air PHY**. Code mapping to `stacks/crdop/lib/`:
+
+```
+Application (APRS, BBS, HyBBX, max25-terminal)
+ ↓
+AX.25 v2.0 (UI / I-frames) ← stacks/daemon/ax25_codec.py
+ ↓
+HDLC + frequency-toggle + CRC-16 ← hdlc_codec.py (+ bell202_line_code.py)
+ ↓
+Modem PHY (AFSK / future G3RUH) ← afsk_modulator.py, afsk_demodulator.py
+ ↓
+MAX25 sound-proxy → kernel ALSA ← sound_proxy.py (+ include/crdop/sound_proxy.h scaffold)
+ ↓
+Radio (FM audio / direct FSK / SSB)
+```
+
+| Layer | Responsibility | Module / tool |
+|-------|----------------|---------------|
+| **L2 AX.25** | Addresses, UI body, FCS semantics | `ax25_codec.py` (daemon), used by `acoustic_engine.py` |
+| **Framing** | HDLC flags `0x7E`, bit-stuffing, CRC-16-CCITT | `hdlc_codec.py` |
+| **Line code** | Bell 202 frequency-toggle (bit 0 → tone change) | `bell202_line_code.py` |
+| **PHY 1200** | Continuous-phase AFSK 1200/2200 Hz, Goertzel demod | `afsk_modulator.py`, `afsk_demodulator.py` |
+| **PHY 9k6+** | G3RUH scrambler + FSK — **not yet in lib/** | planned P1 (DEV-Level 4) |
+| **PHY 300 HF** | 200 Hz shift tone pairs — **not yet in lib/** | planned P2 optional |
+| **Audio I/O** | ALSA capture/playback, no PulseAudio | `sound_proxy.py` |
+| **Bench integration** | Loopback encode/decode, sniffer | `acoustic_engine.py` |
+| **Host TCP** | M25-family ctrl + KISS-semantics data | `m25_host_protocol.py` → `audio-dummyd` |
+| **Operator tools** | Sniff, calibrate mark/space | `tools/max25-signal-sniffer` |
+
+**Host vs on-air:** `m25_host_protocol.py` carries AX.25 UI **without** HDLC on the data port; `acoustic_engine.encode_ax25_ui()` builds full on-air HDLC + AFSK. Do not confuse MAX25 TCP host with ARDOP FEC/ARQ wire modes.
+
+---
+
+## FEC, duplex & retransmission strategy
+
+**Leitprinzip:** *Lieber öfter senden als oft lange.*
+
+Packet on CB/VHF (poor SNR, QRM, half-duplex collisions) favours **short UI frames** and **frequent repetition** over long payloads and heavy block FEC.
+
+### Half-duplex (CB default)
+
+| Aspect | Strategy |
+|--------|----------|
+| Channel access | CSMA + persist — CB: **`persist=255`** |
+| TX slot | Short UI frames; minimal but stable TXDELAY/TXTAIL |
+| FEC | Shorter codewords; optional 2–3 repeats vs one large block |
+| Retransmit | Application/beacon: **send more often** with small payloads (≤128 B info) |
+| PTT | Explicit — VOX off on CB |
+| INI | `duplex = half`, `EXTRADELAY 150` (CB profile) |
+
+### Full-duplex
+
+| Aspect | Strategy |
+|--------|----------|
+| Channel | Simultaneous TX/RX — no CSMA |
+| FEC | Stronger codes possible when bandwidth allows |
+| Audio | Echo suppression / separate paths — full-duplex sound card |
+| INI | `duplex = full`, `EXTRADELAY 0` |
+
+### FEC layers
+
+| Level | Mechanism | CRDOP status |
+|-------|-----------|--------------|
+| **L1 modem** | CRC-16-CCITT (mandatory), preamble sync | **Done** in `hdlc_codec.py` |
+| **L1 optional** | Lightweight FEC for short blocks | **Planned** — offline AWGN simulator |
+| **L2 AX.25** | UI = no ARQ; I-frames = host may ARQ | Host policy |
+| **Application** | Frequent beacons, staggered dual-TNC | HyBBX / max25d INI |
+| **Reference** | FX.25 (RS wrapper), IL2P (new L2) | Inform design; not mandatory on-air |
+
+### FEC design takeaways (see [docs/FEC-SPEC.md](docs/FEC-SPEC.md))
+
+| Source | Lesson for CRDOP |
+|--------|------------------|
+| **FX.25** | Reed-Solomon FEC **wrapper** around AX.25 — backward-compatible idea; AX.25 bit-stuffing weakens naive FEC |
+| **IL2P** | Eliminates bit-stuffing; packet-sync LFSR scramble; RS FEC — **reference** for future optional L2, not P0 on-air |
+| **Operator** | UI frames have no L2-ARQ — reliability via **short payloads + frequent TX** before heavy block FEC |
+| **VARA / IL2P DSP** | Multi-stage FEC and adaptive rate — **reference for DSP strategy only**, not protocol target |
+
+### Host-protocol alignment
+
+| Component | Status |
+|-----------|--------|
+| Spec | **[HOST-PROTOCOL-SPEC.md](docs/HOST-PROTOCOL-SPEC.md)** — frozen v1.0.0 |
+| `max25d CrdopTcpBackend` (default) | `PROTOCOLMODE KISS`, `[CRDOP AX25 UI …]` display |
+| `m25_host_protocol.py` | Defaults **8515/8516** — matches `crdopc` INI |
+| `audio-dummyd` bench | Same ports via CLI; `audio-dummy` max25d **host** mode may use **8520** when :8515 busy |
+
+---
+
+## Implementation status (`stacks/crdop/lib/`)
+
+Honest mapping — 2026-07-13.
+
+| Component | File / tool | Status | Notes |
+|-----------|-------------|--------|-------|
+| Bell 202 line code | `bell202_line_code.py` | **Done** | Mark 1200 / Space 2200; frequency-toggle encode/decode |
+| Line code tests | `test_bell202_line_code.py` | **Done** | Round-trip unit tests |
+| AFSK modulator | `afsk_modulator.py` | **Done** | Continuous-phase PCM @ 48 kHz default |
+| AFSK demodulator | `afsk_demodulator.py` | **Done** | Per-symbol Goertzel demod |
+| HDLC + CRC | `hdlc_codec.py` | **Done** | Flags, bit-stuff, CRC-16 via `ax25_crc` |
+| Sound-proxy (Python) | `sound_proxy.py` | **In progress** | `arecord`/`aplay` subprocess path; C header scaffold separate |
+| Acoustic bench engine | `acoustic_engine.py` | **In progress** | Full loopback UI→HDLC→AFSK→demod→parse; RF path not closed |
+| M25 host protocol | `m25_host_protocol.py` | **In progress** | TCP ctrl/data for bench; port alignment with `max25d` pending |
+| `audio-dummyd` | `tools/audio-dummyd.py` | **In progress** | M25 host + `AcousticEngine`; default launcher target |
+| `max25-signal-sniffer` | `tools/max25-signal-sniffer.py` | **In progress** | ALSA/WAV/loopback Bell 202 analysis |
+| G3RUH 9600–19200 | — | **Planned** | No module yet; direct FSK radio required |
+| 300 HF AFSK | — | **Planned** | Optional P2 |
+| FEC codec (short blocks) | — | **Planned** | Offline inject-bit-error tests first |
+| `speed_baud` INI → launcher | — | **Not implemented** | Research gap |
+| Native C `crdopc` DSP binary | `include/crdop/` | **Scaffold** | Python lib leads; C port follows validation |
+| RF automated CI | — | **Planned** | Manual acoustic/TNC tests today |
+
+---
+
+## Milestones — modulation delivery (P0 / P1 / P2)
+
+> CRDOP subproject milestones — distinct from stack [DEV-Levels](../../docs/V2.0.0-SCOPE.md#dev-levels-roadmap-stack-wide). P0/P1/P2 work lands primarily in **DEV-Level 4** except bench/minimal path in DEV-Level 1.
+
+### P0 bench — Acoustic bench (current)
+
+**Goal:** Prove Bell 202 + HDLC + AX.25 UI in software loopback and ALSA bench — no RF required.
+
+| Deliverable | Status |
+|-------------|--------|
+| `bell202_line_code` + `afsk_*` + `hdlc_codec` unit path | Done |
+| `acoustic_engine.loopback_self_test()` | Done |
+| `max25-signal-sniffer --loopback` | In progress |
+| `audio-dummyd` + `audio-dummy` max25d device | In progress |
+| Document acceptable sound cards (community list) | Planned |
+| Host protocol single definition (launcher ↔ max25d) | Planned |
+
+**Exit criteria:** Stable loopback decode; sniffer reports AX.25 UI from generated PCM; `test_crdop_backend.py` green.
+
+### P0 on-air — CB/VHF 1200 on-air
+
+**Goal:** Field-validated 1200 AFSK interoperable with standard AX.25 packet peers on CB and VHF APRS-class paths.
+
+| Deliverable | Status |
+|-------------|--------|
+| Acoustic and line-level coupling validation | In progress |
+| Line interface to FM rig (CB channels) | Planned |
+| Half-duplex PTT timing in INI + launcher | In progress |
+| FEC strategy: short frames, beacon stagger | In progress |
+| Hardware interface guide (generic spec) | In progress — [docs/HARDWARE-INTERFACE.md](docs/HARDWARE-INTERFACE.md) |
+| Decode parity vs reference 1200 AFSK captures | Planned |
+
+**Exit criteria:** Verified AX.25 UI exchange with at least one reference 1200 AFSK path (acoustic or line-level).
+
+### P1 — G3RUH 9600–19200
+
+**Goal:** Direct-FSK VHF/UHF backbone rates up to **19200 baud** hard maximum.
+
+| Deliverable | Status |
+|-------------|--------|
+| G3RUH scrambler + FSK mod/demod module | Planned |
+| Data-port radio integration docs | Planned |
+| Wide-IF rig requirements checklist | Planned |
+| Loopback with recorded IQ/baseband fixtures | Planned |
+
+**Prerequisite:** P0 on-air stable; radio with suitable data port and wide-IF path for direct FSK.
+
+**Exit criteria:** Scrambler-compatible exchange with G3RUH reference captures or peer at 9600 minimum.
+
+### P2 — HF 300 baud (optional)
+
+**Goal:** SSB USB/LSB 300 bd packet — 1600/1800 Hz pair first.
+
+| Deliverable | Status |
+|-------------|--------|
+| 300 bd demod/mod (200 Hz shift) | Planned |
+| Tone-pair INI selection | Planned |
+| HF dial-frequency calculator notes | Planned |
+
+**Scope:** Optional; does not block CRDOP-v0.5 if deferred.
+
+---
+
+## Validation targets (generic)
+
+Interop validation uses **reference signal classes**, not product endorsements.
+
+| Reference class | Mode | Interface | CRDOP milestone |
+|-----------------|------|-----------|-----------------|
+| **1200 AFSK hardware modem** | Bell 202 | Serial KISS or acoustic | P0 bench–on-air |
+| **Kernel bit-bang modem** | 1200 AFSK | Parallel/serial | P0 bench tone reference |
+| **Mobile KISS → audio** | 1200 AFSK | Bluetooth/audio bridge | P0 on-air mobile |
+| **G3RUH FSK modem** | 4800–19200 | Direct FSK data port | P1 golden reference |
+| **Multi-mode HF TNC** | 300 HF + 1200 VHF | Multi-mode serial | P2 optional |
+| **Sound-card reference decoder** | 300–9600+ | ALSA | Software decode parity (1200) |
+
+Open-hardware documentation: [docs/HARDWARE-INTERFACE.md](docs/HARDWARE-INTERFACE.md). Audio QA: [docs/SOUNDCARD-QUALIFICATION.md](docs/SOUNDCARD-QUALIFICATION.md).
+
+---
+
+## Technical goals (unchanged intent)
+
+1. **1200 baud Bell 202** — reference AX.25 AFSK; primary on-air target (P0).
+2. **Acoustic compatibility** — verifiable against standard 1200 AFSK tone paths.
+3. **Hardware modem parity** — sound IN/OUT + radio usable like serial/KISS hardware in `max25d`.
+4. **Documented DIY hardware** — generic interface spec for builders ([HARDWARE-INTERFACE.md](docs/HARDWARE-INTERFACE.md)).
+5. **Good sound card enforcement** — kernel ALSA direct; MAX25 sound-proxy API; requirements scale with baud.
+6. **G3RUH to 19200** — incremental after 1200 stable (P1).
+7. **FEC via operator strategy** — frequent short sends before heavy on-air FEC (P0).
+
+---
+
+## Not in MAX25 releases
+
+| Item | Policy |
+|------|--------|
+| `vendor/ardopcf` dev tree | Local dev only (`CRDOP_VENDOR_ARDOPCF=ON`); not in release install |
+| **libax25 / ax25-tools / ax25-apps** bundles | Reference tarballs only — not shipped as CRDOP dependency |
+| VARA / PACTOR / WINMOR implementations | Out of scope |
+| Speeds **>19200 baud** | Out of scope (current phase) |
+
+---
+
+## Next release checklist (CRDOP-v0.5 target)
+
+- [ ] Loopback + acoustic bench test automation in CI
+- [ ] Published acceptable sound-card list (community + lab)
+- [ ] Native `crdopc` / `audio-dummyd` path unified in default build
+- [ ] Field validation on CB reference channel (K24)
+- [ ] Hardware interface guide — [docs/HARDWARE-INTERFACE.md](docs/HARDWARE-INTERFACE.md)
+- [ ] Host protocol alignment (`m25_host_protocol` ↔ `max25d` backend)
+- [ ] `speed_baud` INI wired to launcher
+
+---
+
+## Research synthesis (document map)
+
+Synthesis **2026-07-13** — topics below are covered in this ROADMAP and in-repo CRDOP docs. Site-specific operator INI and lab hardware runbooks stay outside this tree.
+
+| Source topic | Synthesized in this ROADMAP |
+|----------------|----------------------------|
+| `reference/modems-packet-radio/MASTER-SOLUTIONS-CATALOG.md` | Taxonomy, baud table, RF matrix, CRDOP priorities |
+| `cb-packet-solutions.md` | CB band context, K24/K25, legal, 1200-only rationale |
+| `amateur-vhf-uhf-solutions.md` | VHF/UHF table, APRS, G3RUH backbone |
+| `amateur-hf-packet-solutions.md` | HF 300 bd, Winlink boundary |
+| `l2-fec-protocols.md` | FEC layers, FX.25/IL2P takeaways |
+| `modern-softmodems.md` | Class D out-of-scope, decision matrix (generic) |
+| `bell-202-afsk-1200.md` | P0 PHY parameters, P0 bench–on-air |
+| `g3ruh-fsk-9600-19200.md` | P1 direct FSK, DEV-Level 4 |
+| `hf-300-baud-afsk.md` | P2 tone pairs, SSB path |
+| `nrzi-hdlc-ax25-layers.md` | Layer model + `lib/` mapping |
+| `MODULATION-REFERENCE.md` | Modulation priority matrix |
+| `soundcard-requirements.md` | ALSA requirements, baud scaling |
+| `acoustic-coupling-hardware.md` | → [HARDWARE-INTERFACE.md](docs/HARDWARE-INTERFACE.md) |
+| `projects/max25-stack/2026-07-13-crdop-development-master.md` | Product scope, MAX25 integration |
+| `projects/max25-stack/2026-07-13-crdop-fec-duplex-strategy.md` | FEC/duplex section |
+| `reference/2026-07-12-cb-eu-packet.md` | CB legal/channel detail |
+| `reference/2026-07-12-packet-radio-fundamentals.md` | Background — no CRDOP code impact |
+| `reference/2026-07-12-modems-linux.md` | Linux sound-modem landscape — reference |
+| `reference/2026-07-12-linux-ax25-ecosystem.md` | Kernel AX.25 deprecation context |
+| `projects/integration/2026-07-12-max25-hybbx-boundary-final.md` | HyBBX attach / serial ownership |
+| `DEVELOPMENT.md` (operator P0–P2) | Parallel to stack release — see [docs/DEVELOPER.md](docs/DEVELOPER.md) |
+
+**Not in CRDOP tree:** site TNC hardware runbooks, production INI, serial acceptance logs — operator-local only.
+
+### Scenario → CRDOP mode (generic, from research)
+
+| Scenario | CRDOP mode |
+|----------|------------|
+| CB packet QSO (FM audio) | **P0** — 1200 AFSK AX.25 |
+| CB net with heavy QRM | **P0** + short-frame FEC strategy; proprietary FM-narrow DSP = separate protocol |
+| VHF APRS / digipeater access | **P0** — 1200 AFSK |
+| VHF/UHF backbone | **P1** — G3RUH direct FSK (≤19200) |
+| HF packet 300 bd | **P2** optional |
+| HF mail / Winlink-class | **Out of scope** — separate protocols |
+| MAX25 RF broadcast / soft-modem path | **P0** target — native CRDOP in stack |
+
+---
+
+## In-repo technical docs
+
+| Document | Topic |
+|----------|--------|
+| [docs/INDEX.md](docs/INDEX.md) | Documentation index |
+| [docs/SOFTMODEM.md](docs/SOFTMODEM.md) | Product definition |
+| [docs/MAX25-USAGE.md](docs/MAX25-USAGE.md) | MAX25-Stack operator guide |
+| [docs/DEVELOPER.md](docs/DEVELOPER.md) | Developer guide |
+| [docs/HARDWARE-INTERFACE.md](docs/HARDWARE-INTERFACE.md) | Generic radio interface spec |
+| [docs/LICENSE-USAGE.md](docs/LICENSE-USAGE.md) | GPLv3 private + commercial |
+| [docs/HOST-PROTOCOL-SPEC.md](../stacks/crdop/docs/HOST-PROTOCOL-SPEC.md) | M25 host wire (frozen) |
+| [docs/ACOUSTIC-TEST-PROTOCOL.md](../stacks/crdop/docs/ACOUSTIC-TEST-PROTOCOL.md) | P0 bench |
+| [docs/FEC-SPEC.md](../stacks/crdop/docs/FEC-SPEC.md) | FEC/duplex parameters |
+| [docs/SOUNDCARD-QUALIFICATION.md](../stacks/crdop/docs/SOUNDCARD-QUALIFICATION.md) | Audio interface QA |
+| [docs/G3RUH-DESIGN.md](../stacks/crdop/docs/G3RUH-DESIGN.md) | P1 — 9600–19200 direct FSK |
+| [docs/CRDOP.md](../../docs/CRDOP.md) | Static project rule |
+| [docs/AUDIO-ARCHITECTURE.md](docs/AUDIO-ARCHITECTURE.md) | Kernel ALSA, sound-proxy |
+| [docs/PROTOCOL.md](docs/PROTOCOL.md) | Host TCP interface |
+| [docs/CONFIG.md](docs/CONFIG.md) | INI keys |
+
+---
+
+*Roadmap synthesized 2026-07-13 from modem catalog and MAX25 integration notes. Update when DEV-Level gates close or modulation priorities change.*
+.*
diff --git a/stacks/crdop/VERSION b/stacks/crdop/VERSION
new file mode 100644
index 0000000..2f5e876
--- /dev/null
+++ b/stacks/crdop/VERSION
@@ -0,0 +1 @@
+CUR999
diff --git a/stacks/crdop/cmake/CRDOPCompiler.cmake b/stacks/crdop/cmake/CRDOPCompiler.cmake
new file mode 100644
index 0000000..e1aab37
--- /dev/null
+++ b/stacks/crdop/cmake/CRDOPCompiler.cmake
@@ -0,0 +1,37 @@
+# CRDOP compiler detection and warning flags (GCC, Clang, MSVC)
+
+if(CMAKE_C_COMPILER_ID STREQUAL "GNU")
+ set(CRDOP_COMPILER "gcc")
+elseif(CMAKE_C_COMPILER_ID MATCHES "Clang|AppleClang")
+ set(CRDOP_COMPILER "clang")
+elseif(MSVC)
+ set(CRDOP_COMPILER "msvc")
+else()
+ set(CRDOP_COMPILER "${CMAKE_C_COMPILER_ID}")
+endif()
+
+message(STATUS "CRDOP compiler=${CRDOP_COMPILER} (${CMAKE_C_COMPILER_ID} ${CMAKE_C_COMPILER_VERSION})")
+
+function(crdop_set_compile_options target)
+ if(MSVC)
+ target_compile_options(${target} PRIVATE /W3 /utf-8)
+ else()
+ target_compile_options(${target} PRIVATE -Wall -Wextra -Wno-unused-parameter)
+ if(CRDOP_COMPILER STREQUAL "clang")
+ target_compile_options(${target} PRIVATE
+ -Wno-gnu-zero-variadic-macro-arguments
+ -Wno-deprecated-non-prototype
+ )
+ endif()
+ if(CMAKE_BUILD_TYPE STREQUAL "Release")
+ target_compile_options(${target} PRIVATE -O2)
+ endif()
+ endif()
+endfunction()
+
+include(CheckLinkerFlag)
+if(NOT WIN32 AND NOT APPLE)
+ check_linker_flag(C "-Wl,--wrap=malloc" CRDOP_LINKER_SUPPORTS_WRAP)
+else()
+ set(CRDOP_LINKER_SUPPORTS_WRAP FALSE)
+endif()
diff --git a/stacks/crdop/cmake/CRDOPPlatform.cmake b/stacks/crdop/cmake/CRDOPPlatform.cmake
new file mode 100644
index 0000000..9d78683
--- /dev/null
+++ b/stacks/crdop/cmake/CRDOPPlatform.cmake
@@ -0,0 +1,71 @@
+# CRDOP platform / architecture detection (standalone project)
+#
+# Tested: Linux, *BSD, Windows, macOS — GCC and Clang/LLVM
+
+if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(aarch64|arm64)$")
+ set(CRDOP_ARCH "aarch64")
+elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "^(armv7|armv7l|arm)$")
+ set(CRDOP_ARCH "arm32")
+elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "^(i[3-6]86)$")
+ set(CRDOP_ARCH "x86")
+elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|AMD64)$")
+ set(CRDOP_ARCH "x86_64")
+else()
+ set(CRDOP_ARCH "${CMAKE_SYSTEM_PROCESSOR}")
+endif()
+
+set(CRDOP_PLATFORM_INCLUDES "")
+set(CRDOP_PLATFORM_LIBS "")
+set(CRDOP_PLATFORM_SOURCES "")
+
+if(WIN32)
+ set(CRDOP_PLATFORM "windows")
+ set(CRDOP_PLATFORM_SOURCES
+ src/windows/Waveout.c
+ lib/hid/hid.c
+ )
+ set(CRDOP_PLATFORM_LIBS wsock32 winmm setupapi ws2_32)
+elseif(APPLE)
+ set(CRDOP_PLATFORM "darwin")
+ set(CRDOP_PLATFORM_SOURCES
+ src/linux/ALSASound.c
+ src/linux/LinSerial.c
+ src/darwin/alsa_shim.c
+ )
+ list(APPEND CRDOP_PLATFORM_INCLUDES "${VENDOR_DIR}/src")
+ find_library(CRDOP_COREAUDIO_LIB CoreAudio REQUIRED)
+ find_library(CRDOP_AUDIOTOOLBOX_LIB AudioToolbox REQUIRED)
+ find_library(CRDOP_COREFOUNDATION_LIB CoreFoundation REQUIRED)
+ set(CRDOP_PLATFORM_LIBS
+ ${CRDOP_COREAUDIO_LIB}
+ ${CRDOP_AUDIOTOOLBOX_LIB}
+ ${CRDOP_COREFOUNDATION_LIB}
+ pthread m
+ )
+elseif(CMAKE_SYSTEM_NAME STREQUAL "FreeBSD")
+ set(CRDOP_PLATFORM "freebsd-oss")
+ set(CRDOP_PLATFORM_SOURCES
+ src/linux/LinSerial.c
+ )
+ set(CRDOP_PLATFORM_LIBS pthread m)
+elseif(CMAKE_SYSTEM_NAME MATCHES "OpenBSD|NetBSD|DragonFly")
+ set(CRDOP_PLATFORM "bsd-oss")
+ set(CRDOP_PLATFORM_SOURCES
+ src/linux/LinSerial.c
+ )
+ set(CRDOP_PLATFORM_LIBS pthread m)
+elseif(UNIX)
+ set(CRDOP_PLATFORM "linux")
+ set(CRDOP_PLATFORM_SOURCES
+ src/linux/ALSASound.c
+ src/linux/LinSerial.c
+ )
+ find_package(PkgConfig REQUIRED)
+ pkg_check_modules(ALSA REQUIRED alsa)
+ set(CRDOP_PLATFORM_INCLUDES ${ALSA_INCLUDE_DIRS})
+ set(CRDOP_PLATFORM_LIBS ${ALSA_LIBRARIES} rt pthread m)
+else()
+ message(FATAL_ERROR "CRDOP: unsupported system ${CMAKE_SYSTEM_NAME}")
+endif()
+
+message(STATUS "CRDOP platform=${CRDOP_PLATFORM} arch=${CRDOP_ARCH} system=${CMAKE_SYSTEM_NAME}")
diff --git a/stacks/crdop/cmake/CRDOPTests.cmake b/stacks/crdop/cmake/CRDOPTests.cmake
new file mode 100644
index 0000000..64b6b48
--- /dev/null
+++ b/stacks/crdop/cmake/CRDOPTests.cmake
@@ -0,0 +1,104 @@
+# Upstream ardopcf cmocka unit tests (Unix + GNU/Clang ld --wrap)
+
+option(CRDOP_BUILD_TESTS "Build and register upstream ardopcf unit tests" OFF)
+
+if(NOT CRDOP_BUILD_TESTS)
+ return()
+endif()
+
+if(WIN32 OR APPLE)
+ message(STATUS "CRDOP_BUILD_TESTS: skipped on ${CRDOP_PLATFORM} (cmocka/--wrap needs Unix GNU or lld linker)")
+ return()
+endif()
+
+find_package(PkgConfig QUIET)
+if(PkgConfig_FOUND)
+ pkg_check_modules(CMOCKA QUIET cmocka)
+endif()
+
+if(NOT CMOCKA_FOUND)
+ find_path(CMOCKA_INCLUDE_DIR cmocka.h)
+ find_library(CMOCKA_LIBRARY cmocka)
+ if(CMOCKA_INCLUDE_DIR AND CMOCKA_LIBRARY)
+ set(CMOCKA_FOUND TRUE)
+ set(CMOCKA_INCLUDE_DIRS "${CMOCKA_INCLUDE_DIR}")
+ set(CMOCKA_LIBRARIES "${CMOCKA_LIBRARY}")
+ endif()
+endif()
+
+if(NOT CMOCKA_FOUND)
+ message(WARNING "CRDOP_BUILD_TESTS: libcmocka not found — install cmocka dev package (see docs/BUILD.md)")
+ return()
+endif()
+
+if(NOT CRDOP_LINKER_SUPPORTS_WRAP)
+ message(WARNING "CRDOP_BUILD_TESTS: linker does not support --wrap; unit tests disabled")
+ return()
+endif()
+
+enable_testing()
+
+set(CRDOP_VENDOR_TEST_DIR "${VENDOR_DIR}/test/ardop")
+set(CRDOP_VENDOR_TEST_COMMON
+ "${CRDOP_VENDOR_TEST_DIR}/setup.c"
+)
+
+function(crdop_add_vendor_test name)
+ cmake_parse_arguments(ARG "" "" "SOURCES;WRAP" ${ARGN})
+ set(test_src "${CRDOP_VENDOR_TEST_DIR}/test_${name}.c")
+ if(NOT EXISTS "${test_src}")
+ message(FATAL_ERROR "CRDOP test source missing: ${test_src}")
+ endif()
+
+ set(sources "${test_src}" ${CRDOP_VENDOR_TEST_COMMON})
+ if(ARG_SOURCES)
+ foreach(rel IN LISTS ARG_SOURCES)
+ list(APPEND sources "${VENDOR_DIR}/${rel}")
+ endforeach()
+ endif()
+
+ add_executable(crdop_test_${name} ${sources})
+ crdop_set_compile_options(crdop_test_${name})
+
+ target_include_directories(crdop_test_${name} PRIVATE
+ "${CRDOP_ROOT}/include"
+ "${VENDOR_DIR}/src"
+ "${VENDOR_DIR}/lib"
+ ${CRDOP_PLATFORM_INCLUDES}
+ )
+
+ if(ARG_SOURCES)
+ target_link_libraries(crdop_test_${name} PRIVATE m pthread)
+ if(CRDOP_PLATFORM STREQUAL "linux")
+ target_link_libraries(crdop_test_${name} PRIVATE rt)
+ endif()
+ else()
+ target_link_libraries(crdop_test_${name} PRIVATE ardopcf_vendor)
+ endif()
+
+ target_link_libraries(crdop_test_${name} PRIVATE ${CMOCKA_LIBRARIES})
+ target_include_directories(crdop_test_${name} PRIVATE ${CMOCKA_INCLUDE_DIRS})
+
+ if(ARG_WRAP)
+ foreach(sym IN LISTS ARG_WRAP)
+ target_link_options(crdop_test_${name} PRIVATE "-Wl,--wrap=${sym}")
+ endforeach()
+ endif()
+
+ add_test(NAME crdop_test_${name} COMMAND crdop_test_${name})
+endfunction()
+
+crdop_add_vendor_test(ARDOPCommon)
+crdop_add_vendor_test(HostInterface)
+crdop_add_vendor_test(Locator)
+crdop_add_vendor_test(Packed6)
+crdop_add_vendor_test(StationId)
+crdop_add_vendor_test(log
+ SOURCES
+ src/common/log_file.c
+ src/common/log.c
+ WRAP
+ fopen fclose fwrite fflush freopen
+)
+
+message(STATUS "CRDOP_BUILD_TESTS: registered upstream ardopcf cmocka tests")
diff --git a/stacks/crdop/cmake/CRDOPVendorArdopcf.cmake b/stacks/crdop/cmake/CRDOPVendorArdopcf.cmake
new file mode 100644
index 0000000..e82c605
--- /dev/null
+++ b/stacks/crdop/cmake/CRDOPVendorArdopcf.cmake
@@ -0,0 +1,127 @@
+# Legacy vendor/ardopcf build (opt-in only; not shipped in MAX25 v1).
+
+include("${CRDOP_ROOT}/cmake/CRDOPCompiler.cmake")
+include("${CRDOP_ROOT}/cmake/CRDOPPlatform.cmake")
+
+option(CRDOP_BUILD_TESTS "Build upstream ardopcf cmocka unit tests" OFF)
+
+set(CRDOP_VENDOR_COMMON
+ lib/rawhid/rawhid.c
+ lib/rockliff/rrs.c
+ lib/ws_server/ws_server.c
+ src/common/ARDOPC.c
+ src/common/ARDOPCommon.c
+ src/common/ardopSampleArrays.c
+ src/common/ARQ.c
+ src/common/BusyDetect.c
+ src/common/FEC.c
+ src/common/FFT.c
+ src/common/HostInterface.c
+ src/common/Locator.c
+ src/common/log_file.c
+ src/common/log.c
+ src/common/Modulate.c
+ src/common/Packed6.c
+ src/common/RXO.c
+ src/common/sdft.c
+ src/common/SoundInput.c
+ src/common/StationId.c
+ src/common/TCPHostInterface.c
+ src/common/txframe.c
+ src/common/wav.c
+ src/common/Webgui.c
+ src/common/noise.c
+ src/common/ardopcf.c
+)
+
+set(CRDOP_VENDOR_SRCS "")
+foreach(rel IN LISTS CRDOP_VENDOR_COMMON CRDOP_PLATFORM_SOURCES)
+ list(APPEND CRDOP_VENDOR_SRCS "${VENDOR_DIR}/${rel}")
+endforeach()
+
+set(TXT2C "${CMAKE_BINARY_DIR}/txt2c")
+add_executable(txt2c "${VENDOR_DIR}/lib/txt2c/txt2c.c")
+crdop_set_compile_options(txt2c)
+set_target_properties(txt2c PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}")
+
+set(WEBGUI_HTML_C "${CMAKE_BINARY_DIR}/gen-webgui.html.c")
+set(WEBGUI_JS_C "${CMAKE_BINARY_DIR}/gen-webgui.js.c")
+add_custom_command(
+ OUTPUT "${WEBGUI_HTML_C}"
+ COMMAND txt2c "${VENDOR_DIR}/webgui/webgui.html" "${WEBGUI_HTML_C}" webgui_html
+ DEPENDS txt2c "${VENDOR_DIR}/webgui/webgui.html"
+ COMMENT "Embedding webgui.html"
+)
+add_custom_command(
+ OUTPUT "${WEBGUI_JS_C}"
+ COMMAND txt2c "${VENDOR_DIR}/webgui/webgui.js" "${WEBGUI_JS_C}" webgui_js
+ DEPENDS txt2c "${VENDOR_DIR}/webgui/webgui.js"
+ COMMENT "Embedding webgui.js"
+)
+
+add_library(ardopcf_vendor_objs OBJECT
+ ${CRDOP_VENDOR_SRCS}
+ "${WEBGUI_HTML_C}"
+ "${WEBGUI_JS_C}"
+)
+
+target_include_directories(ardopcf_vendor_objs PRIVATE
+ "${CRDOP_ROOT}/include"
+ "${VENDOR_DIR}/src"
+ "${VENDOR_DIR}/lib"
+ ${CRDOP_PLATFORM_INCLUDES}
+)
+
+target_compile_definitions(ardopcf_vendor_objs PRIVATE CRDOP_BUILD=1)
+
+if(WIN32)
+ target_compile_definitions(ardopcf_vendor_objs PRIVATE WIN32 _CRT_SECURE_NO_DEPRECATE)
+endif()
+
+if(APPLE)
+ target_compile_definitions(ardopcf_vendor_objs PRIVATE __APPLE__)
+endif()
+
+crdop_set_compile_options(ardopcf_vendor_objs)
+
+add_executable(crdopc
+ "${CRDOP_ROOT}/src/crdop_version.c"
+ $<TARGET_OBJECTS:ardopcf_vendor_objs>
+)
+
+target_include_directories(crdopc PRIVATE
+ "${CRDOP_ROOT}/include"
+ "${VENDOR_DIR}/src"
+ "${VENDOR_DIR}/lib"
+ ${CRDOP_PLATFORM_INCLUDES}
+)
+
+target_compile_definitions(crdopc PRIVATE CRDOP_BUILD=1)
+
+if(WIN32)
+ target_compile_definitions(crdopc PRIVATE WIN32 _CRT_SECURE_NO_DEPRECATE)
+endif()
+
+if(APPLE)
+ target_compile_definitions(crdopc PRIVATE __APPLE__)
+endif()
+
+crdop_set_compile_options(crdopc)
+target_link_libraries(crdopc PRIVATE ${CRDOP_PLATFORM_LIBS})
+
+add_library(ardopcf_vendor STATIC
+ "${CRDOP_ROOT}/src/crdop_version.c"
+ $<TARGET_OBJECTS:ardopcf_vendor_objs>
+)
+target_include_directories(ardopcf_vendor PUBLIC
+ "${CRDOP_ROOT}/include"
+ "${VENDOR_DIR}/src"
+ "${VENDOR_DIR}/lib"
+ ${CRDOP_PLATFORM_INCLUDES}
+)
+target_compile_definitions(ardopcf_vendor PUBLIC CRDOP_BUILD=1)
+target_link_libraries(ardopcf_vendor PUBLIC ${CRDOP_PLATFORM_LIBS})
+
+include("${CRDOP_ROOT}/cmake/CRDOPTests.cmake")
+
+install(TARGETS crdopc RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
diff --git a/stacks/crdop/cmake/toolchains/aarch64-linux-gnu.cmake b/stacks/crdop/cmake/toolchains/aarch64-linux-gnu.cmake
new file mode 100644
index 0000000..59e3a37
--- /dev/null
+++ b/stacks/crdop/cmake/toolchains/aarch64-linux-gnu.cmake
@@ -0,0 +1,4 @@
+set(CMAKE_SYSTEM_NAME Linux)
+set(CMAKE_SYSTEM_PROCESSOR aarch64)
+set(CMAKE_C_COMPILER aarch64-linux-gnu-gcc)
+set(CMAKE_CXX_COMPILER aarch64-linux-gnu-g++)
diff --git a/stacks/crdop/cmake/toolchains/arm-linux-gnueabihf.cmake b/stacks/crdop/cmake/toolchains/arm-linux-gnueabihf.cmake
new file mode 100644
index 0000000..bb70e64
--- /dev/null
+++ b/stacks/crdop/cmake/toolchains/arm-linux-gnueabihf.cmake
@@ -0,0 +1,4 @@
+set(CMAKE_SYSTEM_NAME Linux)
+set(CMAKE_SYSTEM_PROCESSOR armv7l)
+set(CMAKE_C_COMPILER arm-linux-gnueabihf-gcc)
+set(CMAKE_CXX_COMPILER arm-linux-gnueabihf-g++)
diff --git a/stacks/crdop/cmake/toolchains/x86_64-w64-mingw32.cmake b/stacks/crdop/cmake/toolchains/x86_64-w64-mingw32.cmake
new file mode 100644
index 0000000..1b44215
--- /dev/null
+++ b/stacks/crdop/cmake/toolchains/x86_64-w64-mingw32.cmake
@@ -0,0 +1,4 @@
+set(CMAKE_SYSTEM_NAME Windows)
+set(CMAKE_SYSTEM_PROCESSOR x86_64)
+set(CMAKE_C_COMPILER x86_64-w64-mingw32-gcc)
+set(CMAKE_CXX_COMPILER x86_64-w64-mingw32-g++)
diff --git a/stacks/crdop/docs/ACOUSTIC-TEST-PROTOCOL.md b/stacks/crdop/docs/ACOUSTIC-TEST-PROTOCOL.md
new file mode 100644
index 0000000..9656c2a
--- /dev/null
+++ b/stacks/crdop/docs/ACOUSTIC-TEST-PROTOCOL.md
@@ -0,0 +1,84 @@
+# Acoustic bench test protocol — P0 bench acceptance
+
+**Purpose:** Close CRDOP P0 bench without RF. All steps runnable offline in CI or on a dev host.
+
+**Related:** [HOST-PROTOCOL-SPEC.md](HOST-PROTOCOL-SPEC.md) · [DEVELOPER.md](DEVELOPER.md) · [ROADMAP.md](../ROADMAP.md) P0 bench.
+
+---
+
+## Prerequisites
+
+| Item | Requirement |
+|------|-------------|
+| Build | `MAX25_BUILD_CRDOP=ON`, `cmake --install` or source tree |
+| Python | 3.10+ with repo on `PYTHONPATH` |
+| ALSA | Optional for tests 3–4; loopback tests need no hardware |
+| PulseAudio | **Off** or bypassed (`no_pulse=yes` in INI) |
+
+---
+
+## Test matrix
+
+| ID | Name | RF | Pass criteria |
+|----|------|-----|---------------|
+| **T0** | Bell 202 round-trip | No | `test_bell202_line_code.py` green |
+| **T1** | HDLC + CRC | No | `hdlc_codec` round-trip in unit tests |
+| **T2** | Software loopback | No | `max25-signal-sniffer.py --loopback` decodes AX.25 UI |
+| **T3** | `acoustic_engine` self-test | No | `AcousticEngine.loopback_self_test()` returns True |
+| **T4** | `audio-dummyd` + host TCP | No | Connect :8515/:8516, `PROTOCOLMODE KISS` → `OK` |
+| **T5** | `max25d` audio-dummy backend | No | `test_audio_dummy_backend.py` green |
+| **T6** | `max25d` crdop-tcp backend | No | `test_crdop_backend.py` green |
+| **T7** | ALSA loopback cable | No* | Sniffer decodes tone from `aplay`/`arecord` pair |
+| **T8** | WAV fixture | No | Sniffer `--wav` decodes recorded Bell 202 capture |
+
+\*Requires `snd-aloop` or physical loopback interface.
+
+---
+
+## Commands (copy-paste)
+
+```bash
+cd MAX25-Stack
+./scripts/build.sh
+
+# T0–T1
+python3 -m pytest stacks/crdop/test_bell202_line_code.py -q
+python3 -m pytest stacks/daemon/test_crdop_backend.py stacks/daemon/test_audio_dummy_backend.py -q
+
+# T2
+python3 stacks/crdop/tools/max25-signal-sniffer.py --loopback
+
+# T4 (background)
+python3 stacks/crdop/tools/audio-dummyd.py --ctrl-port 8515 --data-port 8516 &
+sleep 1
+python3 -c "
+import socket
+c=socket.create_connection(('127.0.0.1',8515),timeout=2)
+c.sendall(b'PROTOCOLMODE KISS\n')
+print(c.recv(64))
+"
+```
+
+---
+
+## P0 bench exit gate
+
+**100 % P0 bench** when:
+
+- [x] T0, T1, T2, T5, T6 pass in `release-check.sh`
+- [ ] T3 documented green in CI log (or explicit skip reason)
+- [ ] T4 manual or scripted smoke in release-check
+- [ ] Host ports unified: launcher, `audio-dummyd`, `max25d` use [HOST-PROTOCOL-SPEC.md](HOST-PROTOCOL-SPEC.md) defaults
+
+P0 on-air starts only after P0 bench exit — see [docs/HARDWARE-ACCEPTANCE.md](../../docs/HARDWARE-ACCEPTANCE.md).
+
+---
+
+## Failure triage
+
+| Symptom | Check |
+|---------|-------|
+| Wrong tones | Mark 1200 Hz / Space 2200 Hz, sample rate 48000 |
+| CRC fail | `ax25_codec.ax25_crc` vs `hdlc_codec` |
+| TCP refuse | Port 8515 free; `audio-dummyd` running for host tests |
+| ALSA xrun | `period_frames`, cable levels — [SOUNDCARD-QUALIFICATION.md](SOUNDCARD-QUALIFICATION.md) |
diff --git a/stacks/crdop/docs/AUDIO-ARCHITECTURE.md b/stacks/crdop/docs/AUDIO-ARCHITECTURE.md
new file mode 100644
index 0000000..6320253
--- /dev/null
+++ b/stacks/crdop/docs/AUDIO-ARCHITECTURE.md
@@ -0,0 +1,80 @@
+# CRDOP audio architecture — kernel ALSA only
+
+**CRDOP** = stack acronym for **MAX25-SoftModem** (device id `soft-crdop`).
+
+MAX25-SoftModem (CRDOP) talks to the radio **through the sound hardware directly**. There is **no PulseAudio**, **no PipeWire default route**, and **no desktop sound server** in the path.
+
+## Stack layers
+
+```
+┌─────────────┐ M25/1 TCP ┌──────────────┐ host TCP ┌─────────────┐
+│ max25d │ ◄────────────► │ CRDOP modem │ ◄──────────► │ max25- │
+│ (stack) │ │ (DSP/AX.25) │ │ terminal │
+└─────────────┘ └──────┬───────┘ └─────────────┘
+ │
+ MAX25 sound-proxy
+ (buffer, timing, duplex)
+ │
+ ▼
+ libasound (userspace)
+ │
+ ▼
+ Linux kernel ALSA
+ (/dev/snd/*, drivers)
+ │
+ ▼
+ Sound hardware (USB/PCI codec)
+ │
+ ▼
+ Radio interface (line / acoustic)
+```
+
+| Layer | Role | Allowed |
+|-------|------|---------|
+| **max25d** | Device lifecycle, `CrdopTcpBackend`, no audio I/O | — |
+| **CRDOP modem** | AFSK encode/decode, AX.25 framing, duplex policy | — |
+| **MAX25 sound-proxy** | **Only** audio shim between modem and ALSA — period size, xrun recovery, hw params, PTT timing hooks | MAX25-owned |
+| **ALSA userspace** | `snd_pcm_*` on **`hw:` / `plughw:`** devices | Direct libasound |
+| **Kernel ALSA** | Driver, DMA, card registry | Required |
+| **PulseAudio / PipeWire** | — | **Forbidden** in production path |
+
+The sound-proxy is **not** a second network hop — it is the in-process (or co-process) module that owns ALSA opens and keeps modem timing off the desktop audio stack.
+
+## Why no PulseAudio
+
+PulseAudio and PipeWire insert mixing, resampling, and variable latency between applications and the kernel. CRDOP must:
+
+- Reproduce **exact mark/space frequencies** on playback
+- Sample capture with **stable phase** for demodulation
+- Meet **tighter deadlines** as baud rises toward 19200
+
+A userspace mixer breaks those guarantees. Operators must bind **card and device explicitly** (`hw:Card,Device` or tested `plughw:`).
+
+## Operator setup
+
+1. Identify hardware: `arecord -l` and `aplay -l` (kernel cards, not `pulse` pseudo-devices).
+2. Set INI `[audio] capture` and `playback` to **hardware ALSA names**.
+3. Ensure no session steals the device (`pasuspender` / stop PulseAudio on dedicated hosts).
+4. Verify loopback before on-air traffic.
+
+Example INI:
+
+```ini
+[audio]
+backend = alsa-kernel
+no_pulse = yes
+capture = hw:1,0
+playback = hw:1,0
+```
+
+Dedicated packet-radio hosts: prefer **single-purpose Linux** without a desktop sound daemon, or `PULSE_SERVER=` / `PIPEWIRE_RUNTIME_DIR=` unset for the `crdop` service unit.
+
+## Development (Eigenentwicklung)
+
+Native CRDOP will implement the sound-proxy in-tree (`include/crdop/sound_proxy.h`). Legacy optional vendor builds used `ALSASound.c` with the same rule: **open ALSA devices directly**.
+
+## See also
+
+- [SOFTMODEM.md](SOFTMODEM.md) — product scope, baud, duplex
+- [CONFIG.md](CONFIG.md) — INI keys
+- [BUILD.md](BUILD.md) — `libasound2-dev` dependency
diff --git a/stacks/crdop/docs/BUILD.md b/stacks/crdop/docs/BUILD.md
new file mode 100644
index 0000000..e72fac0
--- /dev/null
+++ b/stacks/crdop/docs/BUILD.md
@@ -0,0 +1,99 @@
+# Build
+
+Standalone CRDOP (**MAX25-SoftModem** — MAX25-SoftModem) — embedded `vendor/ardopcf/`, no submodules.
+
+## Requirements
+
+- CMake ≥ 3.16
+- **GCC** or **Clang / LLVM**
+- Platform libraries (see table below)
+- Optional tests: **cmocka** (Linux, *BSD)
+
+## Platform matrix (tested)
+
+| OS | Toolchain | Audio / libs | Status |
+|----|-----------|----------------|--------|
+| **Linux** | GCC, Clang | `libasound2-dev`, `pkg-config` | tested |
+| **FreeBSD** | GCC, Clang | `alsa-lib`, `pkgconf` | tested |
+| **OpenBSD / NetBSD** | GCC, Clang | `alsa-lib` if available | experimental |
+| **Windows** | MinGW-w64, MSVC | WinMM (built-in) | **untested** |
+| **macOS** | Clang (Xcode) | CoreAudio (built-in) | **untested** |
+
+Other operating systems are not tested.
+
+### Debian / Ubuntu
+
+```bash
+sudo apt install build-essential cmake libasound2-dev pkg-config
+sudo apt install libcmocka-dev # optional unit tests
+```
+
+### FreeBSD
+
+```bash
+pkg install cmake alsa-lib pkgconf cmocka
+```
+
+### macOS
+
+```bash
+xcode-select --install
+# or: brew install cmake
+```
+
+### Windows (MinGW-w64 on Linux host)
+
+```bash
+sudo apt install mingw-w64
+CRDOP_TOOLCHAIN=x86_64-w64-mingw32 ./scripts/build-crdop.sh
+# → build/crdopc.exe
+```
+
+Native Windows: Visual Studio 2022+ with CMake, or MSYS2 MinGW.
+
+## Build
+
+```bash
+./scripts/build-crdop.sh
+CC=clang ./scripts/build-crdop.sh
+./scripts/test-all.sh # smoke + cmocka (Unix)
+```
+
+Output: `build/crdopc` (Windows: `build/crdopc.exe`)
+
+## Install
+
+```bash
+./scripts/install-crdop.sh # → /usr/local
+CRDOP_PREFIX=$HOME/.local ./scripts/install-crdop.sh
+```
+
+Installs:
+
+| Path | Contents |
+|------|----------|
+| `bin/crdopc` | Modem binary |
+| `bin/crdop` | Profile launcher (`scripts/crdopc`) |
+| `share/crdop/` | INI examples, `VERSION` |
+
+Run: `crdop` or `CRDOP_INI=~/.config/crdop/crdop.ini crdop`
+
+## Cross-compile (Linux host)
+
+```bash
+CRDOP_TOOLCHAIN=aarch64-linux-gnu ./scripts/build-crdop.sh
+CRDOP_TOOLCHAIN=arm-linux-gnueabihf ./scripts/build-crdop.sh
+CRDOP_TOOLCHAIN=x86_64-w64-mingw32 ./scripts/build-crdop.sh
+```
+
+## Troubleshooting
+
+| Problem | Fix |
+|---------|-----|
+| `vendor/ardopcf missing` | Full repo checkout |
+| No ALSA (Linux/BSD) | Install dev package / `alsa-lib` port |
+| macOS build fails | Xcode CLI tools; CMake ≥ 3.16 |
+| Tests skipped | Install cmocka dev package |
+| Stale build | `rm -rf build && ./scripts/build-crdop.sh` |
+
+Vendor refresh (maintainers): `./scripts/refresh-vendor-ardopcf.sh`
diff --git a/stacks/crdop/docs/CHANGELOG.md b/stacks/crdop/docs/CHANGELOG.md
new file mode 100644
index 0000000..2bbfbf6
--- /dev/null
+++ b/stacks/crdop/docs/CHANGELOG.md
@@ -0,0 +1,25 @@
+# Changelog
+
+## [CUR999] — dev track (current)
+
+**CUR999** — internal dev track id for MAX25-SoftModem inside MAX25-Stack v1.0.0. Dev track `CUR999`; ships with MAX25-Stack v1.0.0.
+
+- Dev track id `CUR999` in `$SRC/stacks/crdop/VERSION`
+- Native modem DSP in development; optional legacy vendor build (`-DCRDOP_VENDOR_ARDOPCF=ON`); **ARDOP-plugin** documented separately
+- `max25d` `crdop-tcp` backend, INI scaffold, launcher, HyBBX attach examples
+
+## [0.5.0] — planned (not yet released)
+
+Target standalone CRDOP v0.5 after v1.0.0 stack ship.
+
+- Standalone CRDOP modem (`crdopc`) with native DSP
+- Embedded optional `vendor/ardopcf` (MIT) for dev-only legacy builds only
+- CB / dual / amateur profiles; Linux and *BSD tested
+
+## [0.1.0-l2-cb] — 2026-07-03
+
+Experimental CB profile bootstrap.
+
+## [0.0.0-bootstrap]
+
+Repository scaffold.
diff --git a/stacks/crdop/docs/CONFIG.md b/stacks/crdop/docs/CONFIG.md
new file mode 100644
index 0000000..821fd1c
--- /dev/null
+++ b/stacks/crdop/docs/CONFIG.md
@@ -0,0 +1,50 @@
+# Configuration
+
+`scripts/crdopc` reads INI → starts native `audio-dummyd` (M25/KISS host).
+
+## INI search order
+
+1. `$CRDOP_INI`
+2. `~/.config/crdop/crdop.ini`
+3. `share/crdop.ini.example`
+
+Templates: `share/crdop.ini.example` · `share/crdop-dual.ini.example` · `share/crdop-amateur.ini.example`
+
+## Keys
+
+### `[profile]`
+
+| Key | Default | Values |
+|-----|---------|--------|
+| `radio_profile` | `cb` | `cb` · `dual` · `amateur` |
+
+### `[modem]`
+
+| Key | Default | Notes |
+|-----|---------|-------|
+| `arq_bandwidth` | profile | `500MAX` (cb/dual) · `1000MAX` (amateur) |
+| `duplex` | `half` | `half` · `full` (`full` → `EXTRADELAY 0`) |
+| `extra_delay_ms` | _(auto)_ | Override delay; empty = profile default |
+
+### `[host]` · `[mycall]` · `[audio]`
+
+| Section | Key | Default | Notes |
+|---------|-----|---------|-------|
+| host | `port` | `8515` | TCP control |
+| mycall | `call` | `NOCALL-0` | On-air ID |
+| audio | `backend` | `alsa-kernel` | Kernel ALSA only — see [AUDIO-ARCHITECTURE.md](AUDIO-ARCHITECTURE.md) |
+| audio | `no_pulse` | `yes` | Reject PulseAudio/PipeWire pseudo devices |
+| audio | `capture` | _(required)_ | e.g. `hw:1,0` from `arecord -l` |
+| audio | `playback` | _(required)_ | e.g. `hw:1,0` from `aplay -l` |
+| audio | `sample_rate` | modem default | Hz |
+| audio | `period_frames` | auto | Buffer tuning |
+
+**No PulseAudio** in the production path. The **MAX25 sound-proxy** opens ALSA directly; `scripts/crdopc` clears Pulse/PipeWire session env when `no_pulse=yes`.
+
+## Environment
+
+| Variable | Purpose |
+|----------|---------|
+| `CRDOP_INI` | Config file path |
+
+Examples: [EXAMPLES.md](EXAMPLES.md)
diff --git a/stacks/crdop/docs/DEVELOPER.md b/stacks/crdop/docs/DEVELOPER.md
new file mode 100644
index 0000000..af43252
--- /dev/null
+++ b/stacks/crdop/docs/DEVELOPER.md
@@ -0,0 +1,188 @@
+# CRDOP developer guide
+
+**CRDOP** = **MAX25-SoftModem**. In-house GPLv3 sound-card modem (`stacks/crdop/`). Project rule: [docs/CRDOP.md](../../../docs/CRDOP.md).
+
+---
+
+## Source tree
+
+```
+stacks/crdop/
+├── lib/ # Native DSP (Python — leads validation)
+│ ├── bell202_line_code.py
+│ ├── afsk_modulator.py
+│ ├── afsk_demodulator.py
+│ ├── hdlc_codec.py
+│ ├── sound_proxy.py
+│ ├── m25_host_protocol.py
+│ ├── acoustic_engine.py
+│ └── test_bell202_line_code.py
+├── include/crdop/ # C API scaffold (future native binary)
+│ ├── sound_proxy.h
+│ └── version.h
+├── tools/
+│ ├── audio-dummyd.py # M25 host TCP daemon
+│ └── max25-signal-sniffer.py
+├── scripts/
+│ ├── crdopc # Launcher → audio-dummyd by default
+│ ├── build-crdop.sh
+│ └── install-crdop.sh
+├── share/ # crdop.ini.example (+ dual, amateur)
+├── cmake/ # Platform, vendor (dev-only), tests
+├── docs/ # This tree
+├── ROADMAP.md
+└── CMakeLists.txt
+```
+
+**Daemon integration:** `stacks/daemon/device_backends.py` → `CrdopTcpBackend` (`soft-crdop`), `AudioDummyBackend` (`audio-dummy`).
+
+**AX.25 codec:** `stacks/daemon/ax25_codec.py` — shared with on-air framing in `acoustic_engine.py`.
+
+---
+
+## Module map
+
+| Module | Layer | Responsibility |
+|--------|-------|----------------|
+| `bell202_line_code.py` | Line code | Bell 202 frequency-toggle: bit 0 → tone change; mark 1200 Hz / space 2200 Hz |
+| `afsk_modulator.py` | PHY TX | Continuous-phase AFSK PCM @ configurable sample rate (default 48 kHz) |
+| `afsk_demodulator.py` | PHY RX | Per-symbol Goertzel demod (1200 baud class) |
+| `hdlc_codec.py` | Framing | HDLC flags `0x7E`, bit-stuffing, CRC-16-CCITT |
+| `sound_proxy.py` | Audio I/O | ALSA capture/playback via `arecord`/`aplay`; buffer config |
+| `m25_host_protocol.py` | Host | TCP ctrl (:8515) + data (:8516); M25-family + KISS-semantics |
+| `acoustic_engine.py` | Bench | Full path: AX.25 UI → HDLC → AFSK → demod → parse; loopback self-test |
+
+**Host vs on-air:** `m25_host_protocol.py` carries AX.25 UI on the data port **without** HDLC wrapping. `acoustic_engine.encode_ax25_ui()` builds full on-air HDLC + AFSK for RF/audio output.
+
+**Not yet in `lib/`:** G3RUH scrambler/FSK (P1), 300 baud HF AFSK (P2), optional short-block FEC.
+
+---
+
+## Running tests
+
+### MAX25-Stack (recommended)
+
+```bash
+./scripts/build.sh
+cmake --build build --target max25_test # all offline tests
+cmake --build build --target max25_daemon_smoke # daemon + CRDOP subset
+```
+
+CRDOP-related tests in `max25_daemon_smoke`:
+
+| Test file | What it checks |
+|-----------|----------------|
+| `stacks/crdop/lib/test_bell202_line_code.py` | Bell 202 encode/decode round-trip |
+| `stacks/daemon/test_crdop_backend.py` | `CrdopTcpBackend` attach, TX (native M25/KISS) |
+| `stacks/daemon/test_audio_dummy_backend.py` | `audio-dummy` loopback backend |
+| `stacks/daemon/test_multi_device.py` | Multi-device registry incl. `soft-crdop` |
+
+### Direct (no CMake)
+
+```bash
+python3 stacks/crdop/lib/test_bell202_line_code.py
+python3 stacks/daemon/test_crdop_backend.py
+python3 stacks/daemon/test_audio_dummy_backend.py
+```
+
+### Sniffer loopback (no audio hardware)
+
+```bash
+python3 stacks/crdop/tools/max25-signal-sniffer.py --loopback
+# or after install:
+max25-signal-sniffer --loopback
+```
+
+Exit 0 when loopback decode produces AX.25 UI lines.
+
+### audio-dummyd bench
+
+```bash
+python3 stacks/crdop/tools/audio-dummyd.py --ctrl-port 8515 --data-port 8516
+# Terminal probe (separate shell):
+max25-terminal -H 127.0.0.1 -P 8515
+```
+
+### Standalone CRDOP vendor tests (dev-only)
+
+When built with `-DCRDOP_VENDOR_ARDOPCF=ON` and cmocka installed:
+
+```bash
+./scripts/build-crdop.sh
+CRDOP_BUILD_TESTS=ON ./scripts/test-all.sh
+```
+
+Vendor tree is **never** installed in MAX25 releases.
+
+---
+
+## Extending the modem
+
+### Add a modulation mode
+
+1. Add PHY module under `lib/` (e.g. `g3ruh_fsk.py`).
+2. Wire into `acoustic_engine.py` — select by `baud` / `modulation` parameter.
+3. Extend `crdop.ini` `[modem]` keys in `share/crdop.ini.example` and [CONFIG.md](CONFIG.md).
+4. Add unit tests beside the module (`test_*.py`).
+5. Update [ROADMAP.md](../ROADMAP.md) implementation status table.
+
+### Extend host protocol
+
+1. Edit `m25_host_protocol.py` — keep ctrl/data port split.
+2. Mirror changes in `stacks/daemon/device_backends.py` (`CrdopTcpBackend`).
+3. Document in [PROTOCOL.md](PROTOCOL.md).
+4. Add cases to `test_crdop_backend.py`.
+
+### C port path
+
+`include/crdop/sound_proxy.h` is the C ABI target. Python `lib/` leads until acoustic bench gates close; then port validated algorithms to native `crdopc` binary.
+
+---
+
+## Build modes
+
+| Mode | CMake flag | Output |
+|------|------------|--------|
+| **MAX25-Stack (default)** | `MAX25_BUILD_CRDOP=ON` | `bin/crdop`, `bin/audio-dummyd`, `bin/max25-signal-sniffer`, `share/crdop/` |
+| **MAX25 without CRDOP** | `-DMAX25_BUILD_CRDOP=OFF` | No CRDOP install artifacts |
+| **Standalone CRDOP** | `stacks/crdop/scripts/build-crdop.sh` | Same scaffold; optional vendor `crdopc` with `-DCRDOP_VENDOR_ARDOPCF=ON` |
+| **Dev vendor ARDOP** | `-DCRDOP_VENDOR_ARDOPCF=ON` | Legacy ardopcf binary — local only, never released |
+
+### Install paths (MAX25 `cmake --install`)
+
+| Artifact | Destination |
+|----------|-------------|
+| `crdop` (launcher) | `${CMAKE_INSTALL_PREFIX}/bin/` |
+| `audio-dummyd`, `max25-signal-sniffer` | `${CMAKE_INSTALL_PREFIX}/bin/` |
+| `crdop.ini.example`, `lib/*.py`, `VERSION` | `${CMAKE_INSTALL_PREFIX}/share/crdop/` |
+
+---
+
+## Dependencies
+
+| Component | Requirement |
+|-----------|-------------|
+| Runtime | Python 3, ALSA utils (`arecord`/`aplay`) on Linux |
+| Build | CMake ≥ 3.16 |
+| Audio | `libasound` — kernel ALSA direct opens only |
+| Tests | None beyond Python 3 (cmocka optional for vendor C tests) |
+
+---
+
+## Conventions
+
+- **English** for all shipped docs and user-facing strings.
+- **No PulseAudio / PipeWire** in production audio path.
+- **ARDOP** is a separate optional plugin — not part of CRDOP — see [plugins/external/ardop/README.md](../../../plugins/external/ardop/README.md).
+- Baud ceiling: **19200** (P1 G3RUH direct FSK). Above that: out of scope.
+
+---
+
+## Related
+
+| Doc | Topic |
+|-----|--------|
+| [MAX25-USAGE.md](MAX25-USAGE.md) | Operator workflow in MAX25-Stack |
+| [HARDWARE-INTERFACE.md](HARDWARE-INTERFACE.md) | Radio interface spec for builders |
+| [AUDIO-ARCHITECTURE.md](AUDIO-ARCHITECTURE.md) | Sound-proxy design |
+| [ROADMAP.md](../ROADMAP.md) | P0/P1/P2 milestones and module status |
diff --git a/stacks/crdop/docs/EXAMPLES.md b/stacks/crdop/docs/EXAMPLES.md
new file mode 100644
index 0000000..b6d32c5
--- /dev/null
+++ b/stacks/crdop/docs/EXAMPLES.md
@@ -0,0 +1,91 @@
+# Examples
+
+MAX25-Stack integration (plugins, `max25d`, terminal): [MAX25-USAGE.md](MAX25-USAGE.md).
+
+## Build
+
+```bash
+./scripts/build-crdop.sh
+CC=clang ./scripts/build-crdop.sh
+CRDOP_BUILD_TESTS=ON ./scripts/test-all.sh # needs libcmocka-dev
+```
+
+Cross-build (Linux host): see [BUILD.md](BUILD.md).
+
+## Run — CB (default, native M25 host)
+
+```bash
+./scripts/crdopc
+# or explicit example INI:
+CRDOP_INI=share/crdop.ini.example ./scripts/crdopc
+```
+
+This starts `audio-dummyd` with native M25/KISS host on TCP :8515/:8516.
+
+Copy config for daily use:
+
+```bash
+mkdir -p ~/.config/crdop
+cp share/crdop.ini.example ~/.config/crdop/crdop.ini
+$EDITOR ~/.config/crdop/crdop.ini
+./scripts/crdopc
+```
+
+## Run — dual (CB ↔ amateur)
+
+```bash
+CRDOP_INI=share/crdop-dual.ini.example ./scripts/crdopc
+```
+
+## Run — amateur (secondary)
+
+```bash
+CRDOP_INI=share/crdop-amateur.ini.example ./scripts/crdopc
+```
+
+## Full-duplex CB
+
+`share/crdop.ini.example` with:
+
+```ini
+[modem]
+duplex = full
+arq_bandwidth = 500MAX
+```
+
+## Custom call / ALSA devices
+
+```ini
+[mycall]
+call = CB01-0
+
+[audio]
+capture = plughw:1,0
+playback = plughw:1,0
+```
+
+Pass extra args after port (reserved for future native modem flags):
+
+```bash
+./scripts/crdopc 8515
+```
+
+## Direct audio-dummyd (no launcher)
+
+```bash
+./build/bin/audio-dummyd --ctrl-port 8515 --data-port 8516
+```
+
+## max25d integration
+
+```ini
+[devices]
+soft-crdop = crdop:default
+
+[device.soft-crdop]
+host = 127.0.0.1
+port = 8515
+listen = yes
+```
+
+See [../../docs/PLUGINS-DEVICE-MODEL.md](../../docs/PLUGINS-DEVICE-MODEL.md).
diff --git a/stacks/crdop/docs/FEC-SPEC.md b/stacks/crdop/docs/FEC-SPEC.md
new file mode 100644
index 0000000..53b1f93
--- /dev/null
+++ b/stacks/crdop/docs/FEC-SPEC.md
@@ -0,0 +1,99 @@
+# CRDOP FEC and duplex — implementation spec
+
+**Principle:** *Send more often with shorter frames rather than one long payload.*
+
+**Related:** [ROADMAP.md](../ROADMAP.md) FEC section · [docs/CRDOP.md](../../../docs/CRDOP.md)
+
+**Status:** MAX25-Stack **v1.0.0** / dev track `CUR999` — L1 optional FEC **planned**; operator-layer strategy **active now**.
+
+---
+
+## Layer model
+
+| Layer | Mechanism | v1.0.0 |
+|-------|-----------|--------|
+| **L3 Application** | Short beacons, staggered intervals | HyBBX / `max25d.ini` |
+| **L2 AX.25** | UI = no ARQ; I-frames optional ARQ via host | `ax25_codec.py` |
+| **L1 Modem** | CRC-16-CCITT mandatory; optional repeat/FEC blocks | `hdlc_codec.py` — CRC done |
+| **PHY** | Bell 202 AFSK (P0) | `afsk_*` |
+
+---
+
+## Half-duplex (CB default)
+
+| Parameter | Value | INI / host |
+|-----------|-------|------------|
+| Duplex | half | `[modem] duplex = half` |
+| CSMA persist | 255 | KISS `0x02` / CB profile |
+| Extra TX delay | 150 ms class | `extra_delay_ms` / `EXTRADELAY` |
+| Max UI info | **≤128 bytes** recommended | Application |
+| PTT | explicit | No VOX on CB |
+
+### Retransmission strategy (no L2 ARQ on UI)
+
+1. Prefer **N small identical beacons** over one large burst.
+2. Stagger dual-radio sites (`ax25_auto_interval` + offset).
+3. Optional L1: **2–3 repeats** of same short codeword vs one RS block >256 B.
+
+---
+
+## Full-duplex
+
+| Parameter | Value |
+|-----------|-------|
+| `duplex` | `full` |
+| `EXTRADELAY` | `0` |
+| FEC | Stronger codes allowed when bandwidth permits |
+| Audio | Full-duplex sound card; echo control required |
+
+---
+
+## Reference protocols (not on-air targets)
+
+| Protocol | Lesson for CRDOP |
+|----------|------------------|
+| **FX.25** | RS wrapper around AX.25; bit-stuffing hurts naive FEC |
+| **IL2P** | No bit-stuffing; packet-sync scramble — reference for future L2 |
+| **VARA DSP** | Multi-stage FEC — PHY reference only |
+
+---
+
+## Planned L1 FEC (post P0 on-air)
+
+| Requirement | Target |
+|-------------|--------|
+| Block size | ≤64 B payload + CRC per repeat unit |
+| Latency | <300 ms added on 1200 bd half-duplex |
+| Compatibility | Plain AX.25 receivers must not break (FX.25-style optional path) |
+| Test | AWGN + FM clip simulator before on-air |
+
+### INI keys (future)
+
+```ini
+[fec]
+enabled = no # default off v1.0.0
+repeat_count = 0 # 0 = no repeat; 2–3 for CB marginal
+profile = half # half | full
+```
+
+---
+
+## CB 1200 parameters (normative operator)
+
+| Parameter | Value |
+|-----------|-------|
+| Baud | 1200 |
+| Modulation | Bell 202 (1200/2200 Hz) |
+| Frame | AX.25 UI |
+| persist | 255 |
+| Beacon | 300 s + stagger (site INI) |
+
+---
+
+## Implementation checklist
+
+- [x] CRC-16 in `hdlc_codec.py`
+- [x] Duplex INI keys in `crdop.ini.example`
+- [ ] `fec.enabled` INI + launcher
+- [ ] Offline BER vs frame-length simulator
+- [ ] Document measured CB SNR thresholds (field)
diff --git a/stacks/crdop/docs/G3RUH-DESIGN.md b/stacks/crdop/docs/G3RUH-DESIGN.md
new file mode 100644
index 0000000..ae1a164
--- /dev/null
+++ b/stacks/crdop/docs/G3RUH-DESIGN.md
@@ -0,0 +1,72 @@
+# G3RUH FSK — CRDOP P1 design spec
+
+**Scope:** 9600–**19200** baud direct FSK. **Hard maximum 19200** — nothing above.
+
+**Prerequisite:** P0 on-air (1200 AFSK) stable. **Not** CB FM mic path.
+
+See [ROADMAP.md](../ROADMAP.md) P1 milestone (stack scheduling: **DEV-Level 4**).
+
+---
+
+## PHY difference from P0
+
+| | 1200 AFSK (P0) | G3RUH FSK (P1) |
+|---|----------------|----------------|
+| Modulation | Audio tones 1200/2200 Hz | Scrambled baseband FSK |
+| RF path | Mic / speaker / line | **Direct** varactor + discriminator |
+| CB FM voice channel | ✅ | ❌ |
+| VHF backbone | Access only at 1200 | ✅ at 9600–19200 |
+
+---
+
+## Parameters (normative)
+
+| Baud | Peak deviation | RF bandwidth (~-60 dB) |
+|------|----------------|------------------------|
+| 4800 | ±1.5 kHz | ~8 kHz |
+| **9600** | **±3 kHz** | **~20 kHz** |
+| **19200** | **±6 kHz** | **~30 kHz** |
+
+CRDOP implements **up to 19200** only.
+
+---
+
+## Radio interface requirements
+
+| Requirement | Detail |
+|-------------|--------|
+| TX | Direct modulation input (varactor), not acoustic |
+| RX | Discriminator / data output, wide IF |
+| Filter | Flat amplitude 10 Hz–5 kHz, constant group delay |
+| Scrambler | G3RUH compatible — interoperate with standard 9600 peers |
+
+Standard narrow FM voice audio **fails** eye-diagram tests at 9600+.
+
+---
+
+## Software modules (planned)
+
+| Module | Path | Status |
+|--------|------|--------|
+| Scrambler | `stacks/crdop/lib/g3ruh_scrambler.py` | Not started |
+| FSK modulator | `stacks/crdop/lib/g3ruh_modulator.py` | Not started |
+| FSK demodulator | `stacks/crdop/lib/g3ruh_demodulator.py` | Not started |
+| HDLC/framing | Reuse `hdlc_codec.py` | Done |
+
+Host protocol unchanged — same M25/KISS TCP; baud selected via INI `speed_baud` (future).
+
+---
+
+## P1 exit criteria
+
+1. Loopback with recorded baseband fixtures at 9600.
+2. Over-the-air exchange with reference 9600 FSK peer.
+3. 19200 optional stretch after 9600 stable.
+
+---
+
+## Out of scope
+
+- 38400+ satellite/backbone rates
+- Acoustic coupling at 9600+
+- CB 27 MHz deployment
diff --git a/stacks/crdop/docs/HARDWARE-INTERFACE.md b/stacks/crdop/docs/HARDWARE-INTERFACE.md
new file mode 100644
index 0000000..9c36e21
--- /dev/null
+++ b/stacks/crdop/docs/HARDWARE-INTERFACE.md
@@ -0,0 +1,194 @@
+# CRDOP hardware interface specification
+
+Generic specification for builders who manufacture **radio ↔ sound-card interfaces** for CRDOP (MAX25-SoftModem). **No product-specific wiring examples** — apply these rules to your own design.
+
+**CRDOP** = **MAX25-SoftModem**. Project rule: [docs/CRDOP.md](../../../docs/CRDOP.md).
+
+---
+
+## Scope
+
+CRDOP connects a host sound card to a transceiver via one of:
+
+| Path | Typical use | Max practical baud |
+|------|-------------|-------------------|
+| **Audio AFSK** | Mic IN / speaker OUT / line level | **1200** (P0) |
+| **Direct FSK** | Varactor TX + FM discriminator RX | **9600–19200** (P1) |
+| **SSB audio** | USB/LSB suppressed carrier | **300** (P2 optional) |
+| **Acoustic coupling** | Speaker ↔ microphone (test / field) | **1200** |
+
+RF path matrix and delivery milestones: [ROADMAP.md](../ROADMAP.md).
+
+---
+
+## Audio levels
+
+### Line level (preferred)
+
+| Signal | Typical level | Notes |
+|--------|---------------|-------|
+| **Playback → radio mic IN** | −20 to −10 dBV (adjust per rig sensitivity) | Use potentiometer or fixed attenuator; verify with sniffer or oscilloscope |
+| **Radio speaker/line → capture** | 0.1–1.0 V RMS typical | Attenuate hot speaker outputs before codec input |
+| **Full-scale digital** | Avoid clipping on TX and RX | Leave 6–12 dB headroom |
+
+### Mic level
+
+Some transceivers expose mic-level inputs only. Attenuate line output to mic sensitivity (often 5–20 mV). Use coupling capacitor if DC bias is present on the mic jack.
+
+### Isolation
+
+| Method | Purpose |
+|--------|---------|
+| **Audio transformer** (1:1 or step-down) | Galvanic isolation; breaks ground loops |
+| **Opto-isolator** (digital PTT path) | Isolate PTT from radio ground |
+| **Separate ground reference** | Star ground at interface PCB; one tie point to radio chassis if required |
+
+**Ground loops** cause hum, false transitions, and decode failures. Always isolate PC USB ground from radio chassis when using direct cable coupling.
+
+---
+
+## PTT control
+
+CRDOP is **half-duplex by default**. The host must key the transmitter before playback and unkey after tail.
+
+| Method | Interface | Caveats |
+|--------|-----------|---------|
+| **GPIO** | USB-serial RTS/DTR, parallel port, dedicated GPIO | Cleanest; software-controlled timing |
+| **Serial CAT** | Radio command port | Latency depends on radio firmware |
+| **VOX** | Audio-derived keying | **Not recommended** for packet — tail timing unpredictable; false keys from noise |
+| **Manual** | Operator PTT | Acceptable for bench only |
+
+### Timing parameters
+
+Configure in `crdop.ini` / `max25d.ini`:
+
+| Parameter | Typical half-duplex | Full-duplex |
+|-----------|--------------------|-------------|
+| Pre-TX delay (`extra_delay_ms`) | 80–200 ms | 0 |
+| TXTAIL | Match radio requirement | N/A |
+| PTT release | After last space tone + margin | N/A |
+
+**Rule:** explicit GPIO/serial PTT beats VOX for AX.25 UI bursts.
+
+---
+
+## Acoustic coupling
+
+Valid for **bench validation** and some field setups:
+
+```
+[Host speaker] ──air gap──► [Radio mic]
+[Radio speaker] ──air gap──► [Host mic]
+```
+
+| Principle | Detail |
+|-----------|--------|
+| **Distance** | Minimize path loss; foam gasket reduces room noise |
+| **Frequency response** | FM voice channel ≈ 3 kHz — sufficient for 1200/2200 Hz Bell 202 |
+| **Level** | Low volume on playback; avoid speaker distortion |
+| **Duplex** | Half-duplex only — acoustic feedback if TX and RX paths overlap |
+| **Test** | Run `max25-signal-sniffer --loopback` on host before acoustic trial |
+
+Acoustic coupling does **not** scale to G3RUH direct FSK (P1).
+
+---
+
+## Direct FSK path (P1 — G3RUH)
+
+When audio AFSK is insufficient (9600–19200 baud):
+
+| Element | Requirement |
+|---------|-------------|
+| **TX** | Discriminator input bypassed; varactor or dedicated FSK input on data port |
+| **RX** | Flat FM discriminator output (de-emphasis off or compensated) |
+| **Bandwidth** | ~20 kHz IF for 9600; wider for 19200 |
+| **Scrambler** | G3RUH polynomial — implemented in future `lib/` module |
+| **Radio** | Data-port capable; standard FM mic/speaker path **not** sufficient |
+
+CB FM rigs at 27 MHz are generally **not** direct-FSK capable at 9600+ — P1 targets VHF/UHF amateur backbone paths.
+
+---
+
+## Sound card requirements
+
+| Requirement | 1200 baud (P0) | 9600–19200 (P1) |
+|-------------|----------------|-----------------|
+| **Sample rate** | ≥ 48 kHz recommended | ≥ 48 kHz; 96 kHz preferred |
+| **Bit depth** | 16-bit mono | 16-bit mono |
+| **Duplex** | Full-duplex USB codec or half-duplex with PTT | Full-duplex, low jitter |
+| **Clock** | Stable crystal; avoid drift during TX | Jitter < 50 ppm class |
+| **Driver** | Kernel ALSA (`hw:` / `plughw:`) | Same |
+| **Isolation** | Transformer on radio side | Same + verified loopback |
+
+### Unsuitable interfaces
+
+- Onboard laptop mic/speaker without external interface
+- Bluetooth audio bridges
+- PulseAudio / PipeWire virtual devices
+- Consumer "USB sound sticks" without galvanic isolation
+
+Configure devices in `crdop.ini`:
+
+```ini
+[audio]
+backend = alsa-kernel
+no_pulse = yes
+capture = plughw:1,0
+playback = plughw:1,0
+sample_rate = 48000
+```
+
+Verify with:
+
+```bash
+max25-signal-sniffer --loopback
+max25-signal-sniffer -D plughw:1,0 -t 2.0
+```
+
+---
+
+## Compliance and operator responsibility
+
+| Topic | Policy |
+|-------|--------|
+| **Transmit licence** | Operator must hold valid authorization for the band and mode |
+| **CB (27 MHz EU)** | EN 300 433 / national rules — voice primary; data may be restricted or channel-specific |
+| **Amateur bands** | Band plan, power limits, identification |
+| **EMC** | Builder responsible for conducted/radiated emissions from interface hardware |
+| **CRDOP software** | GPLv3 — no warranty; see [LICENSE-USAGE.md](LICENSE-USAGE.md) |
+
+CRDOP documentation does **not** constitute legal advice. Check local regulations before on-air operation.
+
+---
+
+## Acceptance checklist (builder)
+
+| Step | Pass criterion |
+|------|----------------|
+| Loopback | `max25-signal-sniffer --loopback` decodes test UI |
+| Level | Mark/space tones within radio spec; no clipping |
+| Isolation | No hum on decode when PC and radio share mains |
+| PTT | Key before TX audio; clean tail; no truncation |
+| On-air | AX.25 UI exchange with known-good peer at 1200 baud |
+| Duplex | Half-duplex CSMA behaviour matches `persist` / `extra_delay` INI |
+
+---
+
+## Related in-repo docs
+
+| Topic | Document |
+|-------|----------|
+| Sound-card requirements | [SOUNDCARD-QUALIFICATION.md](SOUNDCARD-QUALIFICATION.md) |
+| Bell 202 / 1200 AFSK (P0) | [ROADMAP.md](../ROADMAP.md) · [SOFTMODEM.md](SOFTMODEM.md) |
+| G3RUH 9600–19200 (P1) | [G3RUH-DESIGN.md](G3RUH-DESIGN.md) |
+| AX.25 / packet context | [docs/PACKET-RADIO.md](../../../docs/PACKET-RADIO.md) |
+
+---
+
+## Related
+
+| Doc | Topic |
+|-----|--------|
+| [AUDIO-ARCHITECTURE.md](AUDIO-ARCHITECTURE.md) | Kernel ALSA, sound-proxy |
+| [MAX25-USAGE.md](MAX25-USAGE.md) | Stack integration and bench modes |
+| [ROADMAP.md](../ROADMAP.md) | P0/P1/P2 phases |
diff --git a/stacks/crdop/docs/HOST-PROTOCOL-SPEC.md b/stacks/crdop/docs/HOST-PROTOCOL-SPEC.md
new file mode 100644
index 0000000..d45c7e5
--- /dev/null
+++ b/stacks/crdop/docs/HOST-PROTOCOL-SPEC.md
@@ -0,0 +1,103 @@
+# M25 SoftModem host protocol — implementation spec (frozen for v1.0.0)
+
+**CRDOP** = **MAX25-SoftModem** · Native host wire for MAX25-SoftModem · **Not** ARDOP FEC/ARQ.
+
+**Normative code:** `stacks/crdop/lib/m25_host_protocol.py` · **Consumer:** `max25d` `CrdopTcpBackend`, `audio-dummyd`, `crdopc` launcher.
+
+---
+
+## Transport
+
+| Channel | Default port | Wire |
+|---------|--------------|------|
+| **Control** | **8515** | Line-oriented ASCII, **`\n`** terminated (native) |
+| **Data** | **8516** (= ctrl + 1) | Binary AX.25 UI body **without** HDLC flags or FCS |
+
+| Mode | Control terminator | Data semantics |
+|------|-------------------|----------------|
+| **Native M25/KISS** | `\n` | KISS DATA equivalent — 16+ byte UI body |
+
+### Bench alternate ports
+
+When `soft-crdop` and `audio-dummyd` run on one host, bench daemon may use **8520/8521** via CLI. Production `crdopc` and INI default **8515/8516**.
+
+---
+
+## Control commands (native)
+
+All commands are single lines. Reply is one line ending `\n`.
+
+| Command | Args | Reply | Behaviour |
+|---------|------|-------|-----------|
+| `INITIALIZE` | — | `OK` | Reset session state |
+| `PROTOCOLMODE` | `KISS` | `OK` | Select KISS-semantics data channel |
+| `MYCALL` | `CALL-SSID` | `OK` | Store station ID (uppercase) |
+| `LISTEN` | `TRUE`/`FALSE` | `OK` | RX enable (default true) |
+| `PING` | — | `OK` | Liveness |
+| `STATUS` | — | `STATUS ready mycall=…` | State snapshot |
+| *(unknown)* | — | `ERR unknown command` | |
+
+**Not supported on native path:** `PROTOCOLMODE FEC`, `FECMODE`, ARQ bandwidth commands — ARDOP is a separate optional plugin, not part of CRDOP.
+
+---
+
+## Data channel
+
+### TX (host → modem)
+
+1. TCP connect to data port.
+2. Send **one** AX.25 UI frame body: `dest(7) + src(7) + ctrl(1) + pid(1) + info(0..256)`.
+3. Modem responds `OK` or `ERR …` (ASCII line).
+
+Modem builds on-air: HDLC flags, bit-stuffing, frequency-toggle line code, AFSK PHY — see `acoustic_engine.encode_ax25_ui()`.
+
+### RX (modem → host)
+
+Native bench: optional `STATUS` lines on control channel. Production: `max25d` parses demodulated UI and displays `[CRDOP AX25 UI src>dst] text`.
+
+---
+
+## max25d integration
+
+```ini
+[devices]
+soft-crdop = crdop:default
+
+[device.soft-crdop]
+host = 127.0.0.1
+port = 8515
+listen = yes
+```
+
+`CrdopTcpBackend` on `open()`:
+
+1. TCP connect `host:port` (ctrl), `host:port+1` (data).
+2. Send `INITIALIZE`, `PROTOCOLMODE KISS`, `MYCALL …`, `LISTEN TRUE` (each `\n`).
+
+---
+
+## Layer separation
+
+| Layer | On data TCP | On air |
+|-------|-------------|--------|
+| AX.25 UI body | ✅ | Inside HDLC |
+| HDLC + CRC-16 | ❌ | ✅ |
+| Bell 202 AFSK | ❌ | ✅ |
+
+Same rule as KISS `0x00` DATA frames in `kiss_bridge.py`.
+
+---
+
+## Compliance tests
+
+| Test | Command |
+|------|---------|
+| Unit | `pytest stacks/daemon/test_crdop_backend.py` |
+| Bench host | `python3 stacks/crdop/tools/audio-dummyd.py --ctrl-port 8515 --data-port 8516` |
+| Loopback DSP | `pytest stacks/crdop/test_bell202_line_code.py` |
+
+---
+
+## Version
+
+Frozen for **MAX25-Stack v1.0.0** (dev track `CUR999`). Changes require bump in this file + `PROTOCOL.md` + `test_crdop_backend.py`.
diff --git a/stacks/crdop/docs/INDEX.md b/stacks/crdop/docs/INDEX.md
new file mode 100644
index 0000000..91a75b4
--- /dev/null
+++ b/stacks/crdop/docs/INDEX.md
@@ -0,0 +1,77 @@
+# CRDOP documentation index
+
+**CRDOP** = stack acronym for **MAX25-SoftModem** (device id `soft-crdop`). Product: **MAX25-SoftModem** (`stacks/crdop/`). Version: MAX25-Stack **v1.0.0**; dev track `CUR999` in `$SRC/stacks/crdop/VERSION`.
+
+**Project rule (authoritative):** [docs/CRDOP.md](../../../docs/CRDOP.md)
+**AI agent map:** [DEVELOPMENT.md](../../../docs/DEVELOPMENT.md) (repo root)
+
+---
+
+## Start here
+
+| Audience | Doc | One-line purpose |
+|----------|-----|------------------|
+| Everyone | [SOFTMODEM.md](SOFTMODEM.md) | What CRDOP is — baud scope, duplex, ALSA path |
+| MAX25 operator | [MAX25-USAGE.md](MAX25-USAGE.md) | Build, `max25d`, plugins, terminal, HyBBX attach |
+| Developer | [DEVELOPER.md](DEVELOPER.md) | Source tree, modules, tests, extending the modem |
+| Hardware builder | [HARDWARE-INTERFACE.md](HARDWARE-INTERFACE.md) | Generic radio/audio interface specification |
+| Legal / distribution | [LICENSE-USAGE.md](LICENSE-USAGE.md) | GPLv3 — private and commercial use rights |
+
+## Implementation specs (v1.0.0)
+
+| Doc | One-line purpose |
+|-----|------------------|
+| [HOST-PROTOCOL-SPEC.md](HOST-PROTOCOL-SPEC.md) | **Frozen** M25/KISS TCP :8515/:8516 — normative host wire |
+| [ACOUSTIC-TEST-PROTOCOL.md](ACOUSTIC-TEST-PROTOCOL.md) | P0 bench acceptance matrix |
+| [FEC-SPEC.md](FEC-SPEC.md) | Duplex + FEC strategy — implementable parameters |
+| [SOUNDCARD-QUALIFICATION.md](SOUNDCARD-QUALIFICATION.md) | Interface qualification tests (class A/B/C) |
+| [G3RUH-DESIGN.md](G3RUH-DESIGN.md) | P1 — 9600–19200 direct FSK (DEV-Level 4) |
+
+---
+
+## Technical reference
+
+| Doc | One-line purpose |
+|-----|------------------|
+| [AUDIO-ARCHITECTURE.md](AUDIO-ARCHITECTURE.md) | Kernel ALSA only — sound-proxy, no PulseAudio |
+| [PROTOCOL.md](PROTOCOL.md) | M25 host summary — **normative:** [HOST-PROTOCOL-SPEC.md](HOST-PROTOCOL-SPEC.md) |
+| [CONFIG.md](CONFIG.md) | `crdop.ini` keys and launcher behaviour |
+| [BUILD.md](BUILD.md) | Standalone CMake build and cross-compile |
+| [EXAMPLES.md](EXAMPLES.md) | INI profiles, launcher, `max25d` snippets |
+| [CHANGELOG.md](CHANGELOG.md) | Release notes |
+
+---
+
+## Planning
+
+| Doc | One-line purpose |
+|-----|------------------|
+| [ROADMAP.md](../ROADMAP.md) | P0/P1/P2 modulation, RF paths, FEC, delivery milestones |
+
+---
+
+## Plugin registry
+
+| Id | Type | Role |
+|----|------|------|
+| `soft-crdop` | device | Production sound-card modem (`soft-modems` hardware) |
+| `audio-dummy` | device | Acoustic bench — loopback / ALSA / host (`acoustic-bench`) |
+| `soft-modems` | hardware | CRDOP device family |
+| `acoustic-bench` | hardware | Dev/test bench — no RF required |
+| `ardop-plugin` | optional | Third-party ARDOP host registry (separate from CRDOP) |
+
+---
+
+## Install tree (MAX25-Stack build)
+
+| Path | Contents |
+|------|----------|
+| `bin/crdop` | Launcher (`scripts/crdopc`) |
+| `bin/audio-dummyd` | M25 host + acoustic engine |
+| `bin/max25-signal-sniffer` | Bell 202 analysis tool |
+| `share/crdop/` | INI examples, `lib/*.py`, `VERSION` |
+| `share/hybbx/crdop-host.ini.example` | HyBBX Secondary attach |
+
+---
+
+*Compact index — depth in linked docs. Roadmap: [ROADMAP.md](../ROADMAP.md).*
diff --git a/stacks/crdop/docs/LICENSE-USAGE.md b/stacks/crdop/docs/LICENSE-USAGE.md
new file mode 100644
index 0000000..84ad5ff
--- /dev/null
+++ b/stacks/crdop/docs/LICENSE-USAGE.md
@@ -0,0 +1,116 @@
+# CRDOP license and usage rights
+
+**CRDOP** (MAX25-SoftModem) is **free software** under the **GNU General Public License version 3** (GPL-3.0-or-later).
+
+License files:
+
+| File | Scope |
+|------|-------|
+| [stacks/crdop/LICENSE](../LICENSE) | CRDOP subproject |
+| [LICENSE](../../../LICENSE) | MainAX25-Stack repository root |
+
+---
+
+## Summary (not legal advice)
+
+GPLv3 grants everyone — **private individuals and commercial entities** — the right to:
+
+| Right | Meaning |
+|-------|---------|
+| **Use** | Run CRDOP for any purpose (personal, club, business, government) |
+| **Study** | Read and learn from source code |
+| **Modify** | Change code to suit your needs |
+| **Distribute** | Share originals or modified versions |
+| **Commercial use** | Sell services, products, or support built on CRDOP |
+
+**Copyleft obligation:** When you **distribute** CRDOP (or a combined work based on it), you must:
+
+1. Provide **corresponding source** (or a written offer valid ≥ 3 years).
+2. License derivatives under **GPLv3** (or later, per "or later" clause).
+3. Preserve copyright notices and license text.
+4. Document changes if you modify files.
+5. Pass through patent retaliation and anti-tivoization terms as GPLv3 requires.
+
+**No warranty:** Software is provided **AS IS** — no implied fitness for a particular purpose.
+
+---
+
+## Private use
+
+| Scenario | Obligation |
+|----------|------------|
+| Run CRDOP on your own computer | None beyond GPLv3 acceptance |
+| Modify for personal use only | No distribution → no source-offer obligation |
+| Internal company use (no binary redistribution) | Same as private — no copyleft trigger until you distribute |
+
+---
+
+## Commercial use
+
+| Scenario | Obligation |
+|----------|------------|
+| Sell pre-built PCs with CRDOP installed | Provide source (or written offer) to recipients |
+| Ship hardware interface **with** CRDOP binaries | GPL applies to distributed software; hardware schematics you create separately may use another license if they are not a derivative of the GPL code |
+| SaaS / hosted service (no binary to users) | AGPL would impose network copyleft — **CRDOP is GPL, not AGPL**; consult counsel for your deployment model |
+| OEM integration in a product | Ensure GPL compliance for all distributed GPL components; document how users obtain source |
+
+**Practical path:** Include `LICENSE`, offer source at your support URL or ship `stacks/crdop/` tree, and state "based on CRDOP (GPLv3)".
+
+---
+
+## Combined works (MAX25-Stack)
+
+MAX25-Stack bundles CRDOP with daemon, terminal, and plugins. Distribution of the **combined stack** triggers GPLv3 obligations for the GPL-licensed portions. Other components may carry their own licenses — check root `LICENSE` and per-directory notices.
+
+| Component | Typical license |
+|-----------|-----------------|
+| `stacks/crdop/` | GPL-3.0-or-later |
+| MAX25 daemon / terminal | GPL-3.0-or-later (repository default) |
+
+---
+
+## What you may not do
+
+| Restriction | Reason |
+|-------------|--------|
+| Relicense CRDOP under proprietary terms | GPLv3 copyleft |
+| Remove copyright / license notices | GPLv3 §4 |
+| Imply warranty or liability from authors | GPLv3 §15–16 |
+| Distribute without source (when distributing binaries) | GPLv3 §6 |
+
+Patent suits against GPL users can terminate your license (GPLv3 §8).
+
+---
+
+## ARDOP-plugin (separate)
+
+**ARDOP-plugin** is an optional MAX25-Stack registry entry — **not** part of the CRDOP GPLv3 tree.
+
+| Item | Policy |
+|------|--------|
+| CRDOP coupling | **None** — third-party ARDOP host software |
+| Registry | [plugins/external/ardop/README.md](../../../plugins/external/ardop/README.md) |
+
+CRDOP GPLv3 obligations apply to **CRDOP code**, not to operator ARDOP host software.
+
+---
+
+## Contributor grants
+
+By contributing to `stacks/crdop/`, you agree that contributions are licensed under **GPL-3.0-or-later**, consistent with the repository [CONTRIBUTING.md](../../../CONTRIBUTING.md).
+
+---
+
+## Full license text
+
+- Online: <https://www.gnu.org/licenses/gpl-3.0.html>
+- In tree: [stacks/crdop/LICENSE](../LICENSE) · [LICENSE](../../../LICENSE)
+
+---
+
+## Related
+
+| Doc | Topic |
+|-----|--------|
+| [docs/CRDOP.md](../../../docs/CRDOP.md) | Project rule §5 License |
+| [INDEX.md](INDEX.md) | Documentation index |
diff --git a/stacks/crdop/docs/MAX25-USAGE.md b/stacks/crdop/docs/MAX25-USAGE.md
new file mode 100644
index 0000000..616093c
--- /dev/null
+++ b/stacks/crdop/docs/MAX25-USAGE.md
@@ -0,0 +1,292 @@
+# CRDOP in MAX25-Stack — usage guide
+
+Complete operator and integrator workflow for **MAX25-SoftModem (CRDOP)** inside MainAX25-Stack.
+
+**CRDOP** = stack acronym for **MAX25-SoftModem** (device id `soft-crdop`). Project rule: [docs/CRDOP.md](../../../docs/CRDOP.md).
+
+**Versions:** MAX25-Stack **v1.0.0** · CRDOP dev track **CUR999** (`$SRC/stacks/crdop/VERSION`).
+
+**Dependency:** CRDOP sources are standalone-capable in principle, but **MAX25-Stack is required** for build, `max25d`, INI, operator tooling, and plugin registry until **CRDOP-v1.0.0** marks a mature standalone release.
+
+---
+
+## Quick start
+
+```bash
+# 1. Build (CRDOP ON by default)
+./scripts/build.sh
+
+# 2. Configure daemon
+sudo cp share/max25/max25d.ini.example /etc/max25/max25d.ini
+# Edit: enable soft-crdop block (see below)
+
+# 3. Start stack
+./scripts/max25-ctl start --hardware soft-modems --device soft-crdop
+
+# 4. Terminal session
+max25-terminal -U /run/max25/modem.sock
+# or TCP: max25-terminal -H 127.0.0.1 -P 7325
+```
+
+Inside terminal: `SET DEVICE soft-crdop` → `CONNECT` → `SEND …`
+
+---
+
+## Build and install
+
+### CMake options
+
+| Option | Default | Effect |
+|--------|---------|--------|
+| `MAX25_BUILD_CRDOP` | **ON** | Build/install CRDOP scaffold + tools |
+| `MAX25_BUILD_DAEMON` | ON | `max25d` with `CrdopTcpBackend` |
+| `MAX25_BUILD_TERMINAL` | ON | `max25-terminal` / `max25-client` |
+| `CRDOP_VENDOR_ARDOPCF` | OFF | Dev-only legacy ARDOP — **never** in releases |
+
+Disable CRDOP only when not needed:
+
+```bash
+cmake -B build -DMAX25_BUILD_CRDOP=OFF
+cmake --build build -j$(nproc)
+```
+
+### Install tree
+
+After `cmake --install` (or `./scripts/build.sh` + local prefix):
+
+| Path | Contents |
+|------|----------|
+| `bin/crdop` | Launcher — starts `audio-dummyd` with INI |
+| `bin/audio-dummyd` | M25 host TCP daemon + acoustic engine |
+| `bin/max25-signal-sniffer` | Bell 202 analysis |
+| `share/crdop/crdop.ini.example` | CB profile template |
+| `share/crdop/crdop-dual.ini.example` | CB ↔ amateur preset |
+| `share/crdop/crdop-amateur.ini.example` | Amateur secondary preset |
+| `share/crdop/lib/*.py` | DSP modules (installed for tooling) |
+| `share/crdop/VERSION` | `CUR999` |
+| `share/hybbx/crdop-host.ini.example` | HyBBX Secondary attach |
+| `share/clients/soft-crdop.yaml` | Terminal device profile |
+
+---
+
+## Plugin model
+
+| Plugin id | Type | Hardware family | Role |
+|-----------|------|-----------------|------|
+| `soft-crdop` | device | `soft-modems` | Production sound-card modem |
+| `audio-dummy` | device | `acoustic-bench` | Bench — loopback / ALSA / host |
+| `soft-modems` | hardware | — | CRDOP device family |
+| `acoustic-bench` | hardware | — | Dev/test without RF |
+
+Registry: `plugins/manifest.yaml`. Discovery:
+
+```bash
+./scripts/discover-plugins.sh --json | jq '.devices[] | select(.id|test("crdop|audio-dummy"))'
+```
+
+---
+
+## max25d configuration
+
+### Production path — `soft-crdop`
+
+```ini
+[daemon]
+hardware = soft-modems
+device = soft-crdop
+
+[devices]
+default = soft-crdop
+soft-crdop = crdop:default
+
+[device.soft-crdop]
+host = 127.0.0.1
+port = 8515
+listen = yes
+```
+
+`max25-ctl start` launches `crdop` (→ `audio-dummyd`) when `auto_start = yes` in `[stack]`.
+
+### Bench path — `audio-dummy`
+
+No RF; validates DSP loopback or ALSA capture:
+
+```ini
+[daemon]
+hardware = acoustic-bench
+device = audio-dummy
+
+[devices]
+default = audio-dummy
+audio-dummy = audio:loopback
+```
+
+Modes for `audio-dummy` spec:
+
+| Spec | Behaviour |
+|------|-----------|
+| `audio:loopback` | Internal DSP encode/decode |
+| `audio:alsa:plughw:N,M` | Sniff live ALSA capture |
+| `audio:host` | Attach to running `audio-dummyd` on :8515 |
+
+Start bench:
+
+```bash
+./scripts/max25-ctl start --hardware acoustic-bench --device audio-dummy
+max25-terminal -U /run/max25/modem.sock
+```
+
+### Sniffer (no daemon)
+
+```bash
+max25-signal-sniffer --loopback
+max25-signal-sniffer -D plughw:1,0 -t 3.0
+max25-signal-sniffer --mark # 1200 Hz calibration tone
+max25-signal-sniffer --space # 2200 Hz calibration tone
+```
+
+---
+
+## crdop.ini (modem side)
+
+Copy and edit:
+
+```bash
+mkdir -p ~/.config/crdop
+cp share/crdop/crdop.ini.example ~/.config/crdop/crdop.ini
+```
+
+Key sections — full reference: [CONFIG.md](CONFIG.md).
+
+```ini
+[profile]
+radio_profile = cb ; cb | dual | amateur
+
+[modem]
+duplex = half ; half | full
+arq_bandwidth = 500MAX
+
+[mycall]
+call = CB01-0
+
+[audio]
+backend = alsa-kernel
+no_pulse = yes
+capture = plughw:1,0
+playback = plughw:1,0
+
+[host]
+port = 8515
+```
+
+Launch:
+
+```bash
+CRDOP_INI=~/.config/crdop/crdop.ini ./scripts/crdopc
+# or after install:
+crdop
+```
+
+---
+
+## max25-terminal / M25/1
+
+CRDOP uses the same M25/1 session flow as other `max25d` device backends:
+
+```
+SET DEVICE soft-crdop
+CONNECT
+SEND Hello packet world
+```
+
+| Item | Value |
+|------|-------|
+| Daemon socket | `/run/max25/modem.sock` (Linux) |
+| Daemon TCP | `7325` (configurable in `max25d.ini`) |
+| Modem host TCP | `8515` ctrl, `8516` data (CRDOP native) |
+| Client profile | `share/clients/soft-crdop.yaml` |
+
+Protocol reference: [PROTOCOL.md](PROTOCOL.md) · [include/max25/protocol.md](../../../include/max25/protocol.md).
+
+When `ax25_ui = yes` in `max25d.ini`, terminal `SEND` lines encode as AX.25 UI frames.
+
+---
+
+## HyBBX attach
+
+HyBBX is **external** — attach after MAX25 stack is up.
+
+1. Start CRDOP path:
+
+ ```bash
+ ./scripts/max25-ctl start --hardware soft-modems --device soft-crdop
+ ```
+
+2. Merge `share/hybbx/crdop-host.ini.example` into HyBBX `hybbx.ini` on Secondary:
+
+ ```ini
+ [networks]
+ crdop = yes
+
+ [transport.crdop1]
+ enabled = yes
+ modem_host = 127.0.0.1
+ modem_port = 8515
+ mycall = CB-0
+ listen = yes
+ circuit_host = main.example.com
+ circuit_port = 7323
+ ```
+
+MAX25 owns modem lifecycle; HyBBX owns sessions. See [docs/HYBBX.md](../../../docs/HYBBX.md).
+
+---
+
+## Operator profiles
+
+| INI template | Profile | Use |
+|--------------|---------|-----|
+| `crdop.ini.example` | CB | Primary — K24/K25 class channels |
+| `crdop-dual.ini.example` | dual | CB ↔ amateur switching |
+| `crdop-amateur.ini.example` | amateur | Amateur secondary |
+
+```bash
+CRDOP_INI=share/crdop-dual.ini.example ./scripts/crdopc
+```
+
+---
+
+## Testing in MAX25-Stack
+
+```bash
+./scripts/test.sh # cmake --build build --target max25_test
+cmake --build build --target max25_daemon_smoke
+./scripts/release-check.sh # install + policy checks
+```
+
+CRDOP-specific offline tests: `test_crdop_backend.py`, `test_audio_dummy_backend.py`, `test_bell202_line_code.py`.
+
+---
+
+## Troubleshooting
+
+| Symptom | Check |
+|---------|-------|
+| `crdopc not found` | Run `./scripts/build.sh`; launcher uses `audio-dummyd` from install or source tree |
+| No decode | `max25-signal-sniffer --loopback`; verify ALSA devices in INI |
+| PulseAudio hijack | `no_pulse = yes`; use `hw:` / `plughw:` explicitly |
+| Port conflict | Default :8515/:8516 — change `[host] port` + `[device.soft-crdop] port` |
+| Terminal no device | `SET DEVICE soft-crdop`; check `[devices]` in `max25d.ini` |
+
+---
+
+## Related
+
+| Doc | Topic |
+|-----|--------|
+| [INDEX.md](INDEX.md) | Full doc table |
+| [DEVELOPER.md](DEVELOPER.md) | Source, modules, extending |
+| [HARDWARE-INTERFACE.md](HARDWARE-INTERFACE.md) | Radio interface spec |
+| [docs/PLUGINS-DEVICE-MODEL.md](../../../docs/PLUGINS-DEVICE-MODEL.md) | Unified device workflow |
+| [docs/LINUX-HOST-SETUP.md](../../../docs/LINUX-HOST-SETUP.md) | Host prerequisites |
+| [ROADMAP.md](../ROADMAP.md) | P0/P1/P2 milestones and modulation priorities |
diff --git a/stacks/crdop/docs/PROTOCOL.md b/stacks/crdop/docs/PROTOCOL.md
new file mode 100644
index 0000000..2d28bd4
--- /dev/null
+++ b/stacks/crdop/docs/PROTOCOL.md
@@ -0,0 +1,36 @@
+# Protocol
+
+Normative wire: **[HOST-PROTOCOL-SPEC.md](HOST-PROTOCOL-SPEC.md)** (frozen v1.0.0).
+
+CRDOP (**MAX25-SoftModem**) uses the **native MAX25 M25/KISS host protocol** on TCP :8515 (control) / :8516 (data).
+
+## Stack (default)
+
+```
+[Radio] ↔ [audio/ALSA] ↔ audio-dummyd / native modem ↔ TCP :8515 ctrl, :8516 data ↔ max25d
+```
+
+## Over-the-air
+
+AX.25-compatible AFSK tones (1200 baud primary, up to 19200 baud design range). See [SOFTMODEM.md](SOFTMODEM.md).
+
+## Host TCP (native M25)
+
+| Port | Role |
+|------|------|
+| 8515 | Control (line-oriented ASCII, `\n` terminated) |
+| 8516 | Data (AX.25 UI body without HDLC/FCS) |
+
+Common commands: `INITIALIZE` · `PROTOCOLMODE KISS` · `MYCALL` · `LISTEN` · `STATUS` · `PING`
+
+Implementation reference: `stacks/crdop/lib/m25_host_protocol.py`
+
+## CRDOP launch presets
+
+| Profile | Role |
+|---------|------|
+| `cb` | Default — CB / 500MAX class |
+| `dual` | CB ↔ amateur turnaround |
+| `amateur` | Amateur bandwidth class |
+
+Runtime overrides via host TCP or `crdop.ini` always apply.
diff --git a/stacks/crdop/docs/README.md b/stacks/crdop/docs/README.md
new file mode 100644
index 0000000..4a763a2
--- /dev/null
+++ b/stacks/crdop/docs/README.md
@@ -0,0 +1,31 @@
+# CRDOP documentation
+
+**CRDOP** = stack acronym for **MAX25-SoftModem** (device id `soft-crdop`).
+
+**MAX25-SoftModem** — in-house sound-card modem subproject (`stacks/crdop/`).
+
+## Entry point
+
+**[INDEX.md](INDEX.md)** — complete table of all CRDOP docs with one-line purpose.
+
+Project rule: [docs/CRDOP.md](../../../docs/CRDOP.md)
+
+## Policy
+
+| Rule | |
+|------|--|
+| Standard | Native MAX25 M25/KISS host on TCP :8515/:8516 |
+| Focus | CB-first; `dual` and `amateur` are presets |
+| Scope | Modem only — no host application |
+
+## Dev-only vendor tree
+
+Legacy **[pflarue/ardop](https://github.com/pflarue/ardop)** (ardopcf, MIT) may exist under `vendor/ardopcf/` for local dev builds (`-DCRDOP_VENDOR_ARDOPCF=ON`). **Never installed in MAX25 releases.**
+
+Pin: `a7c9228` · record: `vendor/ardopcf.ref`
+
+## Platforms (tested)
+
+Linux · FreeBSD — **tested** (GCC, Clang)
+
+Windows · macOS — build supported, **not yet tested**
diff --git a/stacks/crdop/docs/SOFTMODEM.md b/stacks/crdop/docs/SOFTMODEM.md
new file mode 100644
index 0000000..fef0a0e
--- /dev/null
+++ b/stacks/crdop/docs/SOFTMODEM.md
@@ -0,0 +1,127 @@
+# MAX25-SoftModem — product definition
+
+> **Authoritative rule:** [docs/CRDOP.md](../../../docs/CRDOP.md) · **Doc index:** [INDEX.md](INDEX.md)
+
+## Name and role
+
+- **Product:** MAX25-SoftModem (stack acronym **CRDOP**; plugin id `soft-crdop`)
+- **Stack version:** MAX25-Stack **v1.0.0** · **Dev track:** `CUR999` in `$SRC/stacks/crdop/VERSION`
+- **Stack:** MainAX25-Stack — **standard** component (built/installed unless `MAX25_BUILD_CRDOP=OFF`)
+- **Nature:** Pure MAX25 in-house development — in **development and test phase**, openly documented
+
+## Use like a hardware modem
+
+CRDOP uses the host **sound card IN and OUT** plus a **suitable radio** (or acoustic coupling to existing RF hardware). In `max25d` it registers like any other device backend — operators use the same M25/1 flow (`SET DEVICE`, `CONNECT`, `SEND`).
+
+```
+┌──────────┐ line or acoustic ┌─────────────┐ TCP ┌─────────┐
+│ Radio │ ◄──────────────────► │ CRDOP/ALSA │ ◄──────► │ max25d │
+└──────────┘ └─────────────┘ └─────────┘
+ │
+ 1200–19200 baud
+ half or full duplex
+```
+
+### Acoustic AX.25 compatibility
+
+On the **acoustic / RF layer**, CRDOP targets **AX.25 Packet Radio** interoperability at the PHY level:
+
+- **1200 baud Bell 202 AFSK** (1200/2200 Hz) — standard VHF/UHF and CB packet class
+- **Half- and full-duplex** per radio and audio capability
+- **Acoustic or line-level** coupling — valid test and field configuration
+
+CRDOP is an **alternative modem** when the sound card is the RF interface. It does not replace an existing UART/KISS path when that path is already in use.
+
+Interface specification for builders: [HARDWARE-INTERFACE.md](HARDWARE-INTERFACE.md). MAX25-Stack usage: [MAX25-USAGE.md](MAX25-USAGE.md).
+
+## Protocol stack
+
+| Layer | Policy |
+|-------|--------|
+| On-air / acoustic | AX.25-compatible AFSK tones (1200 baud primary) |
+| Host to max25d | MAX25-native TCP control + data (see [PROTOCOL.md](PROTOCOL.md)) |
+
+We study legacy stacks; we implement our own modem and document it for **reproducible hardware builds**.
+
+## Sound path — kernel ALSA only
+
+CRDOP does **not** use PulseAudio, PipeWire-as-default, or any desktop sound server.
+
+| Layer | Function |
+|-------|----------|
+| **MAX25 sound-proxy** | Sole shim between modem DSP and ALSA — buffers, timing, duplex, xrun recovery |
+| **libasound** | Direct `hw:` / `plughw:` opens |
+| **Kernel ALSA** | Driver + `/dev/snd/*` |
+| **Hardware** | Sound card → radio interface |
+
+Configure explicit devices in `crdop.ini` `[audio]` (`capture`, `playback`). See [AUDIO-ARCHITECTURE.md](AUDIO-ARCHITECTURE.md).
+
+## Baud scope
+
+| Priority | Range | Status |
+|----------|-------|--------|
+| **P0** | **1200 baud** Bell 202 AFSK | Primary — CB + VHF on-air |
+| **P1** | **9600–19200** G3RUH direct FSK | Planned after 1200 stable (VHF backbone) |
+| **P2** | **300** HF AFSK | Optional |
+| — | **>19200 baud** | **Not implemented** — do not configure or expect |
+
+Full modulation matrix, RF paths, band context, delivery milestones: **[ROADMAP.md](../ROADMAP.md)**.
+
+Higher baud rates demand **proportionally better** audio hardware (sample rate, jitter, SNR, isolation). G3RUH requires a **direct FSK radio path** — not the CB FM mic/speaker route.
+
+## Duplex
+
+| Mode | Status |
+|------|--------|
+| **Half-duplex** | Standard CB / packet-radio (PTT or VOX-style timing) |
+| **Full-duplex** | Supported when radio and audio hardware allow simultaneous TX/RX paths |
+
+Configure in `crdop.ini`: `[modem] duplex = half|full`.
+
+## Sound card — required (not optional)
+
+CRDOP must:
+
+1. **Generate** precise mark/space frequencies on **playback**.
+2. **Decode** those tones reliably on **capture**.
+
+Requirements **scale with baud**:
+
+| Baud class | Audio expectation |
+|------------|-------------------|
+| 1200 | Stable full-duplex or half-duplex USB codec or PCI interface; galvanic isolation to radio |
+| 9600–19200 | Low-jitter interface, adequate bandwidth, verified loopback before on-air |
+
+**Unsuitable:** onboard laptop mic/speaker shortcuts, anonymous USB “sound sticks”, Bluetooth bridges, unisolated consumer line inputs.
+
+Document your ALSA device in INI (`[audio] capture` / `playback`) and verify with loopback or sidetone before traffic.
+
+## Open hardware
+
+Documentation targets builders who want to **manufacture radio ↔ sound-card interfaces**:
+
+- Level matching (radio mic/speaker ↔ line level)
+- Galvanic isolation (transformers / opto)
+- PTT integration (GPIO, serial — VOX not recommended for packet)
+- Acoustic coupler fixtures for bench validation
+
+Generic interface specification: [HARDWARE-INTERFACE.md](HARDWARE-INTERFACE.md). Delivery milestones: [ROADMAP.md](../ROADMAP.md).
+
+## Build / release (MAX25-Stack-v1.0.0)
+
+**CRDOP dev track:** `CUR999` in `$SRC/stacks/crdop/VERSION` (ships with MAX25-Stack v1.0.0).
+
+| Item | Shipped |
+|------|---------|
+| CRDOP scaffold (INI, launcher, install) | **Yes** — default CMake |
+| `max25d` `crdop-tcp` backend | **Yes** — offline-tested |
+| Native `crdopc` DSP binary | **In development** — in-house modem DSP |
+| ARDOP / ardopcf vendor tree | **Never** in release tarball |
+
+## HyBBX
+
+When CRDOP is active, HyBBX Secondary attaches via `share/hybbx/crdop-host.ini.example`. HyBBX remains an external project.
+
+## Testing phase
+
+Current work: loopback audio, acoustic bench validation, field on-air trials, acceptable sound-card criteria. Report results via project issues — RF acceptance remains manual until automated gates exist. Developer tests: [DEVELOPER.md](DEVELOPER.md).
diff --git a/stacks/crdop/docs/SOUNDCARD-QUALIFICATION.md b/stacks/crdop/docs/SOUNDCARD-QUALIFICATION.md
new file mode 100644
index 0000000..b35d55b
--- /dev/null
+++ b/stacks/crdop/docs/SOUNDCARD-QUALIFICATION.md
@@ -0,0 +1,95 @@
+# Sound card qualification — CRDOP / MAX25-SoftModem
+
+**Purpose:** Measurable criteria for interface hardware used with CRDOP. No product endorsements — class-based requirements.
+
+See also [ROADMAP.md](../ROADMAP.md) (P0/P1 audio requirements).
+
+---
+
+## Mandatory architecture
+
+```
+CRDOP DSP → sound_proxy → libasound (hw:) → codec → radio interface
+```
+
+| Forbidden in production path | Reason |
+|------------------------------|--------|
+| PulseAudio default route | Mixing, resampling, latency |
+| PipeWire default | Variable buffer |
+| Bluetooth A2DP/HFP | Codec + AGC |
+
+INI: `[audio] backend = alsa-kernel`, `no_pulse = yes`.
+
+---
+
+## Sample rate
+
+| Baud target | Minimum rate | CRDOP default |
+|-------------|--------------|---------------|
+| 1200 AFSK | 48000 Hz | **48000** |
+| 9600 G3RUH (audio tap) | 48000–96000 | 48000 + tuning |
+| 19200 | 96000 Hz | 96000 if hardware supports |
+
+---
+
+## Qualification tests
+
+Run on candidate interface **before** field deployment.
+
+| ID | Test | Pass |
+|----|------|------|
+| **Q1** | Loopback cable, 1200 Hz sine 2 s | THD acceptable by ear + FFT peak at 1200 ±2 Hz |
+| **Q2** | Loopback Bell 202 sniffer | `max25-signal-sniffer --loopback` or ALSA loop |
+| **Q3** | Mark/space calibration tones | `--mark` / `--space` tools within ±5 Hz |
+| **Q4** | 60 s full-duplex record+play | No xrun in `dmesg` / ALSA log |
+| **Q5** | Clock drift | 10 min loopback: no cumulative symbol slip at 1200 bd |
+
+---
+
+## Interface classes
+
+| Class | Isolation | 1200 AFSK | 19200 path |
+|-------|-----------|-----------|------------|
+| **A** | Galvanic line I/O, fixed gain | Required minimum | Test Q1–Q5 |
+| **B** | USB codec, headphone/mic level | Acceptable with level match | Not recommended |
+| **C** | Onboard mic/speaker only | Acoustic bench only | Fail |
+
+CRDOP production path: **Class A or B** with line-level radio interface — see [HARDWARE-INTERFACE.md](HARDWARE-INTERFACE.md).
+
+---
+
+## Level guidelines
+
+| Path | Typical level |
+|------|---------------|
+| Line OUT → radio mic in | 100–300 mV RMS (adjust pad) |
+| Radio speaker/line → Line IN | Attenuate to avoid clipping |
+| DC offset | AC coupling required |
+
+---
+
+## Baud scaling
+
+Requirements **rise faster than linear** above 1200 bd:
+
+| Baud | Clock jitter tolerance | Filter flatness |
+|------|------------------------|-----------------|
+| 1200 | moderate | 300–3000 Hz FM audio path OK |
+| 9600 | strict | 10 Hz–5 kHz flat, constant group delay |
+| 19200 | professional | Direct FSK tap — not soundcard mic path |
+
+---
+
+## Failure actions
+
+| Symptom | Action |
+|---------|--------|
+| xruns | Increase `period_frames`; reduce system load |
+| Wrong tones | Verify 48000 Hz; check resampler not inserted |
+| Intermittent decode | Replace Class C with A/B; check ground loop |
+
+---
+
+## Community list (planned)
+
+P0 bench exit: publish operator-submitted **class + Q1–Q2 result** table — no vendor ranking.
diff --git a/stacks/crdop/include/crdop/sound_proxy.h b/stacks/crdop/include/crdop/sound_proxy.h
new file mode 100644
index 0000000..ce0efcb
--- /dev/null
+++ b/stacks/crdop/include/crdop/sound_proxy.h
@@ -0,0 +1,52 @@
+/*
+ * MAX25 sound-proxy — CRDOP ↔ kernel ALSA (no PulseAudio).
+ *
+ * In-process shim between modem DSP and libasound. Only this layer opens
+ * snd_pcm capture/playback on hw:/plughw: devices. max25d does not touch audio.
+ *
+ * Eigenentwicklung — API stable for v1 scaffold; implementation in progress.
+ */
+#ifndef MAX25_CRDOP_SOUND_PROXY_H
+#define MAX25_CRDOP_SOUND_PROXY_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/** ALSA backend policy — kernel path only; no PulseAudio/PipeWire route. */
+#define MAX25_SOUND_BACKEND_ALSA_KERNEL "alsa-kernel"
+
+typedef struct max25_sound_proxy max25_sound_proxy_t;
+
+typedef struct max25_sound_config {
+ const char *capture_device; /* e.g. "hw:1,0" */
+ const char *playback_device; /* e.g. "hw:1,0" */
+ unsigned int sample_rate; /* Hz — modem-dependent */
+ unsigned int period_frames;
+ unsigned int buffer_frames;
+ int full_duplex; /* 0 = half, 1 = full */
+ int forbid_pulse; /* non-zero: reject pulse/pipewire pseudo devices */
+} max25_sound_config_t;
+
+/** Open ALSA via kernel path; returns NULL on failure (pulse device rejected if forbid_pulse). */
+max25_sound_proxy_t *max25_sound_proxy_open(const max25_sound_config_t *cfg);
+
+void max25_sound_proxy_close(max25_sound_proxy_t *sp);
+
+/** Capture PCM frames (RX path). Returns frames read or negative errno-style code. */
+int max25_sound_proxy_read(max25_sound_proxy_t *sp, int16_t *buf, size_t frames);
+
+/** Playback PCM frames (TX path). Returns frames written or negative code. */
+int max25_sound_proxy_write(max25_sound_proxy_t *sp, const int16_t *buf, size_t frames);
+
+/** Recover from xrun — modem may call between frames. */
+int max25_sound_proxy_recover(max25_sound_proxy_t *sp);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* MAX25_CRDOP_SOUND_PROXY_H */
diff --git a/stacks/crdop/include/crdop/version.h b/stacks/crdop/include/crdop/version.h
new file mode 100644
index 0000000..249ef32
--- /dev/null
+++ b/stacks/crdop/include/crdop/version.h
@@ -0,0 +1,8 @@
+#ifndef CRDOP_VERSION_H
+#define CRDOP_VERSION_H
+
+#define CRDOP_PRODUCT_NAME "CRDOP-CUR999"
+#define CRDOP_VERSION_STRING "CUR999"
+#define CRDOP_VENDOR_BASE "native"
+
+#endif
diff --git a/stacks/crdop/lib/__init__.py b/stacks/crdop/lib/__init__.py
new file mode 100644
index 0000000..2137436
--- /dev/null
+++ b/stacks/crdop/lib/__init__.py
@@ -0,0 +1 @@
+"""MAX25-SoftModem (CRDOP) — native DSP library (Bell 202 AFSK, HDLC, ALSA)."""
diff --git a/stacks/crdop/lib/acoustic_engine.py b/stacks/crdop/lib/acoustic_engine.py
new file mode 100644
index 0000000..47989ec
--- /dev/null
+++ b/stacks/crdop/lib/acoustic_engine.py
@@ -0,0 +1,89 @@
+"""
+Acoustic bench engine — modulate/demodulate/sniff without RF.
+
+Used by audio-dummy device and max25-signal-sniffer.
+"""
+from __future__ import annotations
+
+import sys
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Optional
+
+_LIB = Path(__file__).resolve().parent
+if str(_LIB) not in sys.path:
+ sys.path.insert(0, str(_LIB))
+
+from afsk_demodulator import AfskDemodulator # noqa: E402
+from afsk_modulator import AfskModulator # noqa: E402
+from bell202_line_code import TONE_MARK, TONE_SPACE # noqa: E402
+from hdlc_codec import build_hdlc_frame, parse_hdlc_stream # noqa: E402
+from sound_proxy import SoundConfig, create_sound_proxy # noqa: E402
+
+_ROOT = Path(__file__).resolve().parents[3]
+_DAEMON = _ROOT / "stacks" / "daemon"
+if str(_DAEMON) not in sys.path:
+ sys.path.insert(0, str(_DAEMON))
+from ax25_codec import ax25_build_ui, ax25_parse_ui # noqa: E402
+
+
+@dataclass
+class SniffReport:
+ samples: int = 0
+ symbols: int = 0
+ mark_ratio: float = 0.0
+ space_ratio: float = 0.0
+ transitions: int = 0
+ frames: list[bytes] = field(default_factory=list)
+ decode_lines: list[str] = field(default_factory=list)
+
+
+class AcousticEngine:
+ def __init__(
+ self,
+ sample_rate: int = 48000,
+ baud: int = 1200,
+ sound: Optional[SoundConfig] = None,
+ ) -> None:
+ self.sample_rate = sample_rate
+ self.baud = baud
+ self.mod = AfskModulator(sample_rate, baud)
+ self.demod = AfskDemodulator(sample_rate, baud)
+ self.sound = sound or SoundConfig(sample_rate=sample_rate)
+
+ def encode_ax25_ui(self, src: str, dst: str, text: str) -> bytes:
+ body = ax25_build_ui(src, dst, text.encode("utf-8"))
+ # strip FCS for KISS-style host body, then build on-air HDLC
+ if len(body) >= 2:
+ body = body[:-2]
+ hdlc = build_hdlc_frame(body)
+ return self.mod.modulate_bits(hdlc)
+
+ def analyze_pcm(self, pcm: bytes) -> SniffReport:
+ rep = SniffReport(samples=len(pcm) // 2)
+ tones = self.demod.demodulate_pcm(pcm)
+ rep.symbols = len(tones)
+ if not tones:
+ return rep
+ marks = sum(1 for t in tones if t == TONE_MARK)
+ rep.mark_ratio = marks / len(tones)
+ rep.space_ratio = 1.0 - rep.mark_ratio
+ rep.transitions = sum(1 for i in range(1, len(tones)) if tones[i] != tones[i - 1])
+ raw_bits = self.demod.demodulate_to_bits(pcm)
+ for frame in parse_hdlc_stream(raw_bits):
+ rep.frames.append(frame)
+ parsed = ax25_parse_ui(frame)
+ if parsed:
+ src, dst, info = parsed
+ rep.decode_lines.append(f"[AX25 UI {src}>{dst}] {info.decode('utf-8', errors='replace')}")
+ return rep
+
+ def loopback_self_test(self, src: str = "TST-0", dst: str = "QST") -> SniffReport:
+ pcm = self.encode_ax25_ui(src, dst, "LOOP")
+ return self.analyze_pcm(pcm)
+
+ def play_mark_calibration(self, seconds: float = 1.0) -> None:
+ create_sound_proxy(self.sound).play_pcm(self.mod.steady_tone(TONE_MARK, seconds))
+
+ def play_space_calibration(self, seconds: float = 1.0) -> None:
+ create_sound_proxy(self.sound).play_pcm(self.mod.steady_tone(TONE_SPACE, seconds))
diff --git a/stacks/crdop/lib/afsk_demodulator.py b/stacks/crdop/lib/afsk_demodulator.py
new file mode 100644
index 0000000..5ba3d8b
--- /dev/null
+++ b/stacks/crdop/lib/afsk_demodulator.py
@@ -0,0 +1,56 @@
+"""
+AFSK demodulator — Bell 202 mark/space discrimination per symbol period.
+
+Uses per-symbol Goertzel energy at 1200 Hz and 2200 Hz (Dire Wolf class approach).
+"""
+from __future__ import annotations
+
+import math
+import struct
+from array import array
+
+from bell202_line_code import TONE_MARK, TONE_SPACE, decode_tones_to_bits
+
+_TWO_PI = 2.0 * math.pi
+
+
+def _goertzel_power(samples: array, freq: float, sample_rate: int) -> float:
+ n = len(samples)
+ if n < 8:
+ return 0.0
+ k = int(0.5 + (n * freq) / sample_rate)
+ w = _TWO_PI * k / n
+ coeff = 2.0 * math.cos(w)
+ s0 = s1 = s2 = 0.0
+ for x in samples:
+ s0 = x + coeff * s1 - s2
+ s2 = s1
+ s1 = s0
+ return s1 * s1 + s2 * s2 - coeff * s1 * s2
+
+
+class AfskDemodulator:
+ def __init__(self, sample_rate: int = 48000, baud: int = 1200) -> None:
+ self.sample_rate = sample_rate
+ self.baud = baud
+ self.samples_per_symbol = sample_rate // baud
+
+ def demodulate_pcm(self, pcm: bytes) -> list[int]:
+ """Return mark/space tone sequence from mono S16_LE PCM."""
+ count = len(pcm) // 2
+ if count < self.samples_per_symbol:
+ return []
+ samples = array("h")
+ samples.frombytes(pcm[: count * 2])
+ tones: list[int] = []
+ pos = 0
+ while pos + self.samples_per_symbol <= len(samples):
+ window = samples[pos : pos + self.samples_per_symbol]
+ pos += self.samples_per_symbol
+ p_mark = _goertzel_power(window, 1200.0, self.sample_rate)
+ p_space = _goertzel_power(window, 2200.0, self.sample_rate)
+ tones.append(TONE_MARK if p_mark >= p_space else TONE_SPACE)
+ return tones
+
+ def demodulate_to_bits(self, pcm: bytes, start_tone: int = TONE_SPACE) -> bytes:
+ return decode_tones_to_bits(self.demodulate_pcm(pcm), start_tone=start_tone)
diff --git a/stacks/crdop/lib/afsk_modulator.py b/stacks/crdop/lib/afsk_modulator.py
new file mode 100644
index 0000000..1a6bf68
--- /dev/null
+++ b/stacks/crdop/lib/afsk_modulator.py
@@ -0,0 +1,51 @@
+"""
+Continuous-phase AFSK modulator — Bell 202 mark 1200 Hz / space 2200 Hz.
+"""
+from __future__ import annotations
+
+import math
+import struct
+from array import array
+
+from bell202_line_code import MARK_HZ, SPACE_HZ, TONE_MARK, encode_bits_to_tones
+
+_TWO_PI = 2.0 * math.pi
+
+
+class AfskModulator:
+ def __init__(self, sample_rate: int = 48000, baud: int = 1200) -> None:
+ self.sample_rate = sample_rate
+ self.baud = baud
+ self._phase = 0.0
+
+ def _tone_freq(self, tone: int) -> float:
+ return MARK_HZ if tone == TONE_MARK else SPACE_HZ
+
+ def modulate_bits(self, bits: bytes, start_tone: int = 0) -> bytes:
+ """Return mono S16_LE PCM for the given bit stream."""
+ tones = encode_bits_to_tones(bits, start_tone=start_tone)
+ samples_per_symbol = self.sample_rate // self.baud
+ out: array[int] = array("h")
+ for tone in tones:
+ freq = self._tone_freq(tone)
+ step = _TWO_PI * freq / self.sample_rate
+ for _ in range(samples_per_symbol):
+ sample = int(0.7 * 32767.0 * math.sin(self._phase))
+ out.append(sample)
+ self._phase += step
+ if self._phase >= _TWO_PI:
+ self._phase -= _TWO_PI
+ return struct.pack(f"<{len(out)}h", *out)
+
+ def steady_tone(self, tone: int, duration_s: float) -> bytes:
+ """Calibration tone (Dire Wolf -x style mark/space hold)."""
+ n = int(self.sample_rate * duration_s)
+ freq = self._tone_freq(tone)
+ step = _TWO_PI * freq / self.sample_rate
+ out: array[int] = array("h")
+ for _ in range(n):
+ out.append(int(0.7 * 32767.0 * math.sin(self._phase)))
+ self._phase += step
+ if self._phase >= _TWO_PI:
+ self._phase -= _TWO_PI
+ return struct.pack(f"<{len(out)}h", *out)
diff --git a/stacks/crdop/lib/bell202_line_code.py b/stacks/crdop/lib/bell202_line_code.py
new file mode 100644
index 0000000..cd2621f
--- /dev/null
+++ b/stacks/crdop/lib/bell202_line_code.py
@@ -0,0 +1,50 @@
+"""
+Bell 202 frequency-toggle line code (1200 baud AFSK layer).
+
+On-air rule: bit 0 → toggle mark/space tone; bit 1 → hold current tone.
+We avoid the legacy term in API names — this is Bell 202 / amateur packet radio.
+"""
+from __future__ import annotations
+
+MARK_HZ = 1200.0
+SPACE_HZ = 2200.0
+BAUD_1200 = 1200
+
+TONE_MARK = 1
+TONE_SPACE = 0
+
+
+def encode_bits_to_tones(bits: bytes, start_tone: int = TONE_SPACE) -> list[int]:
+ """Map HDLC bit stream to mark/space tone sequence (one tone per bit)."""
+ tone = start_tone
+ out: list[int] = []
+ for byte in bits:
+ for bit_i in range(7, -1, -1):
+ bit = (byte >> bit_i) & 1
+ if bit == 0:
+ tone ^= 1
+ out.append(tone)
+ return out
+
+
+def decode_tones_to_bits(tones: list[int], start_tone: int = TONE_SPACE) -> bytes:
+ """Recover bit stream from mark/space tone sequence."""
+ if not tones:
+ return b""
+ prev = start_tone
+ bits: list[int] = []
+ for tone in tones:
+ if tone not in (TONE_MARK, TONE_SPACE):
+ continue
+ bits.append(0 if tone != prev else 1)
+ prev = tone
+ buf = bytearray()
+ for i in range(0, len(bits), 8):
+ chunk = bits[i : i + 8]
+ if len(chunk) < 8:
+ break
+ val = 0
+ for b in chunk:
+ val = (val << 1) | b
+ buf.append(val)
+ return bytes(buf)
diff --git a/stacks/crdop/lib/hdlc_codec.py b/stacks/crdop/lib/hdlc_codec.py
new file mode 100644
index 0000000..942168b
--- /dev/null
+++ b/stacks/crdop/lib/hdlc_codec.py
@@ -0,0 +1,110 @@
+"""
+HDLC framing for AX.25 — flags, bit-stuffing, CRC-16-CCITT.
+"""
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+_ROOT = Path(__file__).resolve().parents[3]
+_DAEMON = _ROOT / "stacks" / "daemon"
+if str(_DAEMON) not in sys.path:
+ sys.path.insert(0, str(_DAEMON))
+
+from ax25_codec import ax25_crc # noqa: E402
+
+HDLC_FLAG = 0x7E
+HDLC_ESCAPE = 0x7D
+HDLC_XOR = 0x20
+
+
+def bit_stuff(data: bytes) -> bytes:
+ out = bytearray()
+ ones = 0
+ for b in data:
+ for bit_i in range(7, -1, -1):
+ bit = (b >> bit_i) & 1
+ if bit:
+ ones += 1
+ out.append(1)
+ if ones == 5:
+ out.append(0)
+ ones = 0
+ else:
+ ones = 0
+ out.append(0)
+ # pack bits to bytes MSB-first
+ buf = bytearray()
+ acc = 0
+ n = 0
+ for bit in out:
+ acc = (acc << 1) | bit
+ n += 1
+ if n == 8:
+ buf.append(acc)
+ acc = 0
+ n = 0
+ if n:
+ buf.append(acc << (8 - n))
+ return bytes(buf)
+
+
+def bit_unstuff(data: bytes) -> bytes:
+ bits: list[int] = []
+ for b in data:
+ for bit_i in range(7, -1, -1):
+ bits.append((b >> bit_i) & 1)
+ out: list[int] = []
+ ones = 0
+ i = 0
+ while i < len(bits):
+ bit = bits[i]
+ if bit:
+ ones += 1
+ out.append(1)
+ if ones == 5 and i + 1 < len(bits) and bits[i + 1] == 0:
+ i += 1
+ ones = 0
+ elif ones == 6:
+ break
+ else:
+ ones = 0
+ out.append(0)
+ i += 1
+ buf = bytearray()
+ for j in range(0, len(out), 8):
+ chunk = out[j : j + 8]
+ if len(chunk) < 8:
+ break
+ val = 0
+ for bit in chunk:
+ val = (val << 1) | bit
+ buf.append(val)
+ return bytes(buf)
+
+
+def build_hdlc_frame(ax25_body: bytes) -> bytes:
+ """AX.25 body (no FCS) → on-air HDLC bit stream as bytes (pre tone encoding)."""
+ crc = ax25_crc(ax25_body)
+ payload = ax25_body + bytes((crc & 0xFF, crc >> 8))
+ stuffed = bit_stuff(payload)
+ return bytes([HDLC_FLAG]) + stuffed + bytes([HDLC_FLAG])
+
+
+def parse_hdlc_stream(raw_bits: bytes) -> list[bytes]:
+ """Extract AX.25 bodies (with FCS) from demodulated byte stream between flags."""
+ frames: list[bytes] = []
+ in_frame = False
+ cur = bytearray()
+ for b in raw_bits:
+ if b == HDLC_FLAG:
+ if in_frame and cur:
+ unstuffed = bit_unstuff(bytes(cur))
+ if len(unstuffed) >= 2:
+ frames.append(unstuffed)
+ cur.clear()
+ in_frame = True
+ continue
+ if in_frame:
+ cur.append(b)
+ return frames
diff --git a/stacks/crdop/lib/m25_host_protocol.py b/stacks/crdop/lib/m25_host_protocol.py
new file mode 100644
index 0000000..9212336
--- /dev/null
+++ b/stacks/crdop/lib/m25_host_protocol.py
@@ -0,0 +1,142 @@
+"""
+MAX25 native host protocol for SoftModem / audio-dummy (design freeze).
+
+Payload on data channel = AX.25 UI body **without** HDLC/FCS (same as KISS DATA).
+Control channel = line-oriented ASCII commands (M25-family, not ARDOP FEC/ARQ).
+"""
+from __future__ import annotations
+
+import socket
+import threading
+from typing import Callable, Optional
+
+# KISS-compatible data semantics; control is text lines ending in \\n
+DEFAULT_CTRL_PORT = 8515
+DEFAULT_DATA_PORT = 8516
+
+CmdFn = Callable[[str], str]
+DataRxFn = Callable[[bytes], None]
+
+
+class M25SoftModemHost:
+ """Minimal TCP host for bench / audio-dummy (ctrl + data ports)."""
+
+ def __init__(
+ self,
+ ctrl_port: int = DEFAULT_CTRL_PORT,
+ data_port: int = DEFAULT_DATA_PORT,
+ on_data_tx: Optional[Callable[[bytes], str]] = None,
+ ) -> None:
+ self.ctrl_port = ctrl_port
+ self.data_port = data_port
+ self._on_data_tx = on_data_tx or (lambda _b: "OK")
+ self._stop = threading.Event()
+ self._ctrl_srv: Optional[socket.socket] = None
+ self._data_srv: Optional[socket.socket] = None
+ self._threads: list[threading.Thread] = []
+ self._mycall = "NOCALL-0"
+ self._listen = True
+
+ def start(self) -> None:
+ self._ctrl_srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ self._ctrl_srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ self._ctrl_srv.bind(("127.0.0.1", self.ctrl_port))
+ self._ctrl_srv.listen(4)
+ self._data_srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ self._data_srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ self._data_srv.bind(("127.0.0.1", self.data_port))
+ self._data_srv.listen(4)
+ self._stop.clear()
+ for target, name in ((self._accept_ctrl, "ctrl"), (self._accept_data, "data")):
+ t = threading.Thread(target=target, name=f"m25-host-{name}", daemon=True)
+ t.start()
+ self._threads.append(t)
+
+ def stop(self) -> None:
+ self._stop.set()
+ for srv in (self._ctrl_srv, self._data_srv):
+ if srv is not None:
+ try:
+ srv.close()
+ except OSError:
+ pass
+ self._ctrl_srv = None
+ self._data_srv = None
+
+ def _accept_ctrl(self) -> None:
+ assert self._ctrl_srv is not None
+ while not self._stop.is_set():
+ try:
+ self._ctrl_srv.settimeout(0.5)
+ conn, _ = self._ctrl_srv.accept()
+ except (OSError, socket.timeout):
+ continue
+ threading.Thread(
+ target=self._ctrl_session,
+ args=(conn,),
+ daemon=True,
+ ).start()
+
+ def _ctrl_session(self, conn: socket.socket) -> None:
+ buf = b""
+ try:
+ conn.settimeout(0.5)
+ while not self._stop.is_set():
+ try:
+ chunk = conn.recv(4096)
+ except socket.timeout:
+ continue
+ if not chunk:
+ break
+ buf += chunk
+ while b"\n" in buf:
+ raw, buf = buf.split(b"\n", 1)
+ line = raw.decode("ascii", errors="replace").strip()
+ if not line:
+ continue
+ reply = self._handle_cmd(line)
+ conn.sendall((reply + "\n").encode("ascii"))
+ finally:
+ conn.close()
+
+ def _handle_cmd(self, line: str) -> str:
+ parts = line.split()
+ cmd = parts[0].upper() if parts else ""
+ if cmd == "INITIALIZE":
+ return "OK"
+ if cmd == "PROTOCOLMODE" and len(parts) > 1 and parts[1].upper() == "KISS":
+ return "OK"
+ if cmd == "MYCALL" and len(parts) > 1:
+ self._mycall = parts[1].upper()
+ return "OK"
+ if cmd == "LISTEN":
+ self._listen = len(parts) < 2 or parts[1].upper() in ("TRUE", "1", "ON", "YES")
+ return "OK"
+ if cmd == "PING":
+ return "OK"
+ if cmd == "STATUS":
+ return f"STATUS ready mycall={self._mycall}"
+ return "ERR unknown command"
+
+ def _accept_data(self) -> None:
+ assert self._data_srv is not None
+ while not self._stop.is_set():
+ try:
+ self._data_srv.settimeout(0.5)
+ conn, _ = self._data_srv.accept()
+ except (OSError, socket.timeout):
+ continue
+ threading.Thread(
+ target=self._data_session,
+ args=(conn,),
+ daemon=True,
+ ).start()
+
+ def _data_session(self, conn: socket.socket) -> None:
+ try:
+ payload = conn.recv(4096)
+ if payload:
+ reply = self._on_data_tx(payload)
+ conn.sendall(reply.encode("ascii", errors="replace"))
+ finally:
+ conn.close()
diff --git a/stacks/crdop/lib/sound_proxy.py b/stacks/crdop/lib/sound_proxy.py
new file mode 100644
index 0000000..de9b71d
--- /dev/null
+++ b/stacks/crdop/lib/sound_proxy.py
@@ -0,0 +1,185 @@
+"""
+MAX25 sound-proxy — host audio capture/playback.
+
+Linux/KLinux: ALSA (arecord/aplay).
+FreeBSD: OSS via sound_proxy_oss (sox or /dev/dsp).
+"""
+from __future__ import annotations
+
+import os
+import shutil
+import subprocess
+import sys
+import threading
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Callable, Optional, Protocol, runtime_checkable
+
+
+@dataclass
+class SoundConfig:
+ capture: str = "default"
+ playback: str = "default"
+ sample_rate: int = 48000
+ channels: int = 1
+ period_frames: int = 256
+ forbid_pulse: bool = True
+ backend: str = "" # alsa | oss — empty = auto from platform
+
+
+@runtime_checkable
+class SoundProxyProto(Protocol):
+ def start_capture(self) -> None: ...
+ def read_capture(self, nbytes: int) -> bytes: ...
+ def play_pcm(self, pcm: bytes) -> None: ...
+ def sniff_loop(
+ self,
+ chunk_symbols: int,
+ on_pcm: Callable[[bytes], None],
+ stop: Optional[threading.Event] = None,
+ ) -> None: ...
+ def close(self) -> None: ...
+
+
+def _detect_backend(cfg: SoundConfig) -> str:
+ explicit = (cfg.backend or os.environ.get("MAX25_AUDIO_BACKEND", "")).strip().lower()
+ if explicit in ("alsa", "oss"):
+ return explicit
+ if sys.platform.startswith("freebsd"):
+ return "oss"
+ return "alsa"
+
+
+def create_sound_proxy(cfg: SoundConfig) -> SoundProxyProto:
+ backend = _detect_backend(cfg)
+ if backend == "oss":
+ from sound_proxy_oss import OssSoundConfig, OssSoundProxy
+
+ cap = cfg.capture if cfg.capture not in ("", "default") else "/dev/dsp"
+ pb = cfg.playback if cfg.playback not in ("", "default") else cap
+ return OssSoundProxy(
+ OssSoundConfig(
+ capture=cap,
+ playback=pb,
+ sample_rate=cfg.sample_rate,
+ channels=cfg.channels,
+ )
+ )
+ return AlsaSoundProxy(cfg)
+
+
+class AlsaSoundProxy:
+ def __init__(self, cfg: SoundConfig) -> None:
+ self.cfg = cfg
+ self._rec_proc: Optional[subprocess.Popen[bytes]] = None
+ self._play_proc: Optional[subprocess.Popen[bytes]] = None
+ self._stop = threading.Event()
+
+ def _alsa_env(self) -> dict[str, str]:
+ env = os.environ.copy()
+ if self.cfg.forbid_pulse:
+ env.pop("PULSE_SERVER", None)
+ env.pop("PIPEWIRE_RUNTIME_DIR", None)
+ env["PULSE_SERVER"] = ""
+ return env
+
+ @staticmethod
+ def _check_device(name: str) -> None:
+ low = name.lower()
+ if low in ("default", "pulse", "pipewire") or "pulse" in low or "pipewire" in low:
+ raise ValueError(
+ f"audio device {name!r} not allowed — use hw: or plughw: (kernel ALSA)"
+ )
+
+ def start_capture(self) -> None:
+ if not shutil.which("arecord"):
+ raise RuntimeError("arecord not found — install alsa-utils")
+ self._check_device(self.cfg.capture)
+ cmd = [
+ "arecord",
+ "-q",
+ "-D",
+ self.cfg.capture,
+ "-f",
+ "S16_LE",
+ "-r",
+ str(self.cfg.sample_rate),
+ "-c",
+ str(self.cfg.channels),
+ "-t",
+ "raw",
+ ]
+ self._rec_proc = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ env=self._alsa_env(),
+ )
+
+ def read_capture(self, nbytes: int) -> bytes:
+ if self._rec_proc is None or self._rec_proc.stdout is None:
+ return b""
+ return self._rec_proc.stdout.read(nbytes) or b""
+
+ def play_pcm(self, pcm: bytes) -> None:
+ if not pcm:
+ return
+ if not shutil.which("aplay"):
+ raise RuntimeError("aplay not found — install alsa-utils")
+ self._check_device(self.cfg.playback)
+ cmd = [
+ "aplay",
+ "-q",
+ "-D",
+ self.cfg.playback,
+ "-f",
+ "S16_LE",
+ "-r",
+ str(self.cfg.sample_rate),
+ "-c",
+ str(self.cfg.channels),
+ "-t",
+ "raw",
+ ]
+ subprocess.run(
+ cmd,
+ input=pcm,
+ check=False,
+ env=self._alsa_env(),
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+
+ def sniff_loop(
+ self,
+ chunk_symbols: int,
+ on_pcm: Callable[[bytes], None],
+ stop: Optional[threading.Event] = None,
+ ) -> None:
+ stop_ev = stop or self._stop
+ frame_bytes = (self.cfg.sample_rate // 1200) * 2 * chunk_symbols
+ self.start_capture()
+ try:
+ while not stop_ev.is_set():
+ chunk = self.read_capture(frame_bytes)
+ if not chunk:
+ break
+ on_pcm(chunk)
+ finally:
+ self.close()
+
+ def close(self) -> None:
+ self._stop.set()
+ for proc in (self._rec_proc, self._play_proc):
+ if proc is not None and proc.poll() is None:
+ proc.terminate()
+ try:
+ proc.wait(timeout=1.0)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ self._rec_proc = None
+ self._play_proc = None
+
+
+# Backward-compatible alias
+SoundProxy = AlsaSoundProxy
diff --git a/stacks/crdop/lib/sound_proxy_oss.py b/stacks/crdop/lib/sound_proxy_oss.py
new file mode 100644
index 0000000..66cf765
--- /dev/null
+++ b/stacks/crdop/lib/sound_proxy_oss.py
@@ -0,0 +1,151 @@
+"""
+MAX25 sound-proxy — FreeBSD/OSS capture and playback.
+
+Uses `sox` with OSS devices when available; falls back to raw /dev/dsp read/write.
+"""
+from __future__ import annotations
+
+import os
+import shutil
+import subprocess
+import threading
+from dataclasses import dataclass
+from typing import Callable, Optional
+
+
+@dataclass
+class OssSoundConfig:
+ capture: str = "/dev/dsp"
+ playback: str = "/dev/dsp"
+ sample_rate: int = 48000
+ channels: int = 1
+
+
+class OssSoundProxy:
+ def __init__(self, cfg: OssSoundConfig) -> None:
+ self.cfg = cfg
+ self._rec_proc: Optional[subprocess.Popen[bytes]] = None
+ self._dsp_fd: Optional[int] = None
+ self._stop = threading.Event()
+ self._use_sox = shutil.which("sox") is not None
+
+ def _sox_capture_cmd(self) -> list[str]:
+ dev = self.cfg.capture
+ return [
+ "sox",
+ "-q",
+ "-t",
+ "oss",
+ dev,
+ "-r",
+ str(self.cfg.sample_rate),
+ "-c",
+ str(self.cfg.channels),
+ "-b",
+ "16",
+ "-e",
+ "signed-integer",
+ "-t",
+ "raw",
+ "-",
+ ]
+
+ def _sox_play_cmd(self) -> list[str]:
+ dev = self.cfg.playback
+ return [
+ "sox",
+ "-q",
+ "-t",
+ "raw",
+ "-r",
+ str(self.cfg.sample_rate),
+ "-c",
+ str(self.cfg.channels),
+ "-b",
+ "16",
+ "-e",
+ "signed-integer",
+ "-",
+ "-t",
+ "oss",
+ dev,
+ ]
+
+ def start_capture(self) -> None:
+ if self._use_sox:
+ self._rec_proc = subprocess.Popen(
+ self._sox_capture_cmd(),
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ )
+ return
+ flags = os.O_RDONLY
+ try:
+ self._dsp_fd = os.open(self.cfg.capture, flags)
+ except OSError as exc:
+ raise RuntimeError(f"OSS open {self.cfg.capture}: {exc}") from exc
+
+ def read_capture(self, nbytes: int) -> bytes:
+ if self._rec_proc is not None and self._rec_proc.stdout is not None:
+ return self._rec_proc.stdout.read(nbytes) or b""
+ if self._dsp_fd is not None:
+ try:
+ return os.read(self._dsp_fd, nbytes) or b""
+ except OSError:
+ return b""
+ return b""
+
+ def play_pcm(self, pcm: bytes) -> None:
+ if not pcm:
+ return
+ if self._use_sox:
+ subprocess.run(
+ self._sox_play_cmd(),
+ input=pcm,
+ check=False,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ return
+ try:
+ fd = os.open(self.cfg.playback, os.O_WRONLY)
+ except OSError:
+ return
+ try:
+ os.write(fd, pcm)
+ finally:
+ os.close(fd)
+
+ def sniff_loop(
+ self,
+ chunk_symbols: int,
+ on_pcm: Callable[[bytes], None],
+ stop: Optional[threading.Event] = None,
+ ) -> None:
+ stop_ev = stop or self._stop
+ frame_bytes = max(256, (self.cfg.sample_rate // 1200) * 2 * chunk_symbols)
+ self.start_capture()
+ try:
+ while not stop_ev.is_set():
+ chunk = self.read_capture(frame_bytes)
+ if not chunk:
+ break
+ on_pcm(chunk)
+ finally:
+ self.close()
+
+ def close(self) -> None:
+ self._stop.set()
+ if self._rec_proc is not None and self._rec_proc.poll() is None:
+ self._rec_proc.terminate()
+ try:
+ self._rec_proc.wait(timeout=1.0)
+ except subprocess.TimeoutExpired:
+ self._rec_proc.kill()
+ self._rec_proc = None
+ if self._dsp_fd is not None:
+ try:
+ os.close(self._dsp_fd)
+ except OSError:
+ pass
+ self._dsp_fd = None
diff --git a/stacks/crdop/lib/test_bell202_line_code.py b/stacks/crdop/lib/test_bell202_line_code.py
new file mode 100644
index 0000000..3b22af6
--- /dev/null
+++ b/stacks/crdop/lib/test_bell202_line_code.py
@@ -0,0 +1,34 @@
+#!/usr/bin/env python3
+"""Unit tests for Bell 202 frequency-toggle line code."""
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+LIB = Path(__file__).resolve().parent
+sys.path.insert(0, str(LIB))
+
+from bell202_line_code import decode_tones_to_bits, encode_bits_to_tones # noqa: E402
+
+
+def test_roundtrip_single_byte() -> None:
+ bits = bytes([0x55]) # 01010101 → many toggles
+ tones = encode_bits_to_tones(bits, start_tone=0)
+ back = decode_tones_to_bits(tones, start_tone=0)
+ assert back == bits
+
+
+def test_all_ones_no_toggle() -> None:
+ tones = encode_bits_to_tones(bytes([0xFF]), start_tone=0)
+ assert len(set(tones)) == 1
+
+
+def main() -> int:
+ test_roundtrip_single_byte()
+ test_all_ones_no_toggle()
+ print("OK: bell202_line_code tests")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/stacks/crdop/patches/ardopcf/0001-crdop-linux-gcc-branding.patch b/stacks/crdop/patches/ardopcf/0001-crdop-linux-gcc-branding.patch
new file mode 100644
index 0000000..9b37183
--- /dev/null
+++ b/stacks/crdop/patches/ardopcf/0001-crdop-linux-gcc-branding.patch
@@ -0,0 +1,186 @@
+From: CRDOP <dev@ngteq.local>
+Subject: [PATCH] CRDOP: Linux/GCC build fixes and crdopc branding
+
+Build fixes for GCC 15 (prototypes, ws_server, rawhid fd cast).
+Product identity crdopc / 0.1.0-l2-cb; MIT attribution for ngteq changes.
+---
+ lib/rawhid/rawhid.c | 8 ++++----
+ lib/ws_server/ws_server.c | 2 +-
+ src/common/ARDOPC.c | 4 ++--
+ src/common/ARDOPC.h | 2 +-
+ src/common/ARDOPCommon.c | 5 +++--
+ src/common/ARQ.c | 4 ++--
+ src/common/HostInterface.c | 2 +-
+ src/common/ardopcommon.h | 2 +-
+ src/common/version.h | 2 +-
+ src/linux/ALSASound.c | 4 ++--
+ 10 files changed, 18 insertions(+), 17 deletions(-)
+
+diff --git a/lib/rawhid/rawhid.c b/lib/rawhid/rawhid.c
+index 8791788..256641f 100644
+--- a/lib/rawhid/rawhid.c
++++ b/lib/rawhid/rawhid.c
+@@ -358,7 +358,7 @@ int HID_Read_Block()
+ #ifdef WIN32
+ Len = rawhid_recv(0, Msg, 64, 100);
+ #else
+- Len = read(CM108Handle, Msg, 64);
++ Len = read((int)(intptr_t)CM108Handle, Msg, 64);
+ #endif
+
+ if (Len <= 0)
+@@ -403,7 +403,7 @@ int HID_Write_Block()
+ #ifdef WIN32
+ ret = rawhid_send(0, Msg, 64, 100); // Always send 64
+
+- if (ret < 0)
++ if (ret != 64)
+ {
+ ZF_LOGE("Rigcontrol HID Write Failed %d", errno);
+ rawhid_close(0);
+@@ -411,12 +411,12 @@ int HID_Write_Block()
+ return 0;
+ }
+ #else
+- ret = write(CM108Handle, Msg, 64);
++ ret = write((int)(intptr_t)CM108Handle, Msg, 64);
+
+ if (ret != 64)
+ {
+ printf ("Write to %s failed, n=%d, errno=%d\n", HIDDevice, ret, errno);
+- close (CM108Handle);
++ close((int)(intptr_t)CM108Handle);
+ CM108Handle = 0;
+ return 0;
+ }
+diff --git a/lib/ws_server/ws_server.c b/lib/ws_server/ws_server.c
+index a542627..e8db176 100644
+--- a/lib/ws_server/ws_server.c
++++ b/lib/ws_server/ws_server.c
+@@ -1139,7 +1139,7 @@ int client_handler(char *data, int data_size)
+ int data_len;
+ int opcode;
+ if (state[cnum] == WS_HTTP)
+- return process_http_req(cnum);
++ return process_http_req();
+
+ if ((data_len = read_websocket_frame(data, data_size, &opcode)) < 0)
+ return (-1);
+diff --git a/src/common/ARDOPC.c b/src/common/ARDOPC.c
+index dc69a06..34e3f59 100644
+--- a/src/common/ARDOPC.c
++++ b/src/common/ARDOPC.c
+@@ -2,7 +2,7 @@
+ //
+
+ // ardopcf is a fork by pflarue of ardopc by John Wiseman
+-const char ProductName[] = "ardopcf";
++/* ProductName: CRDOP src/crdop_version.c */
+
+ // Version k Fix conflicting definitions of bytDataToSend
+ // Version m Add CM108 PTT (Sept 2021)
+@@ -56,7 +56,7 @@ BOOL SerialHostInit();
+ void SerialHostPoll();
+ void TCPHostPoll();
+ BOOL MainPoll();
+-void PlatformSleep();
++void PlatformSleep(int mS);
+ const char* PlatformSignalAbbreviation(int signal);
+ BOOL BusyDetect2(float * dblMag, int intStart, int intStop);
+ BOOL IsPingToMe(const StationId* caller, const StationId* target);
+diff --git a/src/common/ARDOPC.h b/src/common/ARDOPC.h
+index da2deb3..bdb8c5e 100644
+--- a/src/common/ARDOPC.h
++++ b/src/common/ARDOPC.h
+@@ -176,7 +176,7 @@ void SendReplyToHost(char * strText);
+ void TCPSendReplyToHost(char * strText);
+ void LogStats();
+ int GetNextFrameData(int * intUpDn, UCHAR * bytFrameTypeToSend, UCHAR * strMod, BOOL blnInitialize);
+-void SendData();
++void SendData(void);
+ int ComputeInterFrameInterval(int intRequestedIntervalMS);
+ int Encode4FSKControl(UCHAR bytFrameType, UCHAR bytSessionID, UCHAR * bytreturn);
+ int EncodeConACKwTiming(UCHAR bytFrameType, int intRcvdLeaderLenMs, UCHAR bytSessionID, UCHAR * bytreturn);
+diff --git a/src/common/ARDOPCommon.c b/src/common/ARDOPCommon.c
+index c6c068c..85db9cf 100644
+--- a/src/common/ARDOPCommon.c
++++ b/src/common/ARDOPCommon.c
+@@ -184,10 +184,11 @@ void processargs(int argc, char * argv[])
+ {
+ case 'h':
+
+- printf("%s Version %s (https://www.github.com/pflarue/ardop)\n", ProductName, ProductVersion);
++ printf("%s Version %s (https://github.com/ngteq/CRDOP)\n", ProductName, ProductVersion);
+ printf("Copyright (c) 2014-2024 Rick Muething, John Wiseman, Peter LaRue\n");
++ printf("CRDOPC modifications Copyright (c) 2026 ngteq (MIT)\n");
+ printf(
+- "See https://github.com/pflarue/ardop/blob/master/LICENSE for licence details including\n"
++ "See https://github.com/ngteq/CRDOP/blob/main/NOTICE.md and https://github.com/pflarue/ardop/blob/master/LICENSE for licence details including\n"
+ " information about authors of external libraries used and their licenses.\n"
+ );
+ printf(HelpScreen, ProductName);
+diff --git a/src/common/ARQ.c b/src/common/ARQ.c
+index 1b44fa7..b67c1d3 100644
+--- a/src/common/ARQ.c
++++ b/src/common/ARQ.c
+@@ -822,7 +822,7 @@ int GetNumCarriers(UCHAR bytFrameType)
+
+ // Function to determine the next data frame to send (or IDLE if none)
+
+-void SendData()
++void SendData(void)
+ {
+ char strMod[16];
+ int Len;
+@@ -1772,7 +1772,7 @@ void ProcessRcvdARQFrame(UCHAR intFrameType, UCHAR * bytData, int DataLen, BOOL
+
+ SetARDOPProtocolState(ISS);
+ ARQState = ISSData;
+- SendData(FALSE);
++ SendData();
+ return;
+ }
+ // In an active ARQ Connection, only send an IDFrame when ProtocolState
+diff --git a/src/common/HostInterface.c b/src/common/HostInterface.c
+index 0ba4eca..e4e6ddd 100644
+--- a/src/common/HostInterface.c
++++ b/src/common/HostInterface.c
+@@ -7,7 +7,7 @@
+ BOOL blnHostRDY = FALSE;
+ extern int intFECFramesSent;
+
+-void SendData();
++void SendData(void);
+ BOOL CheckForDisconnect();
+ int Encode4FSKControl(UCHAR bytFrameType, UCHAR bytSessionID, UCHAR * bytreturn);
+ int ComputeInterFrameInterval(int intRequestedIntervalMS);
+diff --git a/src/common/ardopcommon.h b/src/common/ardopcommon.h
+index 91cf281..1a334eb 100644
+--- a/src/common/ardopcommon.h
++++ b/src/common/ardopcommon.h
+@@ -165,7 +165,7 @@ void SendReplyToHost(char * strText);
+ void TCPSendReplyToHost(char * strText);
+ void LogStats();
+ int GetNextFrameData(int * intUpDn, UCHAR * bytFrameTypeToSend, UCHAR * strMod, BOOL blnInitialize);
+-void SendData();
++void SendData(void);
+ int ComputeInterFrameInterval(int intRequestedIntervalMS);
+ int Encode4FSKControl(UCHAR bytFrameType, UCHAR bytSessionID, UCHAR * bytreturn);
+ VOID WriteExceptionLog(const char * format, ...);
+diff --git a/src/linux/ALSASound.c b/src/linux/ALSASound.c
+index 4938b47..d15dbcc 100644
+--- a/src/linux/ALSASound.c
++++ b/src/linux/ALSASound.c
+@@ -404,10 +404,10 @@ int platform_main(int argc, char * argv[])
+
+ setlinebuf(stdout); // So we can redirect output to file and tail
+
+- ZF_LOGI("%s Version %s (https://www.github.com/pflarue/ardop)", ProductName, ProductVersion);
++ ZF_LOGI("%s Version %s (https://github.com/ngteq/CRDOP)", ProductName, ProductVersion);
+ ZF_LOGI("Copyright (c) 2014-2024 Rick Muething, John Wiseman, Peter LaRue");
+ ZF_LOGI(
+- "See https://github.com/pflarue/ardop/blob/master/LICENSE for licence details including\n"
++ "See https://github.com/ngteq/CRDOP/blob/main/NOTICE.md and https://github.com/pflarue/ardop/blob/master/LICENSE for licence details including\n"
+ " information about authors of external libraries used and their licenses."
+ );
+ ZF_LOGD("Command line: %s", cmdstr);
diff --git a/stacks/crdop/patches/ardopcf/0002-crdop-version-override.patch b/stacks/crdop/patches/ardopcf/0002-crdop-version-override.patch
new file mode 100644
index 0000000..cda40d2
--- /dev/null
+++ b/stacks/crdop/patches/ardopcf/0002-crdop-version-override.patch
@@ -0,0 +1,18 @@
+From: CRDOP <dev@ngteq.local>
+Subject: [PATCH 2/2] CRDOP: version from CRDOP tree (single source of truth)
+
+ProductName and ProductVersion live in src/crdop_version.c / include/crdop/version.h.
+---
+ src/common/version.h | 2 +-
+ 1 file changed, 1 insertion(+), 1 deletion(-)
+
+diff --git a/src/common/version.h b/src/common/version.h
+index faabb3e..c4d0e1f 100644
+--- a/src/common/version.h
++++ b/src/common/version.h
+@@ -4,4 +4,4 @@
+ // ardopcf was a mistake. This was due to a bug and misunderstanding
+ // of the ardop version numbering scheme. So ardopcf v1.0.4.1.1 comes
+ // after and replaces v2.0.3.2.1
+-const char ProductVersion[] = "1.0.4.1.3";
++extern const char ProductVersion[]; /* CRDOP src/crdop_version.c */
diff --git a/stacks/crdop/scripts/apply-vendor-patches.sh b/stacks/crdop/scripts/apply-vendor-patches.sh
new file mode 100755
index 0000000..da62bf5
--- /dev/null
+++ b/stacks/crdop/scripts/apply-vendor-patches.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+# Apply CRDOP patch series — only if vendor tree is still pristine upstream.
+# Released CRDOP commits ship vendor/ardopcf with patches already integrated.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+VENDOR="${ROOT}/vendor/ardopcf"
+PATCH_DIR="${ROOT}/patches/ardopcf"
+STAMP="${VENDOR}/.crdop-patches-applied"
+MARKER="${VENDOR}/src/common/ARDOPC.c"
+
+if [[ ! -d "${VENDOR}/src" ]]; then
+ echo "error: ${VENDOR} missing" >&2
+ exit 1
+fi
+
+if grep -q 'CRDOP src/crdop_version.c' "${MARKER}" 2>/dev/null; then
+ date -u +'%Y-%m-%dT%H:%M:%SZ' > "${STAMP}"
+ echo "CRDOP vendor already integrated ($(cat "${STAMP}"))"
+ exit 0
+fi
+
+if [[ ! -d "${PATCH_DIR}" ]] || ! compgen -G "${PATCH_DIR}/*.patch" >/dev/null; then
+ echo "error: no patches in ${PATCH_DIR}" >&2
+ exit 1
+fi
+
+cd "${VENDOR}"
+for p in "${PATCH_DIR}"/*.patch; do
+ echo "applying ${p#${ROOT}/}"
+ git apply --check "${p}" 2>/dev/null || patch -p1 --forward -r - < "${p}"
+ git apply "${p}" 2>/dev/null || patch -p1 --forward < "${p}"
+done
+
+date -u +'%Y-%m-%dT%H:%M:%SZ' > "${STAMP}"
+echo "CRDOP patches applied ($(cat "${STAMP}"))"
diff --git a/stacks/crdop/scripts/build-crdop.sh b/stacks/crdop/scripts/build-crdop.sh
new file mode 100755
index 0000000..2253d5a
--- /dev/null
+++ b/stacks/crdop/scripts/build-crdop.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+# CRDOP unified build — vendor embedded; GCC, Clang, or MSVC via CMake.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+BUILD="${CRDOP_BUILD_DIR:-${ROOT}/build}"
+TYPE="${CRDOP_BUILD_TYPE:-Release}"
+TOOLCHAIN="${CRDOP_TOOLCHAIN:-}"
+BUILD_TESTS="${CRDOP_BUILD_TESTS:-OFF}"
+RUN_TESTS="${CRDOP_RUN_TESTS:-OFF}"
+
+if [[ -f "${BUILD}/CMakeCache.txt" ]]; then
+ cached_home="$(grep -m1 '^CMAKE_HOME_DIRECTORY:INTERNAL=' "${BUILD}/CMakeCache.txt" 2>/dev/null | cut -d= -f2- || true)"
+ if [[ -n "${cached_home}" && "${cached_home}" != "${ROOT}" ]]; then
+ echo "CRDOP: removing stale CMake cache (was ${cached_home}, now ${ROOT})" >&2
+ rm -rf "${BUILD}"
+ fi
+fi
+
+"${ROOT}/scripts/import-ardopcf.sh"
+
+CMAKE_ARGS=(-B "${BUILD}" -DCMAKE_BUILD_TYPE="${TYPE}" -DCRDOP_BUILD_TESTS="${BUILD_TESTS}")
+
+if [[ -n "${TOOLCHAIN}" ]]; then
+ CMAKE_ARGS+=(-DCMAKE_TOOLCHAIN_FILE="${ROOT}/cmake/toolchains/${TOOLCHAIN}.cmake")
+fi
+
+if [[ -n "${CC:-}" ]]; then
+ CMAKE_ARGS+=(-DCMAKE_C_COMPILER="${CC}")
+fi
+if [[ -n "${CXX:-}" ]]; then
+ CMAKE_ARGS+=(-DCMAKE_CXX_COMPILER="${CXX}")
+fi
+
+cmake "${ROOT}" "${CMAKE_ARGS[@]}"
+cmake --build "${BUILD}" -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)"
+
+"${ROOT}/scripts/test-smoke.sh"
+
+if [[ "${BUILD_TESTS}" == "ON" || "${BUILD_TESTS}" == "1" || "${RUN_TESTS}" == "ON" || "${RUN_TESTS}" == "1" ]]; then
+ if [[ -f "${BUILD}/CTestTestfile.cmake" ]]; then
+ ctest --test-dir "${BUILD}" --output-on-failure
+ else
+ echo "NOTE: CRDOP_BUILD_TESTS requested but no CTest targets (install libcmocka-dev?)" >&2
+ fi
+fi
+
+echo "CRDOP $(cat "${ROOT}/VERSION") built: ${BUILD}/crdopc"
diff --git a/stacks/crdop/scripts/crdopc b/stacks/crdop/scripts/crdopc
new file mode 100755
index 0000000..2fc82bd
--- /dev/null
+++ b/stacks/crdop/scripts/crdopc
@@ -0,0 +1,171 @@
+#!/usr/bin/env bash
+# CRDOP launcher — native MAX25-SoftModem (audio-dummyd / M25 host).
+# ARDOP is a separate optional MAX25-Stack plugin — not part of CRDOP.
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
+MAX25_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
+PREFIX_SHARE="${SCRIPT_DIR}/../share/crdop"
+
+if [[ -n "${CRDOP_INI:-}" ]]; then
+ INI="${CRDOP_INI}"
+elif [[ -f "${HOME}/.config/crdop/crdop.ini" ]]; then
+ INI="${HOME}/.config/crdop/crdop.ini"
+elif [[ -f "${PREFIX_SHARE}/crdop.ini.example" ]]; then
+ INI="${PREFIX_SHARE}/crdop.ini.example"
+else
+ INI="${ROOT}/share/crdop.ini.example"
+fi
+
+ini_get() {
+ local section="$1" key="$2" default="$3" file="$4"
+ [[ -f "${file}" ]] || { echo "${default}"; return; }
+ awk -F= -v sec="[${section}]" -v k="${key}" -v d="${default}" '
+ $0 ~ /^[[:space:]]*[#;]/ { next }
+ $1 ~ /^\[/ { cur=$0; gsub(/[][]/, "", cur); next }
+ cur == substr(sec, 2, length(sec)-2) {
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1)
+ if ($1 == k) {
+ v=$2; gsub(/^[[:space:]]+|[[:space:]]+$/, "", v); gsub(/^"|"$/, "", v)
+ print v; found=1; exit
+ }
+ }
+ END { if (!found) print d }
+ ' "${file}"
+}
+
+resolve_ini() {
+ if [[ ! -f "${INI}" && -f "${ROOT}/share/crdop.ini.example" ]]; then
+ INI="${ROOT}/share/crdop.ini.example"
+ fi
+}
+
+normalize_profile() {
+ case "${1,,}" in
+ cb|dual|amateur) echo "${1,,}" ;;
+ *)
+ echo "warn: unknown radio_profile '${1}', using cb" >&2
+ echo "cb"
+ ;;
+ esac
+}
+
+normalize_duplex() {
+ case "${1,,}" in
+ full|half) echo "${1,,}" ;;
+ *)
+ echo "warn: unknown duplex '${1}', using half" >&2
+ echo "half"
+ ;;
+ esac
+}
+
+enforce_host_audio() {
+ local backend
+ backend="$(ini_get audio backend "" "${INI}")"
+ if [[ -z "${backend}" ]]; then
+ case "$(uname -s)" in
+ FreeBSD) backend="oss" ;;
+ *) backend="alsa" ;;
+ esac
+ fi
+ if [[ "${backend}" == "alsa" ]]; then
+ local no_pulse
+ no_pulse="$(ini_get audio no_pulse yes "${INI}")"
+ case "${no_pulse,,}" in
+ 1|yes|true|on)
+ unset PULSE_SERVER PULSE_COOKIE PIPEWIRE_RUNTIME_DIR 2>/dev/null || true
+ export PULSE_SERVER=
+ export ALSA_CARD="${ALSA_CARD:-}"
+ ;;
+ esac
+ fi
+}
+
+audio_cli_args() {
+ local cap pb backend
+ backend="$(ini_get audio backend "" "${INI}")"
+ if [[ -z "${backend}" ]]; then
+ case "$(uname -s)" in
+ FreeBSD) backend="oss" ;;
+ *) backend="alsa" ;;
+ esac
+ fi
+ cap="$(ini_get audio capture "" "${INI}")"
+ pb="$(ini_get audio playback "" "${INI}")"
+ local args=()
+ [[ -n "${cap}" ]] && args+=("${cap}")
+ [[ -n "${pb}" ]] && args+=("${pb}")
+ if [[ ${#args[@]} -gt 0 ]]; then
+ if [[ "${backend}" == "alsa" ]]; then
+ for a in "${args[@]}"; do
+ if [[ "${a}" == *pulse* || "${a}" == *pipewire* || "${a}" == default ]]; then
+ echo "error: audio device '${a}' — use hw:/plughw: kernel ALSA" >&2
+ exit 1
+ fi
+ done
+ fi
+ printf '%s\n' "${args[@]}"
+ elif [[ "${backend}" == "oss" ]]; then
+ echo "/dev/dsp"
+ echo "/dev/dsp"
+ fi
+}
+
+find_audio_dummyd() {
+ for candidate in \
+ "${MAX25_ROOT}/build/bin/audio-dummyd" \
+ "${MAX25_ROOT}/stacks/crdop/tools/audio-dummyd" \
+ "$(command -v audio-dummyd 2>/dev/null || true)"; do
+ [[ -n "${candidate}" && -x "${candidate}" ]] || continue
+ echo "${candidate}"
+ return
+ done
+ return 1
+}
+
+resolve_ini
+
+if [[ -f "${PREFIX_SHARE}/VERSION" ]]; then
+ CRDOP_VERSION="$(tr -d '[:space:]' < "${PREFIX_SHARE}/VERSION")"
+elif [[ -f "${ROOT}/VERSION" ]]; then
+ CRDOP_VERSION="$(tr -d '[:space:]' < "${ROOT}/VERSION")"
+else
+ CRDOP_VERSION="CUR999"
+fi
+
+PROFILE="$(normalize_profile "$(ini_get profile radio_profile cb "${INI}")")"
+DUPLEX="$(normalize_duplex "$(ini_get modem duplex half "${INI}")")"
+MYCALL="$(ini_get mycall call NOCALL-0 "${INI}")"
+HOST_PORT="$(ini_get host port 8515 "${INI}")"
+DATA_PORT=$((HOST_PORT + 1))
+SAMPLE_RATE="$(ini_get audio sample_rate 48000 "${INI}")"
+
+if [[ $# -ge 1 && "${1}" =~ ^[0-9]+$ ]]; then
+ HOST_PORT="$1"
+ DATA_PORT=$((HOST_PORT + 1))
+ shift
+fi
+
+mapfile -t AUDIO_ARGS < <(audio_cli_args || true)
+enforce_host_audio
+
+DUMMY="$(find_audio_dummyd || true)"
+if [[ -z "${DUMMY}" ]]; then
+ echo "error: audio-dummyd not found — build MAX25 or set path" >&2
+ exit 1
+fi
+
+DUMMY_ARGS=(--ctrl-port "${HOST_PORT}" --data-port "${DATA_PORT}" -r "${SAMPLE_RATE}")
+if [[ ${#AUDIO_ARGS[@]} -ge 1 ]]; then
+ DUMMY_ARGS+=(-D "${AUDIO_ARGS[0]}")
+fi
+if [[ ${#AUDIO_ARGS[@]} -ge 2 ]]; then
+ DUMMY_ARGS+=(-P "${AUDIO_ARGS[1]}")
+fi
+
+AUDIO_BACKEND="$(ini_get audio backend "" "${INI}")"
+[[ -z "${AUDIO_BACKEND}" ]] && case "$(uname -s)" in FreeBSD) AUDIO_BACKEND="oss" ;; *) AUDIO_BACKEND="alsa" ;; esac
+echo "CRDOP ${CRDOP_VERSION} profile=${PROFILE} duplex=${DUPLEX} ctrl=:${HOST_PORT} data=:${DATA_PORT} call=${MYCALL} audio=${AUDIO_BACKEND} (native M25)"
+exec "${DUMMY}" "${DUMMY_ARGS[@]}" "$@"
diff --git a/stacks/crdop/scripts/import-ardopcf.sh b/stacks/crdop/scripts/import-ardopcf.sh
new file mode 100755
index 0000000..26ac003
--- /dev/null
+++ b/stacks/crdop/scripts/import-ardopcf.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+# Verify vendor/ardopcf is present (embedded in repo — no submodule/download).
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+VENDOR="${ROOT}/vendor/ardopcf"
+
+if [[ ! -f "${VENDOR}/LICENSE" || ! -f "${VENDOR}/src/common/ARDOPC.c" ]]; then
+ echo "error: vendor/ardopcf missing — clone CRDOP with full tree or run scripts/refresh-vendor-ardopcf.sh" >&2
+ exit 1
+fi
+
+if ! grep -q 'CRDOP src/crdop_version.c' "${VENDOR}/src/common/ARDOPC.c" 2>/dev/null; then
+ echo "warn: vendor/ardopcf may need CRDOP patches — run scripts/apply-vendor-patches.sh" >&2
+fi
+
+echo "vendor/ardopcf OK (embedded, commit $(grep '^commit=' "${ROOT}/vendor/ardopcf.ref" 2>/dev/null | cut -d= -f2 || echo unknown))"
diff --git a/stacks/crdop/scripts/install-crdop.sh b/stacks/crdop/scripts/install-crdop.sh
new file mode 100755
index 0000000..23f631c
--- /dev/null
+++ b/stacks/crdop/scripts/install-crdop.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+# Build and install CRDOP to PREFIX (default /usr/local).
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+PREFIX="${CRDOP_PREFIX:-/usr/local}"
+BUILD="${CRDOP_BUILD_DIR:-${ROOT}/build}"
+
+"${ROOT}/scripts/build-crdop.sh"
+
+cmake --install "${BUILD}" --prefix "${PREFIX}"
+
+echo "Installed:"
+echo " ${PREFIX}/bin/crdopc — modem binary"
+echo " ${PREFIX}/bin/crdop — profile launcher"
+echo " ${PREFIX}/share/crdop/ — INI examples, VERSION"
diff --git a/stacks/crdop/scripts/refresh-vendor-ardopcf.sh b/stacks/crdop/scripts/refresh-vendor-ardopcf.sh
new file mode 100755
index 0000000..b9d2f93
--- /dev/null
+++ b/stacks/crdop/scripts/refresh-vendor-ardopcf.sh
@@ -0,0 +1,26 @@
+#!/usr/bin/env bash
+# Maintainer tool: re-download upstream ardopcf and re-apply CRDOP patches.
+# Normal builds use the committed vendor/ardopcf tree — no network required.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+VENDOR="${ROOT}/vendor/ardopcf"
+REF_FILE="${ROOT}/vendor/ardopcf.ref"
+URL="${CRDOP_UPSTREAM_URL:-https://github.com/pflarue/ardop.git}"
+PIN="$(grep '^commit_full=' "${REF_FILE}" 2>/dev/null | cut -d= -f2- | head -1)"
+PIN="${PIN:-$(grep '^commit=' "${REF_FILE}" 2>/dev/null | cut -d= -f2- | head -1)}"
+
+echo "This replaces vendor/ardopcf from upstream. Press Ctrl+C to abort."
+sleep 2
+
+rm -rf "${VENDOR}"
+git clone --depth 1 --branch master "${URL}" "${VENDOR}"
+if [[ -n "${PIN}" ]]; then
+ git -C "${VENDOR}" fetch --depth 1 origin "${PIN}"
+ git -C "${VENDOR}" checkout "${PIN}"
+fi
+rm -rf "${VENDOR}/.git"
+rm -f "${VENDOR}/.crdop-patches-applied"
+
+"${ROOT}/scripts/apply-vendor-patches.sh"
+echo "Done. Review diff, update vendor/ardopcf.ref if needed, commit vendor/ardopcf/"
diff --git a/stacks/crdop/scripts/sync-upstream.sh b/stacks/crdop/scripts/sync-upstream.sh
new file mode 100755
index 0000000..a1353ad
--- /dev/null
+++ b/stacks/crdop/scripts/sync-upstream.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+# Test whether CRDOP patches still apply to fresh upstream (maintainers).
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+TMP="$(mktemp -d)"
+trap 'rm -rf "${TMP}"' EXIT
+
+REF="${ROOT}/vendor/ardopcf.ref"
+PIN="$(grep '^commit_full=' "${REF}" | cut -d= -f2-)"
+URL="https://github.com/pflarue/ardop.git"
+
+git clone --depth 1 "${URL}" "${TMP}/ardopcf"
+git -C "${TMP}/ardopcf" fetch --depth 1 origin "${PIN}"
+git -C "${TMP}/ardopcf" checkout "${PIN}"
+
+for p in "${ROOT}"/patches/ardopcf/*.patch; do
+ git -C "${TMP}/ardopcf" apply --check "${p}"
+done
+
+echo "OK: patches apply to upstream ${PIN}"
diff --git a/stacks/crdop/scripts/test-all.sh b/stacks/crdop/scripts/test-all.sh
new file mode 100755
index 0000000..f12181e
--- /dev/null
+++ b/stacks/crdop/scripts/test-all.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+# Smoke test + optional upstream cmocka unit tests (CTest).
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+BUILD="${CRDOP_BUILD_DIR:-${ROOT}/build}"
+
+export CRDOP_BUILD_TESTS="${CRDOP_BUILD_TESTS:-ON}"
+export CRDOP_RUN_TESTS="${CRDOP_RUN_TESTS:-ON}"
+
+"${ROOT}/scripts/build-crdop.sh"
+
+if [[ ! -f "${BUILD}/CTestTestfile.cmake" ]]; then
+ echo "NOTE: unit tests not built (libcmocka missing or unsupported platform)" >&2
+fi
+
+echo "OK: CRDOP test-all finished"
diff --git a/stacks/crdop/scripts/test-smoke.sh b/stacks/crdop/scripts/test-smoke.sh
new file mode 100755
index 0000000..6ee6eff
--- /dev/null
+++ b/stacks/crdop/scripts/test-smoke.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+REPO="$(cd "${ROOT}/../.." && pwd)"
+BIN="${CRDOP_BIN:-${ROOT}/build/crdopc}"
+
+if [[ ! -x "${BIN}" ]]; then
+ for alt in "${REPO}/build/bin/crdopc" "${REPO}/build/crdop/crdopc"; do
+ if [[ -x "${alt}" ]]; then
+ BIN="${alt}"
+ break
+ fi
+ done
+fi
+VER="$(tr -d '[:space:]' < "${ROOT}/VERSION")"
+
+[[ -x "${BIN}" ]] || { echo "FAIL: ${BIN} missing" >&2; exit 1; }
+
+OUT="$("${BIN}" -h 2>&1 || true)"
+echo "${OUT}" | grep -qi crdopc || { echo "FAIL: crdopc branding"; exit 1; }
+echo "${OUT}" | grep -q "${VER}" || { echo "FAIL: expected version ${VER}"; exit 1; }
+
+echo "OK: CRDOP ${VER} smoke test passed"
diff --git a/stacks/crdop/scripts/test-with-host.sh b/stacks/crdop/scripts/test-with-host.sh
new file mode 100755
index 0000000..f14baac
--- /dev/null
+++ b/stacks/crdop/scripts/test-with-host.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+# P1 — loopback host integration (placeholder).
+set -euo pipefail
+
+echo "Host loopback test not automated yet."
+echo " 1. ./scripts/crdopc"
+echo " 2. Connect M25/KISS host client to 127.0.0.1:8515"
+echo " 3. INITIALIZE / PROTOCOLMODE KISS / LISTEN — verify STATUS ready"
+exit 0
diff --git a/stacks/crdop/share/crdop-amateur.ini.example b/stacks/crdop/share/crdop-amateur.ini.example
new file mode 100644
index 0000000..dd76f68
--- /dev/null
+++ b/stacks/crdop/share/crdop-amateur.ini.example
@@ -0,0 +1,15 @@
+; CRDOP — amateur profile (secondary)
+; Docs: docs/EXAMPLES.md
+
+[profile]
+radio_profile = amateur
+
+[modem]
+arq_bandwidth = 1000MAX
+duplex = half
+
+[host]
+port = 8515
+
+[mycall]
+call = CALL-1
diff --git a/stacks/crdop/share/crdop-dual.ini.example b/stacks/crdop/share/crdop-dual.ini.example
new file mode 100644
index 0000000..4601d5c
--- /dev/null
+++ b/stacks/crdop/share/crdop-dual.ini.example
@@ -0,0 +1,15 @@
+; CRDOP — dual profile (CB ↔ amateur turnaround)
+; Docs: docs/EXAMPLES.md
+
+[profile]
+radio_profile = dual
+
+[modem]
+arq_bandwidth = 500MAX
+duplex = half
+
+[host]
+port = 8515
+
+[mycall]
+call = CB01-0
diff --git a/stacks/crdop/share/crdop.freebsd.ini.example b/stacks/crdop/share/crdop.freebsd.ini.example
new file mode 100644
index 0000000..3d0f347
--- /dev/null
+++ b/stacks/crdop/share/crdop.freebsd.ini.example
@@ -0,0 +1,25 @@
+; MAX25-SoftModem (CRDOP) — FreeBSD / OSS
+; Copy: cp share/crdop/crdop.freebsd.ini.example ~/.config/crdop/crdop.ini
+; pkg install sox (recommended for OSS capture/playback)
+
+[profile]
+radio_profile = cb
+
+[modem]
+speed_baud = 1200
+duplex = half
+max_speed_baud = 19200
+
+[host]
+port = 8515
+
+[mycall]
+call = NOCALL-0
+
+[audio]
+backend = oss
+no_pulse = yes
+; pcm0 from sysctl dev.pcm — typical OSS path:
+capture = /dev/dsp
+playback = /dev/dsp
+sample_rate = 48000
diff --git a/stacks/crdop/share/crdop.ini.example b/stacks/crdop/share/crdop.ini.example
new file mode 100644
index 0000000..52dc3a1
--- /dev/null
+++ b/stacks/crdop/share/crdop.ini.example
@@ -0,0 +1,37 @@
+; MAX25-SoftModem (CRDOP) — CB profile (default)
+; Copy: cp share/crdop.ini.example ~/.config/crdop/crdop.ini
+; Docs: docs/SOFTMODEM.md · docs/AUDIO-ARCHITECTURE.md · docs/CONFIG.md
+;
+; Development/test phase — in-house MAX25-Stack standard.
+; Audio: kernel ALSA ONLY via MAX25 sound-proxy — NO PulseAudio / PipeWire route.
+; Good sound card REQUIRED (accurate tones; stricter at higher baud).
+; 1200 baud primary, max 19200 baud. Duplex: half | full
+
+[profile]
+radio_profile = cb
+
+[modem]
+speed_baud = 1200
+duplex = half
+max_speed_baud = 19200
+
+[host]
+port = 8515
+
+[mycall]
+call = NOCALL-0
+
+[audio]
+; MAX25 sound-proxy → libasound → kernel ALSA → hardware (direct)
+backend = alsa-kernel
+; Reject pulse/pipewire pseudo devices — production path is kernel-only
+no_pulse = yes
+; REQUIRED: explicit hw: or plughw: from arecord -l / aplay -l
+; capture = hw:1,0
+; playback = hw:1,0
+; sample_rate = 48000
+; period_frames = 256
+
+[coupling]
+; line | acoustic — radio or TNC/BayCom reference path
+mode = line
diff --git a/stacks/crdop/src/crdop_version.c b/stacks/crdop/src/crdop_version.c
new file mode 100644
index 0000000..7136d4a
--- /dev/null
+++ b/stacks/crdop/src/crdop_version.c
@@ -0,0 +1,5 @@
+#include "crdop/version.h"
+
+/* CRDOP product identity — overrides vendor version.h when linked first. */
+const char ProductName[] = CRDOP_PRODUCT_NAME;
+const char ProductVersion[] = CRDOP_VERSION_STRING;
diff --git a/stacks/crdop/tools/audio-dummyd b/stacks/crdop/tools/audio-dummyd
new file mode 100755
index 0000000..e64bda5
--- /dev/null
+++ b/stacks/crdop/tools/audio-dummyd
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -euo pipefail
+ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
+exec python3 "${ROOT}/stacks/crdop/tools/audio-dummyd.py" "$@"
diff --git a/stacks/crdop/tools/audio-dummyd.py b/stacks/crdop/tools/audio-dummyd.py
new file mode 100644
index 0000000..a4bb922
--- /dev/null
+++ b/stacks/crdop/tools/audio-dummyd.py
@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+"""Audio dummy daemon — M25 host TCP + acoustic DSP bench for max25d."""
+from __future__ import annotations
+
+import argparse
+import sys
+import threading
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[3]
+LIB = ROOT / "stacks" / "crdop" / "lib"
+sys.path.insert(0, str(LIB))
+
+from acoustic_engine import AcousticEngine # noqa: E402
+from m25_host_protocol import DEFAULT_CTRL_PORT, DEFAULT_DATA_PORT, M25SoftModemHost # noqa: E402
+from sound_proxy import SoundConfig # noqa: E402
+
+_DAEMON = ROOT / "stacks" / "daemon"
+sys.path.insert(0, str(_DAEMON))
+from ax25_codec import ax25_build_ui # noqa: E402
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description="MAX25 audio-dummy device daemon")
+ ap.add_argument("--ctrl-port", type=int, default=DEFAULT_CTRL_PORT)
+ ap.add_argument("--data-port", type=int, default=DEFAULT_DATA_PORT)
+ ap.add_argument("-D", "--capture", default="")
+ ap.add_argument("-P", "--playback", default="")
+ ap.add_argument("-r", "--rate", type=int, default=48000)
+ args = ap.parse_args()
+
+ sound = SoundConfig(
+ capture=args.capture or "default",
+ playback=args.playback or args.capture or "default",
+ sample_rate=args.rate,
+ )
+ eng = AcousticEngine(sample_rate=args.rate, sound=sound)
+ rx_lock = threading.Lock()
+ last_rx: list[str] = []
+
+ def on_tx(payload: bytes) -> str:
+ if len(payload) < 16:
+ return "ERR short payload"
+ pcm = eng.mod.modulate_bits(payload)
+ eng.sound.playback = sound.playback
+ from sound_proxy import create_sound_proxy
+
+ create_sound_proxy(sound).play_pcm(pcm)
+ return "OK"
+
+ host = M25SoftModemHost(args.ctrl_port, args.data_port, on_data_tx=on_tx)
+ host.start()
+ print(
+ f"audio-dummyd ctrl=:{args.ctrl_port} data=:{args.data_port} rate={args.rate}",
+ flush=True,
+ )
+ try:
+ while True:
+ threading.Event().wait(3600.0)
+ except KeyboardInterrupt:
+ host.stop()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/stacks/crdop/tools/max25-signal-sniffer b/stacks/crdop/tools/max25-signal-sniffer
new file mode 100755
index 0000000..e412997
--- /dev/null
+++ b/stacks/crdop/tools/max25-signal-sniffer
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+# MAX25 signal sniffer launcher
+set -euo pipefail
+ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
+exec python3 "${ROOT}/stacks/crdop/tools/max25-signal-sniffer.py" "$@"
diff --git a/stacks/crdop/tools/max25-signal-sniffer.py b/stacks/crdop/tools/max25-signal-sniffer.py
new file mode 100644
index 0000000..fed95f7
--- /dev/null
+++ b/stacks/crdop/tools/max25-signal-sniffer.py
@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+"""MAX25 signal sniffer — analyze Bell 202 AFSK on ALSA capture or PCM file."""
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[3]
+LIB = ROOT / "stacks" / "crdop" / "lib"
+sys.path.insert(0, str(LIB))
+
+from acoustic_engine import AcousticEngine # noqa: E402
+from sound_proxy import SoundConfig, SoundProxy # noqa: E402
+
+
+def read_wav_pcm(path: Path) -> bytes:
+ import wave
+
+ with wave.open(str(path), "rb") as wf:
+ if wf.getsampwidth() != 2 or wf.getnchannels() != 1:
+ raise SystemExit("WAV must be mono S16_LE")
+ return wf.readframes(wf.getnframes())
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description="MAX25 acoustic signal sniffer (Bell 202 1200 bd)")
+ ap.add_argument("-D", "--device", default="", help="ALSA capture device (hw: or plughw:)")
+ ap.add_argument("-r", "--rate", type=int, default=48000)
+ ap.add_argument("-f", "--file", type=Path, help="Read mono S16 WAV instead of ALSA")
+ ap.add_argument("-t", "--seconds", type=float, default=2.0, help="ALSA capture duration")
+ ap.add_argument("--loopback", action="store_true", help="Internal DSP loopback (no audio hw)")
+ ap.add_argument("--mark", action="store_true", help="Play mark calibration tone (1200 Hz)")
+ ap.add_argument("--space", action="store_true", help="Play space calibration tone (2200 Hz)")
+ args = ap.parse_args()
+
+ cfg = SoundConfig(capture=args.device or "default", sample_rate=args.rate)
+ eng = AcousticEngine(sample_rate=args.rate, sound=cfg)
+
+ if args.loopback:
+ rep = eng.loopback_self_test()
+ print(f"loopback samples={rep.samples} symbols={rep.symbols} transitions={rep.transitions}")
+ print(f"mark={rep.mark_ratio:.2%} space={rep.space_ratio:.2%}")
+ for line in rep.decode_lines:
+ print(line)
+ return 0 if rep.decode_lines else 1
+
+ if args.mark:
+ eng.play_mark_calibration(args.seconds)
+ print(f"played mark tone {args.seconds}s")
+ return 0
+ if args.space:
+ eng.play_space_calibration(args.seconds)
+ print(f"played space tone {args.seconds}s")
+ return 0
+
+ if args.file:
+ pcm = read_wav_pcm(args.file)
+ else:
+ if not args.device:
+ print("error: -D hw:... or --file or --loopback required", file=sys.stderr)
+ return 2
+ proxy = SoundProxy(cfg)
+ proxy.start_capture()
+ nbytes = int(args.rate * args.seconds) * 2
+ pcm = proxy.read_capture(nbytes)
+ proxy.close()
+
+ rep = eng.analyze_pcm(pcm)
+ print(f"sniff samples={rep.samples} symbols={rep.symbols} transitions={rep.transitions}")
+ print(f"mark={rep.mark_ratio:.2%} space={rep.space_ratio:.2%} frames={len(rep.frames)}")
+ for line in rep.decode_lines:
+ print(line)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/stacks/crdop/vendor/README.md b/stacks/crdop/vendor/README.md
new file mode 100644
index 0000000..d5f9c02
--- /dev/null
+++ b/stacks/crdop/vendor/README.md
@@ -0,0 +1,5 @@
+# Vendor tree (dev-only)
+
+Legacy **ardopcf** may exist under `vendor/ardopcf/` for local developer builds (`-DCRDOP_VENDOR_ARDOPCF=ON`). **Not** part of MAX25 release install.
+
+Operator ARDOP (third-party host): [plugins/external/ardop/README.md](../../plugins/external/ardop/README.md) — separate from CRDOP.
diff --git a/stacks/crdop/vendor/ardopcf.ref b/stacks/crdop/vendor/ardopcf.ref
new file mode 100644
index 0000000..54a0ab6
--- /dev/null
+++ b/stacks/crdop/vendor/ardopcf.ref
@@ -0,0 +1,9 @@
+# Pinned upstream for CRDOP vendor submodule (vendor/ardopcf)
+# Update with: scripts/sync-upstream.sh
+
+url=https://github.com/pflarue/ardop.git
+branch=master
+commit=a7c9228
+commit_full=a7c92289b569afbe4259dc556d749405ebc008f5
+describe=ardopcf baseline (Update CONTRIBUTING.md fix typo)
+imported=2026-07-04
diff --git a/stacks/daemon/README.md b/stacks/daemon/README.md
new file mode 100644
index 0000000..458883d
--- /dev/null
+++ b/stacks/daemon/README.md
@@ -0,0 +1,72 @@
+# max25d — Linux daemon
+
+**max25d** supervises TNC/modem hardware, runs per-device RX/TX backends, and exposes M25/1 to `max25-terminal`.
+
+Linux only. Config: `share/max25/max25d.ini.example`.
+
+## Components
+
+| File | Role |
+|------|------|
+| `max25d` | Python 3 daemon — M25/1 server, stack lifecycle |
+| `device_backends.py` | Backend abstraction (TNC, BayCom, CRDOP) |
+| `kiss_bridge.py` | Serial KISS for command-mode TNCs (TNC2C/PK-TNC2) |
+| `banlist.py` | AX.25 source ban list (silent RX drop) |
+| `test_multi_device.py` | Multi-device routing smoke |
+
+## Backends
+
+| Device id | Backend | Notes |
+|-----------|---------|-------|
+| `tnc2c`, `pktnc2` | `KissSerialBackend` | CI-tested |
+| `max25e0` (`max25-bcpr:bc0`) | bcpr / KISS PTY | BayCom/based SER12 userspace — [../max25-bcpr/README.md](../max25-bcpr/README.md) |
+| `baycom-kiss` | `KissRawSerialBackend` | USB/async KISS (not SER12 product face) |
+| `soft-crdop` | `CrdopTcpBackend` | MAX25-SoftModem (CRDOP) — standard; KISS default; sound IN/OUT + radio like hardware modem |
+
+Untested backends log a startup warning; SEND/RX return `ERR link not ready` when the stack path is unavailable.
+
+## Quick start
+
+```bash
+# Protocol only (no hardware):
+./max25d --no-stack -c ../../share/max25/max25d.ini.example
+
+# With stack auto-start:
+sudo ./max25d -c /etc/max25/max25d.ini
+```
+
+## One RF device per Linux host
+
+**Target:** one `[devices]` id per `max25d` (localhost-only, single-machine Lite model — see [../../README.md](../../README.md)).
+
+```ini
+[devices]
+default = tnc2c
+tnc2c = /dev/ttyS4
+```
+
+**Legacy multi-id** (deprecated for new sites): `share/max25/max25d.full-station.ini.example`, `max25d.dual-baycom.ini.example`.
+
+M25/1: `devices=` in `STATUS`, `SET DEVICE <id>`, `GET DEVICES`, `RX device=<id> …`.
+
+## Source ban list
+
+Block unwanted AX.25 callers (source address on incoming UI frames). Banned traffic is dropped silently — no terminal display, no daemon log line.
+
+```ini
+[modem]
+bans_file = /etc/max25/bans.txt
+```
+
+File format: one callsign per line (`#` comments allowed). Ban without SSID blocks all SSIDs of that call (`DG1ABC` blocks `DG1ABC-7`). Ban with SSID (`DK0WC-7`) matches only that SSID.
+
+M25/1: `BAN <callsign>`, `UNBAN <callsign>`, `BANS`. Changes persist to `bans_file` immediately.
+
+## Install
+
+```bash
+./scripts/install-max25.sh --deps
+# Installs: /usr/local/bin/max25d, kiss_bridge.py, device_backends.py, max25-terminal
+```
+
+Host setup guide: [../../README.md](../../README.md) (quick start). Protocol: [include/max25/protocol.md](../../include/max25/protocol.md).
diff --git a/stacks/daemon/ax25_codec.py b/stacks/daemon/ax25_codec.py
new file mode 100644
index 0000000..03ee671
--- /dev/null
+++ b/stacks/daemon/ax25_codec.py
@@ -0,0 +1,176 @@
+"""
+AX.25 UI frame codec for max25d — address fields, CRC-CCITT FCS, UI parse/build.
+
+Derived from libax25 axutils.c (address encoding) and ax25ipd/crc.c (RFC 1171 FCS).
+KISS DATA carries the AX.25 body without FCS; over-the-air and some paths include FCS.
+"""
+from __future__ import annotations
+
+import re
+from typing import Optional
+
+# HDLC CRC-CCITT (polynomial 0x1021), RFC 1171 / ax25ipd table
+_FCS_TABLE: tuple[int, ...] = (
+ 0x0000, 0x1189, 0x2312, 0x329B, 0x4624, 0x57AD, 0x6536, 0x74BF,
+ 0x8C48, 0x9DC1, 0xAF5A, 0xBED3, 0xCA6C, 0xDBE5, 0xE97E, 0xF8F7,
+ 0x1081, 0x0108, 0x3393, 0x221A, 0x56A5, 0x472C, 0x75B7, 0x643E,
+ 0x9CC9, 0x8D40, 0xBFDB, 0xAE52, 0xDAED, 0xCB64, 0xF9FF, 0xE876,
+ 0x2102, 0x308B, 0x0210, 0x1399, 0x6726, 0x76AF, 0x4434, 0x55BD,
+ 0xAD4A, 0xBCC3, 0x8E58, 0x9FD1, 0xEB6E, 0xFAE7, 0xC87C, 0xD9F5,
+ 0x3183, 0x200A, 0x1291, 0x0318, 0x77A7, 0x662E, 0x54B5, 0x453C,
+ 0xBDCB, 0xAC42, 0x9ED9, 0x8F50, 0xFBEF, 0xEA66, 0xD8FD, 0xC974,
+ 0x4204, 0x538D, 0x6116, 0x709F, 0x0420, 0x15A9, 0x2732, 0x36BB,
+ 0xCE4C, 0xDFC5, 0xED5E, 0xFCD7, 0x8868, 0x99E1, 0xAB7A, 0xBAF3,
+ 0x5285, 0x430C, 0x7197, 0x601E, 0x14A1, 0x0528, 0x37B3, 0x263A,
+ 0xDECD, 0xCF44, 0xFDDF, 0xEC56, 0x98E9, 0x8960, 0xBBFB, 0xAA72,
+ 0x6306, 0x728F, 0x4014, 0x519D, 0x2522, 0x34AB, 0x0630, 0x17B9,
+ 0xEF4E, 0xFEC7, 0xCC5C, 0xDDD5, 0xA96A, 0xB8E3, 0x8A78, 0x9BF1,
+ 0x7387, 0x620E, 0x5095, 0x411C, 0x35A3, 0x242A, 0x16B1, 0x0738,
+ 0xFFCF, 0xEE46, 0xDCDD, 0xCD54, 0xB9EB, 0xA862, 0x9AF9, 0x8B70,
+ 0x8408, 0x9581, 0xA71A, 0xB693, 0xC22C, 0xD3A5, 0xE13E, 0xF0B7,
+ 0x0840, 0x19C9, 0x2B52, 0x3ADB, 0x4E64, 0x5FED, 0x6D76, 0x7CFF,
+ 0x9489, 0x8500, 0xB79B, 0xA612, 0xD2AD, 0xC324, 0xF1BF, 0xE036,
+ 0x18C1, 0x0948, 0x3BD3, 0x2A5A, 0x5EE5, 0x4F6C, 0x7DF7, 0x6C7E,
+ 0xA50A, 0xB483, 0x8618, 0x9791, 0xE32E, 0xF2A7, 0xC03C, 0xD1B5,
+ 0x2942, 0x38CB, 0x0A50, 0x1BD9, 0x6F66, 0x7EEF, 0x4C74, 0x5DFD,
+ 0xB58B, 0xA402, 0x9699, 0x8710, 0xF3AF, 0xE226, 0xD0BD, 0xC134,
+ 0x39C3, 0x284A, 0x1AD1, 0x0B58, 0x7FE7, 0x6E6E, 0x5CF5, 0x4D7C,
+ 0xC60C, 0xD785, 0xE51E, 0xF497, 0x8028, 0x91A1, 0xA33A, 0xB2B3,
+ 0x4A44, 0x5BCD, 0x6956, 0x78DF, 0x0C60, 0x1DE9, 0x2F72, 0x3EFB,
+ 0xD68D, 0xC704, 0xF59F, 0xE416, 0x90A9, 0x8120, 0xB3BB, 0xA232,
+ 0x5AC5, 0x4B4C, 0x79D7, 0x685E, 0x1CE1, 0x0D68, 0x3FF3, 0x2E7A,
+ 0xE70E, 0xF687, 0xC41C, 0xD595, 0xA12A, 0xB0A3, 0x8238, 0x93B1,
+ 0x6B46, 0x7ACF, 0x4854, 0x59DD, 0x2D62, 0x3CEB, 0x0E70, 0x1FF9,
+ 0xF78F, 0xE606, 0xD49D, 0xC514, 0xB1AB, 0xA022, 0x92B9, 0x8330,
+ 0x7BC7, 0x6A4E, 0x58D5, 0x495C, 0x3DE3, 0x2C6A, 0x1EF1, 0x0F78,
+)
+
+_FCS_GOOD = 0xF0B8 # ax25ipd PPPGOODFCS — residual after valid frame + FCS
+
+AX25_UI_CONTROL = 0x03
+AX25_UI_PID = 0xF0
+MIN_UI_FRAME = 16 # dest(7) + src(7) + ctrl + pid
+
+_CALLSIGN_RE = re.compile(r"^([A-Z0-9]{1,6})(?:-([0-9]{1,2}))?$")
+
+
+def parse_callsign(text: str) -> tuple[str, int]:
+ """Parse operator callsign text; invalid SSID clamps to 0 (legacy helper)."""
+ text = text.strip().upper()
+ if "-" in text:
+ call, ssid_s = text.split("-", 1)
+ try:
+ ssid = int(ssid_s)
+ except ValueError:
+ ssid = 0
+ if ssid < 0 or ssid > 15:
+ ssid = 0
+ return call[:6], ssid
+ return text[:6], 0
+
+
+def validate_callsign(text: str) -> tuple[str, int]:
+ """Strict callsign parse (libax25 ax25_aton_entry rules)."""
+ text = text.strip().upper()
+ match = _CALLSIGN_RE.match(text)
+ if not match:
+ raise ValueError(f"invalid AX.25 callsign: {text!r}")
+ call = match.group(1)
+ ssid = int(match.group(2)) if match.group(2) is not None else 0
+ if ssid < 0 or ssid > 15:
+ raise ValueError(f"invalid AX.25 SSID in {text!r}")
+ return call, ssid
+
+
+def format_callsign(call: str, ssid: int) -> str:
+ """Textual callsign; omit -0 suffix (libax25 ax25_ntoa convention)."""
+ call = call.strip().upper()
+ if ssid <= 0:
+ return call
+ if ssid >= 10:
+ return f"{call}-{ssid}"
+ return f"{call}-{ssid}"
+
+
+def ax25_crc(data: bytes) -> int:
+ """Compute AX.25 FCS over body (addresses + control + PID + info)."""
+ fcs = 0xFFFF
+ for b in data:
+ fcs = (fcs >> 8) ^ _FCS_TABLE[(fcs ^ b) & 0xFF]
+ return fcs ^ 0xFFFF
+
+
+def ax25_crc_valid(frame: bytes) -> bool:
+ """True when frame includes a valid little-endian FCS trailer."""
+ fcs = 0xFFFF
+ for b in frame:
+ fcs = (fcs >> 8) ^ _FCS_TABLE[(fcs ^ b) & 0xFF]
+ return fcs == _FCS_GOOD
+
+
+def ax25_encode_address(call: str, ssid: int, last: bool) -> bytes:
+ """Seven-byte AX.25 address field (libax25 ax25_aton_entry layout)."""
+ padded = call.upper().ljust(6)[:6]
+ raw = bytes((ord(c) << 1) for c in padded)
+ ssid_b = ((ssid & 0x0F) << 1) | (0x01 if last else 0x00)
+ return raw + bytes([ssid_b])
+
+
+def ax25_decode_address(raw: bytes) -> tuple[str, int, bool]:
+ call = "".join(chr((b >> 1) & 0x7F) for b in raw[:6]).strip()
+ ssid = (raw[6] >> 1) & 0x0F
+ last = bool(raw[6] & 0x01)
+ return call, ssid, last
+
+
+def ax25_build_ui(src: str, dst: str, info: bytes) -> bytes:
+ src_call, src_ssid = validate_callsign(src)
+ dst_call, dst_ssid = validate_callsign(dst)
+ body = (
+ ax25_encode_address(dst_call, dst_ssid, last=False)
+ + ax25_encode_address(src_call, src_ssid, last=True)
+ + bytes([AX25_UI_CONTROL, AX25_UI_PID])
+ + info
+ )
+ crc = ax25_crc(body)
+ return body + bytes((crc & 0xFF, crc >> 8))
+
+
+def ax25_parse_ui(frame: bytes) -> Optional[tuple[str, str, bytes]]:
+ """
+ Parse a UI frame. Strips FCS only when the trailer validates (ax25ipd ok_crc).
+ KISS payloads are usually FCS-free; over-the-air captures may include FCS.
+ """
+ if len(frame) < MIN_UI_FRAME:
+ return None
+
+ body = frame
+ if len(frame) >= MIN_UI_FRAME + 2 and ax25_crc_valid(frame):
+ body = frame[:-2]
+
+ pos = 0
+ addresses: list[tuple[str, int, bool]] = []
+ while pos + 7 <= len(body):
+ call, ssid, last = ax25_decode_address(body[pos : pos + 7])
+ addresses.append((call, ssid, last))
+ pos += 7
+ if last:
+ break
+ else:
+ return None
+
+ if len(addresses) < 2:
+ return None
+ if pos + 2 > len(body):
+ return None
+ if body[pos] != AX25_UI_CONTROL or body[pos + 1] != AX25_UI_PID:
+ return None
+
+ payload = body[pos + 2 :]
+ dst_call, dst_ssid, _ = addresses[0]
+ src_call, src_ssid, _ = addresses[-1]
+ return (
+ format_callsign(src_call, src_ssid),
+ format_callsign(dst_call, dst_ssid),
+ payload,
+ )
diff --git a/stacks/daemon/banlist.py b/stacks/daemon/banlist.py
new file mode 100644
index 0000000..061ed1f
--- /dev/null
+++ b/stacks/daemon/banlist.py
@@ -0,0 +1,89 @@
+"""Simple AX.25 source ban list for max25d — silent RX drop."""
+from __future__ import annotations
+
+import re
+import sys
+import threading
+from pathlib import Path
+from typing import Optional
+
+DEFAULT_BANS_FILE = Path("/etc/max25/bans.txt")
+
+_AX25_UI_SRC_RE = re.compile(r"\[AX25 UI ([^>]+)>")
+
+
+def extract_ax25_source(line: str) -> Optional[str]:
+ """Return source callsign from a formatted AX.25 UI RX line, or None."""
+ match = _AX25_UI_SRC_RE.search(line)
+ if not match:
+ return None
+ return match.group(1).strip().upper()
+
+
+def callsign_banned(ban_entry: str, source: str) -> bool:
+ """Match ban entry against an incoming source callsign."""
+ ban = ban_entry.strip().upper()
+ src = source.strip().upper()
+ if not ban or not src:
+ return False
+ if "-" in ban:
+ return ban == src
+ return ban == src.split("-", 1)[0]
+
+
+class BanList:
+ """Persistent set of banned AX.25 source addresses."""
+
+ def __init__(self, path: Path | str = DEFAULT_BANS_FILE) -> None:
+ self.path = Path(path)
+ self._lock = threading.Lock()
+ self._entries: set[str] = set()
+ self.load()
+
+ def load(self) -> None:
+ entries: set[str] = set()
+ try:
+ if self.path.is_file():
+ text = self.path.read_text(encoding="utf-8")
+ for raw in text.splitlines():
+ line = raw.split("#", 1)[0].strip().upper()
+ if line:
+ entries.add(line)
+ except OSError as exc:
+ print(f"max25d: banlist load failed ({self.path}): {exc}", file=sys.stderr)
+ with self._lock:
+ self._entries = entries
+
+ def save(self) -> None:
+ lines = sorted(self._entries)
+ content = "\n".join(lines)
+ if content:
+ content += "\n"
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ self.path.write_text(content, encoding="utf-8")
+
+ def list(self) -> list[str]:
+ with self._lock:
+ return sorted(self._entries)
+
+ def add(self, callsign: str) -> None:
+ call = callsign.strip().upper()
+ with self._lock:
+ self._entries.add(call)
+ self.save()
+
+ def remove(self, callsign: str) -> bool:
+ call = callsign.strip().upper()
+ with self._lock:
+ if call not in self._entries:
+ return False
+ self._entries.remove(call)
+ self.save()
+ return True
+
+ def is_banned(self, source: str) -> bool:
+ src = source.strip().upper()
+ if not src:
+ return False
+ with self._lock:
+ return any(callsign_banned(entry, src) for entry in self._entries)
diff --git a/stacks/daemon/daemon_log.py b/stacks/daemon/daemon_log.py
new file mode 100644
index 0000000..f1696df
--- /dev/null
+++ b/stacks/daemon/daemon_log.py
@@ -0,0 +1,237 @@
+"""
+Structured stderr logging for max25d — human- and machine-readable lines.
+
+Format:
+ max25d [LEVEL] [area] message
+ max25d [LEVEL] [area] [device] message
+
+Levels: INFO, OK, WARN, ERROR, EVENT, RECOVERY
+"""
+from __future__ import annotations
+
+import sys
+from dataclasses import dataclass
+from typing import Callable, Optional
+
+PREFIX = "max25d"
+DEVICE_TOKENS = ("tnc2c", "pktnc2", "tmodem", "max25e0", "max25e0:bc0", "max25e0:bc1", "baycom-kiss", "soft-crdop")
+
+
+@dataclass(frozen=True)
+class DeviceSummary:
+ device_id: str
+ backend: str
+ hardware: str
+ serial: str
+ enabled: bool
+ tested: bool
+
+
+class DaemonLogger:
+ """Thread-safe enough for max25d (GIL); all lines go to stderr."""
+
+ def __init__(self, emit: Optional[Callable[[str], None]] = None) -> None:
+ self._emit = emit or self._default_emit
+
+ @staticmethod
+ def _default_emit(line: str) -> None:
+ print(line, file=sys.stderr, flush=True)
+
+ def _line(
+ self,
+ level: str,
+ msg: str,
+ *,
+ area: str = "",
+ device: str = "",
+ ) -> None:
+ parts = [PREFIX, f"[{level}]"]
+ if area:
+ parts.append(f"[{area}]")
+ if device:
+ parts.append(f"[{device}]")
+ parts.append(msg)
+ self._emit(" ".join(parts))
+
+ def info(self, msg: str, *, area: str = "", device: str = "") -> None:
+ self._line("INFO", msg, area=area, device=device)
+
+ def ok(self, msg: str, *, area: str = "", device: str = "") -> None:
+ self._line("OK", msg, area=area, device=device)
+
+ def warn(self, msg: str, *, area: str = "", device: str = "") -> None:
+ self._line("WARN", msg, area=area, device=device)
+
+ def error(self, msg: str, *, area: str = "", device: str = "") -> None:
+ self._line("ERROR", msg, area=area, device=device)
+
+ def event(self, msg: str, *, area: str = "", device: str = "") -> None:
+ self._line("EVENT", msg, area=area, device=device)
+
+ def recovery(self, msg: str, *, device: str = "") -> None:
+ self._line("RECOVERY", msg, area="serial", device=device)
+
+ def _parse_device_prefix(self, text: str) -> tuple[str, str]:
+ head, sep, tail = text.partition(":")
+ if sep and head in DEVICE_TOKENS:
+ return head, tail.strip()
+ device = ""
+ for token in DEVICE_TOKENS:
+ if f"({token})" in text or f" {token})" in text:
+ device = token
+ break
+ return device, text
+
+ def emit_unstructured(self, msg: str) -> None:
+ """Map legacy free-form strings to structured levels."""
+ device, text = self._parse_device_prefix(msg.strip())
+ lower = text.lower()
+ if lower.startswith("warning:"):
+ self.warn(text[8:].strip(), device=device)
+ elif lower.startswith("recovery:"):
+ self.recovery(text[9:].strip(), device=device)
+ elif " prep ok" in lower or lower.startswith("ok:"):
+ self.ok(text, device=device)
+ elif any(x in lower for x in ("failed", "error", "fail:")):
+ self.error(text, device=device)
+ elif lower.startswith("serial watch:"):
+ self.info(text, area="watch", device=device)
+ elif lower.startswith("serial "):
+ self.info(text, area="serial", device=device)
+ elif lower.startswith("stack "):
+ self.info(text, area="stack", device=device)
+ elif lower.startswith("rx ") or lower.startswith("tx "):
+ self.event(text, device=device)
+ else:
+ self.info(text, device=device)
+
+ def banner(self, title: str) -> None:
+ self._emit(f"{PREFIX} === {title} ===")
+
+ def section(self, name: str) -> None:
+ self._emit(f"{PREFIX} [{name}]")
+
+ def kv(self, key: str, value: str, *, indent: int = 0) -> None:
+ pad = " " * indent
+ self._emit(f"{PREFIX} {pad}{key}={value}")
+
+
+LOGGER = DaemonLogger()
+
+
+def device_serial_label(dev) -> str:
+ """Human-readable serial/KISS path for startup summary."""
+ spec = (dev.device_spec or dev.serial_device or "").strip()
+ if dev.backend_type == "kiss-serial":
+ baud = dev.serial_baud or "?"
+ line = (dev.serial_line or "?").upper()
+ dtr = dev.serial_dtr_rts or "default"
+ kiss = dev.serial_kiss_entry or "default"
+ path = spec or dev.serial_device or "?"
+ return f"{path} {baud} {line} dtr={dtr} kiss_entry={kiss}"
+ if dev.backend_type == "baycom-kiss":
+ return f"baycom:{dev.baycom_modem or 'a'} kiss={dev.kiss_link or '?'}"
+ if dev.backend_type == "crdop-tcp":
+ host = dev.crdop_host or "127.0.0.1"
+ port = dev.crdop_port or "?"
+ return f"crdop-tcp {host}:{port}"
+ if spec:
+ return spec
+ return dev.backend_type or "auto"
+
+
+def emit_startup_banner(
+ *,
+ config_path: Optional[str],
+ cfg,
+ devices: list,
+ tested_fn: Callable[[str], bool],
+) -> None:
+ LOGGER.banner("MAX25d starting")
+ LOGGER.section("config")
+ if config_path:
+ LOGGER.kv("ini", config_path, indent=1)
+ else:
+ LOGGER.kv("ini", "(built-in defaults)", indent=1)
+ LOGGER.kv("mode", cfg.mode, indent=1)
+ LOGGER.kv("default_device", cfg.default_device or cfg.device, indent=1)
+ LOGGER.kv("devices", str(len(devices)), indent=1)
+
+ LOGGER.section("network")
+ LOGGER.kv("tcp", f"{cfg.tcp_host}:{cfg.tcp_port}", indent=1)
+ LOGGER.kv("unix", cfg.unix_socket or "(disabled)", indent=1)
+ if cfg.tcp_password:
+ LOGGER.kv("tcp_auth", "enabled", indent=1)
+ else:
+ LOGGER.warn("TCP has no password — set tcp_password before exposing LAN", area="security")
+
+ LOGGER.section("modem")
+ LOGGER.kv("callerid", cfg.callerid, indent=1)
+ LOGGER.kv("callid", cfg.callid, indent=1)
+ LOGGER.kv("ax25_ui", "yes" if cfg.ax25_ui else "no", indent=1)
+ if cfg.bans_file:
+ LOGGER.kv("bans_file", cfg.bans_file, indent=1)
+
+ LOGGER.section("stack")
+ LOGGER.kv("auto_start", "yes" if cfg.auto_start else "no", indent=1)
+ LOGGER.kv("serial_enabled", "yes" if cfg.serial_enabled else "no", indent=1)
+ LOGGER.kv("stack_recover_only", "yes" if cfg.stack_recover_only else "no", indent=1)
+ LOGGER.kv("serial_watch", "yes" if cfg.serial_watch else "no", indent=1)
+ if cfg.serial_watch:
+ LOGGER.kv("serial_watch_interval", f"{cfg.serial_watch_interval}s", indent=1)
+ LOGGER.kv("serial_watch_startup_grace", f"{cfg.serial_watch_startup_grace}s", indent=1)
+ LOGGER.kv("serial_bootwait_escalate", "yes" if cfg.serial_bootwait_escalate else "no", indent=1)
+
+ LOGGER.section("session")
+ LOGGER.kv(
+ "detach",
+ "max25d --session tmux | max25d-session start/attach/stop",
+ indent=1,
+ )
+
+ LOGGER.section("devices")
+ for dev in devices:
+ if not dev.enabled:
+ LOGGER.kv(
+ dev.device_id,
+ f"disabled backend={dev.backend_type or 'auto'}",
+ indent=1,
+ )
+ continue
+ serial = device_serial_label(dev)
+ hw = dev.hardware or cfg.hardware
+ tested = tested_fn(dev.device_id)
+ note = "" if tested else " — not hardware-validated in CI"
+ LOGGER.kv(
+ dev.device_id,
+ f"{hw} {serial}{note}",
+ indent=1,
+ )
+ if not tested and dev.backend_type == "kiss-serial":
+ LOGGER.warn(
+ "RF path not CI-validated — verify on hardware before production",
+ area="devices",
+ device=dev.device_id,
+ )
+
+
+def emit_startup_complete(
+ *,
+ device_lines: list[tuple[str, str, str]],
+ tcp_host: str,
+ tcp_port: int,
+ unix_socket: str,
+) -> None:
+ LOGGER.banner("MAX25d ready")
+ LOGGER.section("listen")
+ LOGGER.kv("tcp", f"{tcp_host}:{tcp_port}", indent=1)
+ LOGGER.kv("unix", unix_socket or "(disabled)", indent=1)
+ if device_lines:
+ LOGGER.section("device_status")
+ for dev_id, stack_st, link_st in device_lines:
+ LOGGER.kv(dev_id, f"stack={stack_st} link={link_st}", indent=1)
+ LOGGER.info(
+ "attach: max25-terminal -U /run/max25/modem.sock | "
+ "detach session: max25d-session attach",
+ area="operator",
+ )
diff --git a/stacks/daemon/device_backends.py b/stacks/daemon/device_backends.py
new file mode 100644
index 0000000..b4c6c29
--- /dev/null
+++ b/stacks/daemon/device_backends.py
@@ -0,0 +1,1076 @@
+"""
+Device backends for max25d — heterogeneous RF paths (TNC, BayCom, CRDOP).
+
+Each enabled [devices] id gets one backend instance. Backends without hardware
+validation log a startup warning but still wire real stack paths (not silent no-ops).
+"""
+from __future__ import annotations
+
+import os
+import select
+import socket
+import struct
+import termios
+import threading
+import time
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+from typing import Callable, Optional
+
+from kiss_bridge import (
+ MAX_PAYLOAD,
+ KissDecoder,
+ SerialProfile,
+ ax25_build_ui,
+ ax25_parse_ui,
+ format_rx_line,
+ kiss_data_frame,
+ serial_profile_for_device,
+)
+from kiss_bridge import KissBridge # noqa: E402 — re-exported wrapper target
+from paths import normalize_max25_bcpr_path
+
+LogFn = Callable[[str], None]
+RxFn = Callable[[str], None]
+InvalidFn = Callable[[], None]
+
+
+def _spec_int(raw: str, default: int) -> int:
+ try:
+ return int(str(raw).strip())
+ except (TypeError, ValueError):
+ return default
+
+# max25e0 host address defaults (overridable in max25d.ini [device.max25e0])
+MAX25E0_DEFAULT_IPV4 = "127.0.0.25/8"
+MAX25E0_DEFAULT_IPV6 = "::25/128"
+
+# manifest.yaml device ids → default hardware + backend kind
+DEVICE_REGISTRY: dict[str, dict[str, str | bool]] = {
+ "tnc2c": {"hardware": "tncs", "backend": "kiss-serial", "tested": True},
+ "pktnc2": {"hardware": "tncs", "backend": "kiss-serial", "tested": False},
+ "tmodem": {"hardware": "tncs", "backend": "kiss-raw-serial", "tested": False},
+ # Kernel baycom-ser12/par96 removed 2026-07-18 — use max25-bcpr → device max25e0
+ "baycom-kiss": {"hardware": "modems", "backend": "kiss-raw-serial", "tested": False},
+ "pccom-kiss": {"hardware": "modems", "backend": "kiss-raw-serial", "tested": False},
+ "max25e0": {"hardware": "modems", "backend": "max25-bcpr-kiss", "tested": True},
+ "max25e0:bc0": {"hardware": "modems", "backend": "max25-bcpr-kiss", "tested": True},
+ "max25e0:bc1": {"hardware": "modems", "backend": "max25-bcpr-kiss", "tested": True},
+ "soft-crdop": {"hardware": "soft-modems", "backend": "crdop-tcp", "tested": True},
+ "audio-dummy": {"hardware": "acoustic-bench", "backend": "audio-dummy", "tested": True},
+}
+
+
+def registry_hardware(device_id: str, fallback: str = "tncs") -> str:
+ entry = DEVICE_REGISTRY.get(device_id, {})
+ return str(entry.get("hardware", fallback))
+
+
+def registry_backend(device_id: str) -> str:
+ entry = DEVICE_REGISTRY.get(device_id, {})
+ return str(entry.get("backend", "kiss-serial"))
+
+
+def registry_tested(device_id: str) -> bool:
+ entry = DEVICE_REGISTRY.get(device_id, {})
+ return bool(entry.get("tested", False))
+
+
+def baycom_ctl_device_id(dev_cfg: DeviceBackendConfig) -> str:
+ """Legacy helper: kernel BayCom ctl device id (stack removed — prefer max25-bcpr)."""
+ entry = DEVICE_REGISTRY.get(dev_cfg.device_id, {})
+ if entry.get("backend") == "baycom-kiss":
+ return dev_cfg.device_id
+ return "max25e0"
+
+
+@dataclass
+class DeviceBackendConfig:
+ device_id: str
+ hardware: str = ""
+ backend_type: str = ""
+ device_spec: str = ""
+ enabled: bool = True
+ # Serial (TNC / baycom-kiss USB)
+ serial_device: str = ""
+ serial_baud: int = 0
+ serial_line: str = ""
+ serial_dtr_rts: str = ""
+ serial_kiss_entry: str = ""
+ # BayCom kernel KISS PTY
+ kiss_link: str = ""
+ baycom_modem: str = "a"
+ baycom_ini: str = ""
+ # max25-bcpr userspace SER12 — max25e0 (+ forks max25e0:bcN)
+ max25_bcpr_ini: str = ""
+ max25_bcpr_device: str = "" # bc0 | bc1
+ # Host addresses (max25e0 family only; forks inherit from max25e0)
+ ipv4: str = ""
+ ipv6: str = ""
+ # Legacy field aliases (read-only mirrors filled by parser)
+ bcpr_ini: str = ""
+ bcpr_device: str = ""
+ # CRDOP TCP
+ crdop_host: str = "127.0.0.1"
+ crdop_port: int = 8515
+ crdop_profile: str = "default"
+ crdop_listen: bool = True
+ # Acoustic bench / audio-dummy
+ audio_mode: str = "loopback" # loopback | alsa | host
+ audio_capture: str = ""
+ audio_playback: str = ""
+ audio_sample_rate: int = 48000
+ audio_host_port: int = 8520
+
+
+class DeviceBackend(ABC):
+ """Common RX/TX/PTT surface for max25d."""
+
+ device_id: str
+ status: str = "closed"
+ backend_type: str = ""
+
+ @abstractmethod
+ def open(self) -> bool:
+ ...
+
+ @abstractmethod
+ def close(self) -> None:
+ ...
+
+ @abstractmethod
+ def attach_session(self, mycall: str) -> bool:
+ ...
+
+ @abstractmethod
+ def detach_session(self) -> None:
+ ...
+
+ @abstractmethod
+ def transmit(self, src: str, dst: str, text: str, ax25_ui: bool) -> tuple[bool, str]:
+ ...
+
+
+class KissSerialBackend(DeviceBackend):
+ """TNC2C / PK-TNC2 — command-mode serial entry into KISS."""
+
+ backend_type = "kiss-serial"
+
+ def __init__(
+ self,
+ cfg: DeviceBackendConfig,
+ root: str,
+ on_rx: RxFn,
+ log: Optional[LogFn] = None,
+ prefix: Optional[str] = None,
+ on_invalid: Optional[InvalidFn] = None,
+ ) -> None:
+ self.device_id = cfg.device_id
+ self._cfg = cfg
+ self._root = root
+ self._prefix = prefix
+ self._on_rx = on_rx
+ self._on_invalid = on_invalid
+ self._log = log or (lambda _m: None)
+ self._bridge: Optional[KissBridge] = None
+ self.status = "closed"
+
+ def _bridge_log(self, msg: str) -> None:
+ self._log(f"{self.device_id}: {msg}")
+
+ def _ini_overrides(self) -> dict[str, str]:
+ out: dict[str, str] = {}
+ if self._cfg.serial_device:
+ out["device"] = self._cfg.serial_device
+ if self._cfg.serial_baud:
+ out["baud"] = str(self._cfg.serial_baud)
+ if self._cfg.serial_line:
+ out["line"] = self._cfg.serial_line
+ if self._cfg.serial_dtr_rts:
+ out["dtr_rts"] = self._cfg.serial_dtr_rts
+ if self._cfg.serial_kiss_entry:
+ out["kiss_entry"] = self._cfg.serial_kiss_entry
+ return out
+
+ def open(self) -> bool:
+ profile = serial_profile_for_device(
+ self.device_id,
+ self._root,
+ self._ini_overrides(),
+ prefix=self._prefix,
+ )
+ bridge = KissBridge(
+ profile,
+ self._on_rx,
+ self._bridge_log,
+ tree_root=self._root,
+ install_prefix=self._prefix,
+ on_invalid=self._on_invalid,
+ )
+ if not bridge.open():
+ self._bridge = bridge
+ self.status = bridge.status
+ return False
+ self._bridge = bridge
+ self.status = bridge.status
+ return True
+
+ def close(self) -> None:
+ if self._bridge is not None:
+ self._bridge.close()
+ self.status = self._bridge.status
+ self._bridge = None
+ else:
+ self.status = "closed"
+
+ def stabilize_session(self, mycall: str, *, force: bool = False) -> bool:
+ if self._bridge is None:
+ return False
+ ok = self._bridge.stabilize_session(mycall, force=force)
+ self.status = self._bridge.status
+ return ok
+
+ def attach_session(self, mycall: str) -> bool:
+ if self._bridge is None:
+ return False
+ ok = self._bridge.attach_session(mycall)
+ self.status = self._bridge.status
+ return ok
+
+ def detach_session(self) -> None:
+ if self._bridge is None:
+ return
+ self._bridge.detach_session()
+ self.status = self._bridge.status
+
+ def transmit(self, src: str, dst: str, text: str, ax25_ui: bool) -> tuple[bool, str]:
+ if self._bridge is None:
+ return False, "serial not ready"
+ ok, display = self._bridge.transmit(src, dst, text, ax25_ui)
+ self.status = self._bridge.status
+ return ok, display
+
+
+class KissRawBackend(DeviceBackend):
+ """Raw KISS on serial or BayCom KISS PTY (no command-mode entry)."""
+
+ backend_type = "kiss-raw"
+
+ def __init__(
+ self,
+ cfg: DeviceBackendConfig,
+ path: str,
+ profile: SerialProfile,
+ on_rx: RxFn,
+ log: Optional[LogFn] = None,
+ *,
+ is_pty: bool = False,
+ ) -> None:
+ self.device_id = cfg.device_id
+ self._path = path
+ self._profile = profile
+ self._on_rx = on_rx
+ self._log = log or (lambda _m: None)
+ self._is_pty = is_pty
+ self._fd: Optional[int] = None
+ self._thread: Optional[threading.Thread] = None
+ self._stop = threading.Event()
+ self._lock = threading.Lock()
+ self._mycall = ""
+ self._kiss_active = False
+ self._decoder = KissDecoder()
+ self.status = "closed"
+
+ def open(self) -> bool:
+ path = self._path
+ if not path:
+ self.status = "error-no-path"
+ self._log(f"{self.device_id}: no KISS path configured")
+ return False
+ if not os.path.exists(path):
+ self.status = "error-no-device"
+ self._log(f"{self.device_id}: path missing: {path}")
+ return False
+ try:
+ fd = os.open(path, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
+ if not self._is_pty:
+ self._configure_serial(fd)
+ termios.tcflush(fd, termios.TCIOFLUSH)
+ except OSError as exc:
+ self.status = "error-open"
+ self._log(f"{self.device_id}: open failed: {exc}")
+ return False
+ self._fd = fd
+ self.status = "open"
+ self._stop.clear()
+ self._thread = threading.Thread(
+ target=self._rx_loop,
+ name=f"kiss-raw-{self.device_id}",
+ daemon=True,
+ )
+ self._thread.start()
+ self._log(f"{self.device_id}: raw KISS open {path}")
+ return True
+
+ def _configure_serial(self, fd: int) -> None:
+ from kiss_bridge import _parse_baud, _parse_line
+
+ speed = _parse_baud(self._profile.baud)
+ databits, parity = _parse_line(self._profile.line)
+ t = termios.tcgetattr(fd)
+ t[0] = t[1] = 0
+ t[2] = termios.CLOCAL | termios.CREAD | databits | parity
+ t[3] = t[4] = t[5] = speed
+ t[6][termios.VMIN] = 0
+ t[6][termios.VTIME] = 5
+ termios.tcsetattr(fd, termios.TCSANOW, t)
+ flags = struct.unpack("I", __import__("fcntl").ioctl(fd, 0x5415, struct.pack("I", 0)))[0]
+ if self._profile.dtr_rts:
+ flags |= 0x004 | 0x002
+ __import__("fcntl").ioctl(fd, 0x5416, struct.pack("I", flags))
+
+ def close(self) -> None:
+ self._stop.set()
+ if self._thread is not None:
+ self._thread.join(timeout=2.0)
+ self._thread = None
+ with self._lock:
+ if self._fd is not None:
+ try:
+ os.close(self._fd)
+ except OSError:
+ pass
+ self._fd = None
+ self._kiss_active = False
+ self.status = "closed"
+ self._decoder = KissDecoder()
+
+ def attach_session(self, mycall: str) -> bool:
+ if self._fd is None:
+ return False
+ self._mycall = mycall.upper()
+ self._kiss_active = True
+ self.status = "ready"
+ return True
+
+ def detach_session(self) -> None:
+ self._kiss_active = False
+ if self._fd is not None:
+ self.status = "open"
+
+ def stabilize_session(self, mycall: str, *, force: bool = False) -> bool:
+ """Reopen KISS path after dead PTY / EIO (e.g. bcprd recycled outside max25d)."""
+ path = self._path
+ if (
+ not force
+ and self._kiss_active
+ and self.status == "ready"
+ and self._fd is not None
+ and path
+ and os.path.exists(path)
+ ):
+ if not self._is_pty:
+ return True
+ # PTY symlink may have been retargeted while our fd still points at a
+ # deleted slave — force reopen when the live path inode differs.
+ try:
+ cur = os.stat(path)
+ fd_st = os.fstat(self._fd)
+ if cur.st_ino == fd_st.st_ino and cur.st_dev == fd_st.st_dev:
+ return True
+ except OSError:
+ pass
+ was_active = self._kiss_active or force
+ call = (mycall or self._mycall or "").upper()
+ self.close()
+ if not self.open():
+ return False
+ if was_active and call:
+ return self.attach_session(call)
+ return self._fd is not None
+
+ def transmit(self, src: str, dst: str, text: str, ax25_ui: bool) -> tuple[bool, str]:
+ if self._fd is None or not self._kiss_active:
+ return False, "KISS not ready"
+ if len(text.encode("utf-8")) > MAX_PAYLOAD:
+ return False, "payload too long"
+ info = text.encode("utf-8")
+ frame = ax25_build_ui(src, dst, info)
+ pkt = kiss_data_frame(0, frame)
+ with self._lock:
+ try:
+ os.write(self._fd, pkt)
+ # PTY: never tcdrain — if the master side dies, drain can hang forever.
+ if not self._is_pty:
+ termios.tcdrain(self._fd)
+ except OSError as exc:
+ self.status = "error-tx"
+ return False, f"tx failed: {exc}"
+ display = format_rx_line(src, dst, info, ax25_ui)
+ return True, display
+
+ def _rx_loop(self) -> None:
+ while not self._stop.is_set():
+ fd = self._fd
+ if fd is None:
+ break
+ try:
+ chunk = os.read(fd, 4096)
+ except BlockingIOError:
+ time.sleep(0.05)
+ continue
+ except OSError:
+ break
+ if not chunk:
+ time.sleep(0.05)
+ continue
+ for _port, payload in self._decoder.feed(chunk):
+ if not payload:
+ continue
+ parsed = ax25_parse_ui(payload)
+ if parsed is None:
+ continue
+ src, dst, info = parsed
+ line = format_rx_line(src, dst, info, ax25_ui=True)
+ self._on_rx(line)
+
+
+class BayComKissBackend(KissRawBackend):
+ """BayCom kernel modem (SER12 / PAR96) via baycom-pr KISS PTY."""
+
+ backend_type = "baycom-kiss"
+
+ def __init__(
+ self,
+ cfg: DeviceBackendConfig,
+ on_rx: RxFn,
+ log: Optional[LogFn] = None,
+ ) -> None:
+ modem = cfg.baycom_modem or "a"
+ kiss = cfg.kiss_link or f"/var/run/baycom-pr/kiss-{modem}"
+ if modem == "a" and not cfg.kiss_link:
+ default = "/var/run/baycom-pr/kiss"
+ if os.path.exists(default) or not os.path.exists(kiss):
+ kiss = default
+ profile = SerialProfile(baud=9600, line="8n1", dtr_rts=False)
+ super().__init__(cfg, kiss, profile, on_rx, log, is_pty=True)
+
+
+class Max25BcprKissBackend(KissRawBackend):
+ """max25-bcpr userspace SER12 via KISS PTY (max25e0 / max25e0:bcN).
+
+ Hardware is a TCM3105-class AFSK modem chip (bits↔AFSK + PTT) only — not a TNC.
+ """
+
+ backend_type = "max25-bcpr-kiss"
+
+ def __init__(
+ self,
+ cfg: DeviceBackendConfig,
+ on_rx: RxFn,
+ log: Optional[LogFn] = None,
+ ) -> None:
+ tag = (cfg.max25_bcpr_device or cfg.bcpr_device or "bc0").strip() or "bc0"
+ kiss = cfg.kiss_link or f"/tmp/max25-bcpr/kiss-{tag}"
+ kiss = normalize_max25_bcpr_path(kiss)
+ profile = SerialProfile(baud=9600, line="8n1", dtr_rts=False)
+ super().__init__(cfg, kiss, profile, on_rx, log, is_pty=True)
+
+
+class KissRawSerialBackend(KissRawBackend):
+ """USB/async BayCom KISS serial (kiss-serial backend)."""
+
+ backend_type = "kiss-raw-serial"
+
+ def __init__(
+ self,
+ cfg: DeviceBackendConfig,
+ root: str,
+ on_rx: RxFn,
+ log: Optional[LogFn] = None,
+ prefix: Optional[str] = None,
+ ) -> None:
+ overrides: dict[str, str] = {}
+ if cfg.serial_device:
+ overrides["device"] = cfg.serial_device
+ if cfg.serial_baud:
+ overrides["baud"] = str(cfg.serial_baud)
+ if cfg.serial_line:
+ overrides["line"] = cfg.serial_line
+ if cfg.serial_dtr_rts:
+ overrides["dtr_rts"] = cfg.serial_dtr_rts
+ prof = serial_profile_for_device(cfg.device_id, root, overrides, prefix=prefix)
+ path = cfg.serial_device or prof.device
+ super().__init__(cfg, path, prof, on_rx, log, is_pty=False)
+
+
+class CrdopTcpBackend(DeviceBackend):
+ """MAX25-SoftModem (CRDOP) via TCP host interface (:8515 / :8516).
+
+ Native M25/KISS host protocol (MAX25-SoftModem) only.
+ """
+
+ backend_type = "crdop-tcp"
+
+ def __init__(
+ self,
+ cfg: DeviceBackendConfig,
+ on_rx: RxFn,
+ log: Optional[LogFn] = None,
+ ) -> None:
+ self.device_id = cfg.device_id
+ self._cfg = cfg
+ self._on_rx = on_rx
+ self._log = log or (lambda _m: None)
+ self._ctrl: Optional[socket.socket] = None
+ self._data: Optional[socket.socket] = None
+ self._thread: Optional[threading.Thread] = None
+ self._stop = threading.Event()
+ self._lock = threading.RLock()
+ self._mycall = ""
+ self._connected = False
+ self.status = "closed"
+
+ def _line_term(self) -> str:
+ return "\n"
+
+ def open(self) -> bool:
+ host = self._cfg.crdop_host
+ port = self._cfg.crdop_port
+ ctrl = None
+ data = None
+ try:
+ ctrl = socket.create_connection((host, port), timeout=5.0)
+ ctrl.settimeout(0.5)
+ data = socket.create_connection((host, port + 1), timeout=5.0)
+ data.settimeout(0.5)
+ except OSError as exc:
+ if ctrl is not None:
+ try:
+ ctrl.close()
+ except OSError:
+ pass
+ self.status = "error-connect"
+ self._log(f"{self.device_id}: CRDOP TCP connect failed ({host}:{port}): {exc}")
+ return False
+ self._ctrl = ctrl
+ self._data = data
+ self.status = "open"
+ self._stop.clear()
+ self._thread = threading.Thread(
+ target=self._rx_loop,
+ name=f"crdop-rx-{self.device_id}",
+ daemon=True,
+ )
+ self._thread.start()
+ self._log(f"{self.device_id}: CRDOP TCP open {host}:{port}")
+ return True
+
+ def close(self) -> None:
+ self._stop.set()
+ if self._thread is not None:
+ self._thread.join(timeout=2.0)
+ self._thread = None
+ for sock in (self._ctrl, self._data):
+ if sock is not None:
+ try:
+ sock.close()
+ except OSError:
+ pass
+ self._ctrl = None
+ self._data = None
+ self._connected = False
+ self.status = "closed"
+
+ def _cmd(self, text: str) -> str:
+ if self._ctrl is None:
+ return ""
+ term = self._line_term()
+ payload = (text.rstrip(term) + term).encode("ascii", errors="replace")
+ with self._lock:
+ self._ctrl.sendall(payload)
+ return self._read_line_unlocked()
+
+ def _read_line_unlocked(self) -> str:
+ if self._ctrl is None:
+ return ""
+ term = self._line_term()
+ term_b = term.encode("ascii")
+ buf = b""
+ deadline = time.time() + 3.0
+ while time.time() < deadline:
+ try:
+ chunk = self._ctrl.recv(4096)
+ except socket.timeout:
+ continue
+ except OSError:
+ break
+ if not chunk:
+ break
+ buf += chunk
+ while term_b in buf:
+ raw, buf = buf.split(term_b, 1)
+ line = raw.decode("ascii", errors="replace").strip()
+ if line:
+ return line
+ return ""
+
+ def attach_session(self, mycall: str) -> bool:
+ if self._ctrl is None:
+ return False
+ self._mycall = mycall.upper()
+ cmds = [
+ "INITIALIZE",
+ "PROTOCOLMODE KISS",
+ f"MYCALL {self._mycall}",
+ ]
+ if self._cfg.crdop_listen:
+ cmds.append("LISTEN TRUE")
+ for cmd in cmds:
+ reply = self._cmd(cmd)
+ self._log(f"{self.device_id}: {cmd} → {reply or '(no reply)'}")
+ self._connected = True
+ self.status = "ready"
+ return True
+
+ def detach_session(self) -> None:
+ if self._ctrl is not None and self._connected:
+ self._cmd("ABORT")
+ self._connected = False
+ if self._ctrl is not None:
+ self.status = "open"
+
+ def transmit(self, src: str, dst: str, text: str, ax25_ui: bool) -> tuple[bool, str]:
+ if self._ctrl is None or self._data is None or not self._connected:
+ return False, "CRDOP not ready"
+ payload = text.encode("utf-8")
+ if len(payload) > MAX_PAYLOAD:
+ return False, "payload too long"
+ with self._lock:
+ try:
+ body = ax25_build_ui(src, dst, payload)
+ if len(body) >= 2:
+ body = body[:-2]
+ self._data.sendall(body)
+ except OSError as exc:
+ self.status = "error-tx"
+ return False, f"tx failed: {exc}"
+ if ax25_ui:
+ display = f"[CRDOP AX25 UI {src}>{dst}] {text}"
+ else:
+ display = text
+ return True, display
+
+ def _rx_loop(self) -> None:
+ while not self._stop.is_set():
+ ctrl = self._ctrl
+ if ctrl is None:
+ break
+ try:
+ ready, _, _ = select.select([ctrl], [], [], 0.5)
+ if not ready:
+ continue
+ chunk = ctrl.recv(4096)
+ except (OSError, socket.timeout):
+ continue
+ if not chunk:
+ time.sleep(0.05)
+ continue
+ term = self._line_term()
+ for line in chunk.decode("ascii", errors="replace").split(term):
+ line = line.strip()
+ if not line:
+ continue
+ if line.startswith("STATUS"):
+ self._on_rx(f"[CRDOP RX {self.device_id}] {line}")
+
+
+class AudioDummyBackend(DeviceBackend):
+ """Acoustic bench dummy — loopback, ALSA sniff, or M25 host TCP."""
+
+ backend_type = "audio-dummy"
+
+ def __init__(
+ self,
+ cfg: DeviceBackendConfig,
+ on_rx: RxFn,
+ log: Optional[LogFn] = None,
+ ) -> None:
+ self.device_id = cfg.device_id
+ self._cfg = cfg
+ self._on_rx = on_rx
+ self._log = log or (lambda _m: None)
+ self._ctrl: Optional[socket.socket] = None
+ self._data: Optional[socket.socket] = None
+ self._thread: Optional[threading.Thread] = None
+ self._stop = threading.Event()
+ self._lock = threading.RLock()
+ self._mycall = ""
+ self._connected = False
+ self._engine = None
+ self.status = "closed"
+
+ def _import_engine(self):
+ import sys
+ from pathlib import Path
+
+ lib = Path(__file__).resolve().parents[1] / "crdop" / "lib"
+ if str(lib) not in sys.path:
+ sys.path.insert(0, str(lib))
+ from acoustic_engine import AcousticEngine # noqa: WPS433
+ from sound_proxy import SoundConfig # noqa: WPS433
+
+ sound = SoundConfig(
+ capture=self._cfg.audio_capture or "default",
+ playback=self._cfg.audio_playback or self._cfg.audio_capture or "default",
+ sample_rate=self._cfg.audio_sample_rate,
+ )
+ return AcousticEngine(sample_rate=self._cfg.audio_sample_rate, sound=sound)
+
+ def open(self) -> bool:
+ mode = (self._cfg.audio_mode or "loopback").lower()
+ if mode == "host":
+ host = "127.0.0.1"
+ port = self._cfg.audio_host_port
+ ctrl = None
+ data = None
+ try:
+ ctrl = socket.create_connection((host, port), timeout=3.0)
+ ctrl.settimeout(0.5)
+ data = socket.create_connection((host, port + 1), timeout=3.0)
+ data.settimeout(0.5)
+ except OSError as exc:
+ if ctrl is not None:
+ try:
+ ctrl.close()
+ except OSError:
+ pass
+ self.status = "error-connect"
+ self._log(f"{self.device_id}: audio-dummy host connect failed: {exc}")
+ return False
+ self._ctrl = ctrl
+ self._data = data
+ self._stop.clear()
+ self._thread = threading.Thread(
+ target=self._host_rx_loop,
+ name=f"audio-dummy-{self.device_id}",
+ daemon=True,
+ )
+ self._thread.start()
+ self.status = "open"
+ self._log(f"{self.device_id}: audio-dummy host {host}:{port}")
+ return True
+
+ try:
+ self._engine = self._import_engine()
+ except Exception as exc:
+ self.status = "error-engine"
+ self._log(f"{self.device_id}: audio engine load failed: {exc}")
+ return False
+
+ if mode == "alsa" and self._cfg.audio_capture:
+ self._stop.clear()
+ self._thread = threading.Thread(
+ target=self._alsa_sniff_loop,
+ name=f"audio-sniff-{self.device_id}",
+ daemon=True,
+ )
+ self._thread.start()
+ self.status = "open"
+ self._log(f"{self.device_id}: audio-dummy mode={mode}")
+ return True
+
+ def close(self) -> None:
+ self._stop.set()
+ if self._thread is not None:
+ self._thread.join(timeout=2.0)
+ self._thread = None
+ for sock in (self._ctrl, self._data):
+ if sock is not None:
+ try:
+ sock.close()
+ except OSError:
+ pass
+ self._ctrl = None
+ self._data = None
+ self._connected = False
+ self.status = "closed"
+
+ def _host_cmd(self, text: str) -> str:
+ if self._ctrl is None:
+ return ""
+ payload = (text.rstrip("\n") + "\n").encode("ascii", errors="replace")
+ with self._lock:
+ self._ctrl.sendall(payload)
+ buf = b""
+ deadline = time.time() + 2.0
+ while time.time() < deadline:
+ try:
+ chunk = self._ctrl.recv(4096)
+ except socket.timeout:
+ continue
+ if not chunk:
+ break
+ buf += chunk
+ if b"\n" in buf:
+ line, _ = buf.split(b"\n", 1)
+ return line.decode("ascii", errors="replace").strip()
+ return ""
+
+ def attach_session(self, mycall: str) -> bool:
+ self._mycall = mycall.upper()
+ if self._ctrl is not None:
+ for cmd in (
+ "INITIALIZE",
+ "PROTOCOLMODE KISS",
+ f"MYCALL {self._mycall}",
+ "LISTEN TRUE",
+ ):
+ reply = self._host_cmd(cmd)
+ self._log(f"{self.device_id}: {cmd} → {reply or '(no reply)'}")
+ self._connected = True
+ self.status = "ready"
+ return True
+
+ def detach_session(self) -> None:
+ self._connected = False
+ if self._ctrl is not None:
+ self.status = "open"
+ else:
+ self.status = "closed"
+
+ def transmit(self, src: str, dst: str, text: str, ax25_ui: bool) -> tuple[bool, str]:
+ if not self._connected:
+ return False, "audio-dummy not ready"
+ payload = text.encode("utf-8")
+ if len(payload) > MAX_PAYLOAD:
+ return False, "payload too long"
+
+ if self._engine is not None:
+ pcm = self._engine.encode_ax25_ui(src, dst, text)
+ rep = self._engine.analyze_pcm(pcm)
+ for line in rep.decode_lines:
+ self._on_rx(f"[AUDIO RX {self.device_id}] {line}")
+ display = f"[AX25 UI {src}>{dst}] {text}" if ax25_ui else text
+ return True, display
+
+ if self._data is None:
+ return False, "no data channel"
+ import sys
+ from pathlib import Path
+
+ lib = Path(__file__).resolve().parents[1] / "crdop" / "lib"
+ if str(lib) not in sys.path:
+ sys.path.insert(0, str(lib))
+ from ax25_codec import ax25_build_ui # noqa: WPS433
+
+ body = ax25_build_ui(src, dst, payload)
+ if len(body) >= 2:
+ body = body[:-2]
+ try:
+ with self._lock:
+ self._data.sendall(body)
+ except OSError as exc:
+ return False, f"tx failed: {exc}"
+ display = f"[AX25 UI {src}>{dst}] {text}" if ax25_ui else text
+ return True, display
+
+ def _alsa_sniff_loop(self) -> None:
+ import sys
+ from pathlib import Path
+
+ lib = Path(__file__).resolve().parents[1] / "crdop" / "lib"
+ if str(lib) not in sys.path:
+ sys.path.insert(0, str(lib))
+ from sound_proxy import SoundProxy # noqa: WPS433
+
+ if self._engine is None:
+ return
+ proxy = SoundProxy(self._engine.sound)
+
+ def on_pcm(chunk: bytes) -> None:
+ rep = self._engine.analyze_pcm(chunk)
+ for line in rep.decode_lines:
+ self._on_rx(f"[SNIFF {self.device_id}] {line}")
+
+ try:
+ proxy.sniff_loop(chunk_symbols=40, on_pcm=on_pcm, stop=self._stop)
+ except Exception as exc:
+ self._log(f"{self.device_id}: sniff error: {exc}")
+
+ def _host_rx_loop(self) -> None:
+ while not self._stop.is_set():
+ if self._ctrl is None:
+ break
+ try:
+ ready, _, _ = select.select([self._ctrl], [], [], 0.5)
+ if not ready:
+ continue
+ chunk = self._ctrl.recv(4096)
+ except (OSError, socket.timeout):
+ continue
+ if not chunk:
+ time.sleep(0.05)
+ continue
+ for line in chunk.decode("ascii", errors="replace").split("\n"):
+ line = line.strip()
+ if line.startswith("STATUS"):
+ self._on_rx(f"[AUDIO RX {self.device_id}] {line}")
+
+
+def parse_device_spec(device_id: str, spec: str, cp, cfg_defaults: dict) -> DeviceBackendConfig:
+ """Build backend config from [devices] value and optional [device.<id>]."""
+ dev = DeviceBackendConfig(device_id=device_id)
+ dev.hardware = registry_hardware(device_id, cfg_defaults.get("hardware", "tncs"))
+ dev.backend_type = registry_backend(device_id)
+
+ section = f"device.{device_id}"
+ sec_opts: dict[str, str] = {}
+ if cp.has_section(section):
+ sec_opts = {k: cp.get(section, k) for k in cp.options(section)}
+
+ if sec_opts.get("hardware"):
+ dev.hardware = sec_opts["hardware"]
+ if sec_opts.get("backend"):
+ dev.backend_type = sec_opts["backend"]
+
+ spec = (spec or "").strip()
+ dev.device_spec = spec
+
+ if spec.startswith("baycom:"):
+ dev.backend_type = "baycom-kiss"
+ dev.baycom_modem = spec.split(":", 1)[1].strip() or "a"
+ dev.hardware = "modems"
+ elif spec.startswith("max25-bcpr:") or spec.startswith("bcpr:"):
+ # Userspace SER12 — product face max25-bcpr; device id remains max25e0
+ dev.backend_type = "max25-bcpr-kiss"
+ tag = spec.split(":", 1)[1].strip() or "bc0"
+ dev.max25_bcpr_device = tag
+ dev.bcpr_device = tag # legacy alias
+ dev.hardware = "modems"
+ if not sec_opts.get("kiss_link"):
+ dev.kiss_link = f"/tmp/max25-bcpr/kiss-{tag}"
+ elif spec.startswith("crdop:"):
+ dev.backend_type = "crdop-tcp"
+ dev.crdop_profile = spec.split(":", 1)[1].strip() or "default"
+ dev.hardware = "soft-modems"
+ elif spec.startswith("audio:"):
+ dev.backend_type = "audio-dummy"
+ dev.audio_mode = spec.split(":", 1)[1].strip() or "loopback"
+ dev.hardware = "acoustic-bench"
+ elif spec.startswith("/") or spec.startswith("dev:"):
+ if dev.backend_type in ("baycom-kiss",):
+ dev.kiss_link = spec
+ else:
+ dev.serial_device = spec.removeprefix("dev:")
+ if dev.backend_type == "crdop-tcp":
+ dev.backend_type = "kiss-serial"
+ elif spec:
+ dev.serial_device = spec
+
+ if sec_opts.get("kiss_link"):
+ dev.kiss_link = sec_opts["kiss_link"]
+ if dev.backend_type in ("max25-bcpr-kiss", "bcpr-kiss") and dev.kiss_link:
+ dev.kiss_link = normalize_max25_bcpr_path(dev.kiss_link)
+ if sec_opts.get("modem"):
+ dev.baycom_modem = sec_opts["modem"]
+ if sec_opts.get("baycom_ini"):
+ dev.baycom_ini = sec_opts["baycom_ini"]
+ if sec_opts.get("max25_bcpr_ini") or sec_opts.get("bcpr_ini"):
+ ini_path = sec_opts.get("max25_bcpr_ini") or sec_opts.get("bcpr_ini") or ""
+ dev.max25_bcpr_ini = ini_path
+ dev.bcpr_ini = ini_path
+ if sec_opts.get("max25_bcpr_device") or sec_opts.get("bcpr_device"):
+ tag = sec_opts.get("max25_bcpr_device") or sec_opts.get("bcpr_device") or ""
+ dev.max25_bcpr_device = tag
+ dev.bcpr_device = tag
+ if sec_opts.get("ipv4"):
+ dev.ipv4 = sec_opts["ipv4"].strip()
+ if sec_opts.get("ipv6"):
+ dev.ipv6 = sec_opts["ipv6"].strip()
+ if sec_opts.get("host"):
+ dev.crdop_host = sec_opts["host"]
+ if sec_opts.get("port"):
+ dev.crdop_port = _spec_int(sec_opts["port"], dev.crdop_port)
+ if sec_opts.get("listen"):
+ dev.crdop_listen = sec_opts["listen"].lower() in ("1", "yes", "true", "on")
+ if sec_opts.get("mode"):
+ dev.audio_mode = sec_opts["mode"]
+ if sec_opts.get("capture"):
+ dev.audio_capture = sec_opts["capture"]
+ if sec_opts.get("playback"):
+ dev.audio_playback = sec_opts["playback"]
+ if sec_opts.get("sample_rate"):
+ dev.audio_sample_rate = _spec_int(sec_opts["sample_rate"], dev.audio_sample_rate)
+ if sec_opts.get("host_port"):
+ dev.audio_host_port = _spec_int(sec_opts["host_port"], dev.audio_host_port)
+
+ serial_sec = f"serial.{device_id}"
+ if cp.has_section(serial_sec):
+ if cp.has_option(serial_sec, "device"):
+ dev.serial_device = cp.get(serial_sec, "device")
+ if cp.has_option(serial_sec, "baud"):
+ dev.serial_baud = cp.getint(serial_sec, "baud")
+ if cp.has_option(serial_sec, "line"):
+ dev.serial_line = cp.get(serial_sec, "line")
+ if cp.has_option(serial_sec, "dtr_rts"):
+ dev.serial_dtr_rts = cp.get(serial_sec, "dtr_rts")
+ if cp.has_option(serial_sec, "kiss_entry"):
+ dev.serial_kiss_entry = cp.get(serial_sec, "kiss_entry")
+
+ # max25e0 family: hardcoded host addresses (forks inherit from root max25e0)
+ if device_id == "max25e0" or device_id.startswith("max25e0:"):
+ root_opts: dict[str, str] = {}
+ if device_id != "max25e0" and cp.has_section("device.max25e0"):
+ root_opts = {k: cp.get("device.max25e0", k) for k in cp.options("device.max25e0")}
+ if not dev.ipv4:
+ dev.ipv4 = (root_opts.get("ipv4") or "").strip() or MAX25E0_DEFAULT_IPV4
+ if not dev.ipv6:
+ dev.ipv6 = (root_opts.get("ipv6") or "").strip() or MAX25E0_DEFAULT_IPV6
+
+ return dev
+
+
+def create_backend(
+ dev_cfg: DeviceBackendConfig,
+ root: str,
+ on_rx: RxFn,
+ log: Optional[LogFn] = None,
+ prefix: Optional[str] = None,
+ on_invalid: Optional[InvalidFn] = None,
+) -> DeviceBackend:
+ kind = dev_cfg.backend_type or registry_backend(dev_cfg.device_id)
+ if kind == "kiss-serial":
+ return KissSerialBackend(dev_cfg, root, on_rx, log, prefix=prefix, on_invalid=on_invalid)
+ if kind == "baycom-kiss":
+ return BayComKissBackend(dev_cfg, on_rx, log)
+ if kind in ("max25-bcpr-kiss", "bcpr-kiss"):
+ return Max25BcprKissBackend(dev_cfg, on_rx, log)
+ if kind == "kiss-raw-serial":
+ return KissRawSerialBackend(dev_cfg, root, on_rx, log, prefix=prefix)
+ if kind == "crdop-tcp":
+ return CrdopTcpBackend(dev_cfg, on_rx, log)
+ if kind == "audio-dummy":
+ return AudioDummyBackend(dev_cfg, on_rx, log)
+ return KissSerialBackend(dev_cfg, root, on_rx, log, prefix=prefix)
+
+
+def backend_needs_stack(kind: str) -> bool:
+ return kind in (
+ "kiss-serial",
+ "baycom-kiss",
+ "max25-bcpr-kiss", "bcpr-kiss",
+ "kiss-raw-serial",
+ "crdop-tcp",
+ "audio-dummy",
+ )
+
+
+def backend_serial_label(backend: Optional[DeviceBackend]) -> str:
+ if backend is None:
+ return "n/a"
+ return backend.status
+
+
+# Legacy alias (tests / transitional)
+BcprKissBackend = Max25BcprKissBackend
diff --git a/stacks/daemon/kiss_bridge.py b/stacks/daemon/kiss_bridge.py
new file mode 100644
index 0000000..b433d0e
--- /dev/null
+++ b/stacks/daemon/kiss_bridge.py
@@ -0,0 +1,574 @@
+"""
+KISS serial bridge for max25d — AX.25 UI over TNC2C / PK-TNC2.
+
+PTT: TNC firmware keys on KISS DATA (requires MYCALL); kernel BayCom (baycom_ser_fdx)
+keys RTS in the driver when the KISS bridge accepts a DATA frame — no max25d PTT command.
+"""
+from __future__ import annotations
+
+import fcntl
+import os
+import struct
+import termios
+import threading
+import time
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Callable, Optional
+
+from ax25_codec import ( # noqa: E402
+ ax25_build_ui,
+ ax25_crc,
+ ax25_crc_valid,
+ ax25_parse_ui,
+ format_callsign,
+ parse_callsign,
+ validate_callsign,
+)
+from tx_pace import tx_pace_before_send # noqa: E402
+
+FEND = 0xC0
+FESC = 0xDB
+TFEND = 0xDC
+TFESC = 0xDD
+
+# Native KISS return (TheFirmware TF 2.7) — firmware reset to banner, not TAPR kiss off
+KISS_RETURN_FRAME = b"\xc0\xff\xc0"
+
+KISS_CMD_DATA = 0x00
+MAX_FRAME = 1024
+MAX_PAYLOAD = 256
+
+
+@dataclass
+class SerialProfile:
+ device: str = "/dev/ttyS4"
+ baud: int = 19200
+ line: str = "8n1"
+ dtr_rts: bool = True
+ kiss_entry: str = "kiss_on" # kiss_on | auto
+
+
+class KissDecoder:
+ def __init__(self) -> None:
+ self._buf = bytearray()
+ self._in_frame = False
+ self._escape = False
+
+ def feed(self, data: bytes) -> list[tuple[int, bytes]]:
+ frames: list[tuple[int, bytes]] = []
+ for byte in data:
+ if byte == FEND:
+ if self._in_frame and self._buf:
+ parsed = self._deliver()
+ if parsed is not None:
+ frames.append(parsed)
+ self._in_frame = True
+ self._escape = False
+ self._buf.clear()
+ continue
+ if not self._in_frame:
+ continue
+ if self._escape:
+ if byte == TFEND:
+ byte = FEND
+ elif byte == TFESC:
+ byte = FESC
+ self._escape = False
+ elif byte == FESC:
+ self._escape = True
+ continue
+ if len(self._buf) >= MAX_FRAME:
+ self._buf.clear()
+ self._in_frame = False
+ continue
+ self._buf.append(byte)
+ return frames
+
+ def _deliver(self) -> Optional[tuple[int, bytes]]:
+ if len(self._buf) < 1:
+ return None
+ cmd_byte = self._buf[0]
+ if (cmd_byte & 0x0F) != KISS_CMD_DATA:
+ return None
+ port = (cmd_byte >> 4) & 0x0F
+ payload = bytes(self._buf[1:])
+ return port, payload
+
+
+def kiss_escape(data: bytes) -> bytes:
+ out = bytearray()
+ for b in data:
+ if b == FEND:
+ out.extend((FESC, TFEND))
+ elif b == FESC:
+ out.extend((FESC, TFESC))
+ else:
+ out.append(b)
+ return bytes(out)
+
+
+def kiss_encode(port: int, cmd: int, payload: bytes) -> bytes:
+ cmd_byte = ((port & 0x0F) << 4) | (cmd & 0x0F)
+ return b"\xC0" + kiss_escape(bytes([cmd_byte]) + payload) + b"\xC0"
+
+
+def kiss_data_frame(port: int, ax25_frame: bytes) -> bytes:
+ """Build KISS DATA; strip FCS when CRC validates (ax25ipd / KISS convention)."""
+ if ax25_crc_valid(ax25_frame):
+ ax25_frame = ax25_frame[:-2]
+ return kiss_encode(port, KISS_CMD_DATA, ax25_frame)
+
+
+def format_rx_line(src: str, dst: str, payload: bytes, ax25_ui: bool) -> str:
+ try:
+ text = payload.decode("utf-8")
+ except UnicodeDecodeError:
+ text = payload.decode("utf-8", errors="replace")
+ if ax25_ui:
+ return f"[AX25 UI {src}>{dst}] {text}"
+ return text
+
+
+def load_env_file(path: str) -> dict[str, str]:
+ out: dict[str, str] = {}
+ if not path or not os.path.isfile(path):
+ return out
+ with open(path, encoding="utf-8") as fh:
+ for line in fh:
+ line = line.strip()
+ if not line or line.startswith("#"):
+ continue
+ if "=" in line:
+ key, val = line.split("=", 1)
+ out[key.strip()] = val.strip()
+ return out
+
+
+def _load_serial_env(device_id: str, root: str, prefix: Optional[str] = None) -> dict[str, str]:
+ from paths import serial_env_candidates
+
+ tree = Path(root)
+ pref = Path(prefix) if prefix else None
+ for path in serial_env_candidates(device_id, tree, pref):
+ env = load_env_file(str(path))
+ if env:
+ return env
+ return {}
+
+
+def serial_profile_for_device(
+ device_id: str,
+ root: str,
+ ini: dict[str, str],
+ prefix: Optional[str] = None,
+) -> SerialProfile:
+ prof = SerialProfile()
+ if ini.get("device"):
+ prof.device = ini["device"]
+ if ini.get("baud"):
+ prof.baud = int(ini["baud"])
+ if ini.get("line"):
+ prof.line = ini["line"].lower()
+ if ini.get("dtr_rts"):
+ prof.dtr_rts = ini["dtr_rts"].lower() in ("1", "yes", "true", "on")
+ if ini.get("kiss_entry"):
+ prof.kiss_entry = ini["kiss_entry"].lower()
+
+ env = _load_serial_env(device_id, root, prefix)
+
+ if device_id == "tnc2c":
+ prof.device = ini.get("device") or env.get("TNC2C_DEV", prof.device)
+ prof.baud = int(ini.get("baud") or env.get("TNC2C_BAUD", prof.baud))
+ prof.line = (ini.get("line") or env.get("TNC2C_LINE", prof.line)).lower()
+ if "dtr_rts" not in ini:
+ prof.dtr_rts = True
+ if "kiss_entry" not in ini:
+ prof.kiss_entry = "kiss_on"
+ elif device_id == "pktnc2":
+ prof.device = ini.get("device") or env.get("PKTNC2_DEV") or env.get("TNC_DEV", prof.device)
+ prof.baud = int(ini.get("baud") or env.get("PKTNC2_BAUD") or env.get("TNC_BAUD", "9600"))
+ prof.line = (ini.get("line") or env.get("PKTNC2_LINE") or env.get("TNC_LINE", "8n1")).lower()
+ if "dtr_rts" not in ini:
+ prof.dtr_rts = False
+ if "kiss_entry" not in ini:
+ prof.kiss_entry = "auto"
+ elif device_id in ("pccom-kiss", "baycom-kiss"):
+ prof.device = ini.get("device") or env.get("PCCOM_KISS_DEV", prof.device or "/dev/ttyUSB0")
+ prof.baud = int(ini.get("baud") or env.get("PCCOM_KISS_BAUD", "9600"))
+ prof.line = (ini.get("line") or env.get("PCCOM_KISS_LINE", "8n1")).lower()
+ dtr = ini.get("dtr_rts") or env.get("PCCOM_KISS_DTR_RTS", "no")
+ prof.dtr_rts = str(dtr).lower() in ("1", "yes", "true", "on")
+ if "kiss_entry" not in ini:
+ prof.kiss_entry = "none"
+ elif device_id == "tmodem":
+ default_dev = "/dev/cuaU0" if sys.platform.startswith("freebsd") else "/dev/ttyACM0"
+ prof.device = ini.get("device") or env.get("TMODEM_DEV", prof.device or default_dev)
+ prof.baud = int(ini.get("baud") or env.get("TMODEM_BAUD", "115200"))
+ prof.line = (ini.get("line") or env.get("TMODEM_LINE", "8n1")).lower()
+ dtr = ini.get("dtr_rts") or env.get("TMODEM_DTR_RTS", "no")
+ prof.dtr_rts = str(dtr).lower() in ("1", "yes", "true", "on")
+ if "kiss_entry" not in ini:
+ prof.kiss_entry = "none"
+ return prof
+
+
+def _parse_line(line: str) -> tuple[int, int]:
+ line = line.lower()
+ if line == "7e1":
+ return termios.CS7, termios.PARENB
+ return termios.CS8, 0
+
+
+def _parse_baud(baud: int) -> int:
+ table = {
+ 1200: termios.B1200,
+ 2400: termios.B2400,
+ 4800: termios.B4800,
+ 9600: termios.B9600,
+ 19200: termios.B19200,
+ }
+ if baud not in table:
+ raise ValueError(f"unsupported baud: {baud}")
+ return table[baud]
+
+
+class KissBridge:
+ """Thread-safe KISS bridge on a serial TNC port."""
+
+ def __init__(
+ self,
+ profile: SerialProfile,
+ on_rx: Callable[[str], None],
+ log: Optional[Callable[[str], None]] = None,
+ *,
+ tree_root: str = "",
+ install_prefix: Optional[str] = None,
+ on_invalid: Optional[Callable[[], None]] = None,
+ ) -> None:
+ self.profile = profile
+ self._on_rx = on_rx
+ self._on_invalid = on_invalid
+ self._log = log or (lambda _m: None)
+ self._tree_root = tree_root
+ self._install_prefix = install_prefix
+ self._fd: Optional[int] = None
+ self._thread: Optional[threading.Thread] = None
+ self._stop = threading.Event()
+ self._lock = threading.Lock()
+ self._kiss_active = False
+ self._mycall = ""
+ self.status = "closed"
+ self._decoder = KissDecoder()
+
+ def open(self) -> bool:
+ dev = self.profile.device
+ if not os.access(dev, os.R_OK | os.W_OK):
+ self.status = "error-no-device"
+ self._log(f"serial: no access to {dev}")
+ return False
+ try:
+ speed = _parse_baud(self.profile.baud)
+ databits, parity = _parse_line(self.profile.line)
+ except ValueError as exc:
+ self.status = "error-config"
+ self._log(f"serial: {exc}")
+ return False
+ try:
+ fd = os.open(dev, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
+ t = termios.tcgetattr(fd)
+ t[0] = t[1] = 0
+ t[2] = termios.CLOCAL | termios.CREAD | databits | parity
+ t[3] = t[4] = t[5] = speed
+ t[6][termios.VMIN] = 0
+ t[6][termios.VTIME] = 5
+ termios.tcsetattr(fd, termios.TCSANOW, t)
+ termios.tcflush(fd, termios.TCIOFLUSH)
+ flags = struct.unpack("I", fcntl.ioctl(fd, 0x5415, struct.pack("I", 0)))[0]
+ if self.profile.dtr_rts:
+ flags |= 0x004 | 0x002
+ fcntl.ioctl(fd, 0x5416, struct.pack("I", flags))
+ except OSError as exc:
+ self.status = "error-open"
+ self._log(f"serial open failed: {exc}")
+ return False
+ self._fd = fd
+ self._log(f"serial open {dev} {self.profile.baud} {self.profile.line.upper()}")
+ if self.profile.dtr_rts:
+ time.sleep(2.0)
+ self._log("serial: DTR settle (2s)")
+ self.status = "open"
+ return True
+
+ def close(self) -> None:
+ self._stop_rx_thread()
+ with self._lock:
+ if self._fd is not None:
+ if self._kiss_active:
+ self._write_unlocked(KISS_RETURN_FRAME)
+ try:
+ os.close(self._fd)
+ except OSError:
+ pass
+ self._fd = None
+ self._kiss_active = False
+ self.status = "closed"
+ self._decoder = KissDecoder()
+
+ def _stop_rx_thread(self) -> None:
+ """Stop KISS RX thread so recovery owns the serial FD exclusively."""
+ self._stop.set()
+ if self._thread is not None:
+ self._thread.join(timeout=2.0)
+ self._thread = None
+ self._stop.clear()
+
+ def _start_rx_thread(self) -> None:
+ """Start KISS RX after terminal recovery and KISS entry succeed."""
+ if self._fd is None or self._thread is not None or not self._kiss_active:
+ return
+ self._stop.clear()
+ self._thread = threading.Thread(target=self._rx_loop, name="kiss-rx", daemon=True)
+ self._thread.start()
+
+ def attach_session(self, mycall: str) -> bool:
+ if self._fd is None:
+ return False
+ self._mycall = mycall.upper()
+ self._stop_rx_thread()
+ with self._lock:
+ ok = self._stabilize_unlocked(self._mycall, force_ladder=False)
+ if ok:
+ self._start_rx_thread()
+ return ok
+
+ def stabilize_session(self, mycall: str, *, force: bool = False) -> bool:
+ """Probe terminal/KISS health and repair without closing the port (keeps DTR)."""
+ if self._fd is None:
+ self.status = "error-open"
+ return False
+ if not force and self._kiss_active and self.status == "ready":
+ return True
+ self._mycall = mycall.upper()
+ self._stop_rx_thread()
+ with self._lock:
+ ok = self._stabilize_unlocked(self._mycall, force_ladder=force)
+ if ok:
+ self._start_rx_thread()
+ return ok
+
+ def _load_recovery_mod(self):
+ import importlib.util
+
+ from paths import tnc_serial_recovery_path
+
+ path = None
+ if self._tree_root:
+ prefix = Path(self._install_prefix) if self._install_prefix else None
+ path = tnc_serial_recovery_path(Path(self._tree_root), prefix)
+ if path is None:
+ path = Path(__file__).resolve().parents[1] / "tncs" / "tnc_serial_recovery.py"
+ if not path.is_file():
+ return None
+ spec = importlib.util.spec_from_file_location("tnc_serial_recovery", path)
+ if spec is None or spec.loader is None:
+ return None
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ return mod
+
+ def _recovery_io(self) -> tuple[Callable[[bytes], None], Callable[[float], bytes]]:
+ def wf(data: bytes) -> None:
+ self._write_unlocked(data)
+
+ def rf(seconds: float) -> bytes:
+ return self._drain_unlocked(seconds)
+
+ return wf, rf
+
+ def _leave_kiss_unlocked(self) -> None:
+ if not self._kiss_active:
+ return
+ self._write_unlocked(KISS_RETURN_FRAME)
+ time.sleep(0.3)
+ self._drain_unlocked(0.2)
+ self._kiss_active = False
+
+ def _enter_kiss_session_unlocked(self) -> bool:
+ if not self._set_mycall_unlocked(self._mycall):
+ self._log("serial: MYCALL may have failed")
+ if not self._enter_kiss_unlocked():
+ self.status = "error-kiss"
+ self._kiss_active = False
+ return False
+ self._kiss_active = True
+ self.status = "ready"
+ return True
+
+ def _stabilize_unlocked(self, mycall: str, *, force_ladder: bool) -> bool:
+ """Host probe, optional recovery ladder, MYCALL + KISS — port stays open."""
+ self._mycall = mycall.upper()
+ try:
+ mod = self._load_recovery_mod()
+ wf, rf = self._recovery_io()
+ self._leave_kiss_unlocked()
+
+ if mod is None:
+ self._log("serial: tnc_serial_recovery.py not found")
+ self.status = "error-config"
+ self._kiss_active = False
+ return False
+
+ ok, probe_data, only_echo = mod.probe_info(wf, rf, pause=0.25)
+ if not ok or only_echo or force_ladder:
+ self._log(
+ f"serial: initial probe — {mod.format_rx_brief(probe_data)}, "
+ f"echo_only={only_echo}, banner={mod.has_banner(probe_data)}"
+ )
+ if ok and not only_echo and not force_ladder:
+ return self._enter_kiss_session_unlocked()
+ if only_echo or not ok or force_ladder:
+ label = "auto-repair" if force_ladder else "recovery ladder"
+ self._log(f"serial: {label}")
+ ok, _ = mod.recover_terminal(wf, rf, log=self._log)
+ if not ok:
+ self.status = "error-host"
+ self._kiss_active = False
+ return False
+ return self._enter_kiss_session_unlocked()
+ self.status = "error-host"
+ self._kiss_active = False
+ return False
+ except OSError as exc:
+ self.status = "error-io"
+ self._kiss_active = False
+ self._log(f"serial: stabilize I/O error ({exc})")
+ return False
+
+ def _recover_terminal_unlocked(self) -> bool:
+ """Legacy hook — full ladder when probe fails."""
+ try:
+ mod = self._load_recovery_mod()
+ if mod is None:
+ return True
+ wf, rf = self._recovery_io()
+ ok, _, only_echo = mod.probe_info(wf, rf)
+ if ok and not only_echo:
+ self._log("serial: terminal mode OK")
+ return True
+ self._log("serial: software recovery ladder")
+ ok, _ = mod.recover_terminal(wf, rf, log=self._log)
+ if ok:
+ self._log("serial: recovery OK")
+ return ok
+ except Exception as exc:
+ self._log(f"serial: recovery skipped ({exc})")
+ return True
+
+ def detach_session(self) -> None:
+ self._stop_rx_thread()
+ with self._lock:
+ if self._fd is not None and self._kiss_active:
+ self._write_unlocked(KISS_RETURN_FRAME)
+ time.sleep(0.2)
+ self._kiss_active = False
+ if self._fd is not None:
+ self.status = "open"
+
+ def transmit(self, src: str, dst: str, text: str, ax25_ui: bool) -> tuple[bool, str]:
+ if self._fd is None or not self._kiss_active:
+ return False, "serial not ready"
+ if len(text.encode("utf-8")) > MAX_PAYLOAD:
+ return False, "payload too long"
+ try:
+ validate_callsign(src)
+ validate_callsign(dst)
+ except ValueError as exc:
+ return False, str(exc)
+ info = text.encode("utf-8")
+ try:
+ frame = ax25_build_ui(src, dst, info)
+ except ValueError as exc:
+ return False, str(exc)
+ pkt = kiss_data_frame(0, frame)
+ with self._lock:
+ try:
+ tx_pace_before_send()
+ self._write_unlocked(pkt)
+ termios.tcdrain(self._fd)
+ except OSError as exc:
+ self.status = "error-tx"
+ return False, f"tx failed: {exc}"
+ display = format_rx_line(src, dst, info, ax25_ui)
+ return True, display
+
+ def _write_unlocked(self, data: bytes) -> None:
+ if self._fd is None:
+ return
+ os.write(self._fd, data)
+
+ def _drain_unlocked(self, seconds: float) -> bytes:
+ if self._fd is None:
+ return b""
+ end = time.time() + seconds
+ chunks: list[bytes] = []
+ while time.time() < end:
+ try:
+ chunk = os.read(self._fd, 4096)
+ if chunk:
+ chunks.append(chunk)
+ except BlockingIOError:
+ time.sleep(0.02)
+ return b"".join(chunks)
+
+ def _set_mycall_unlocked(self, call: str) -> bool:
+ mod = self._load_recovery_mod()
+ if mod is not None and hasattr(mod, "tf_mycall_frame"):
+ cmd = mod.tf_mycall_frame(call)
+ else:
+ cmd = f"\x1bI {call.upper()}\r".encode("ascii", errors="replace")
+ self._write_unlocked(cmd)
+ time.sleep(0.4)
+ reply = self._drain_unlocked(0.6)
+ return b"?" not in reply[:32]
+
+ def _enter_kiss_unlocked(self) -> bool:
+ entry = self.profile.kiss_entry
+ if entry == "tapr":
+ self._write_unlocked(b"kiss on\r")
+ time.sleep(0.5)
+ self._drain_unlocked(0.3)
+ return True
+ self._write_unlocked(b"\x1b@K")
+ time.sleep(0.5)
+ self._drain_unlocked(0.3)
+ return True
+
+ def _rx_loop(self) -> None:
+ while not self._stop.is_set():
+ fd = self._fd
+ if fd is None:
+ break
+ try:
+ chunk = os.read(fd, 4096)
+ except BlockingIOError:
+ time.sleep(0.05)
+ continue
+ except OSError:
+ self._log("serial: rx I/O error — watch will repair")
+ self.status = "error-io"
+ break
+ if not chunk:
+ time.sleep(0.05)
+ continue
+ for _port, payload in self._decoder.feed(chunk):
+ if not payload:
+ continue
+ parsed = ax25_parse_ui(payload)
+ if parsed is None:
+ if self._on_invalid is not None and len(payload) >= 16:
+ self._on_invalid()
+ continue
+ src, dst, info = parsed
+ line = format_rx_line(src, dst, info, ax25_ui=True)
+ self._on_rx(line)
diff --git a/stacks/daemon/max25_platform.py b/stacks/daemon/max25_platform.py
new file mode 100644
index 0000000..0ca1acb
--- /dev/null
+++ b/stacks/daemon/max25_platform.py
@@ -0,0 +1,83 @@
+"""MAX25 host platform — Linux full stack, FreeBSD server + CRDOP/OSS."""
+from __future__ import annotations
+
+import os
+import sys
+
+
+def system_name() -> str:
+ return sys.platform
+
+
+def is_linux() -> bool:
+ return sys.platform == "linux"
+
+
+def is_freebsd() -> bool:
+ return sys.platform.startswith("freebsd")
+
+
+def is_bsd() -> bool:
+ return sys.platform.startswith(("freebsd", "openbsd", "netbsd", "darwin"))
+
+
+def max25d_supported() -> bool:
+ """Daemon may run (full stack on Linux, server+CRDOP on FreeBSD)."""
+ return is_linux() or is_freebsd()
+
+
+def default_unix_socket() -> str:
+ if is_linux():
+ return "/run/max25/modem.sock"
+ if is_freebsd():
+ return "/var/run/max25/modem.sock"
+ return "/tmp/max25/modem.sock"
+
+
+def default_bans_file() -> str:
+ if is_linux():
+ return "/var/lib/max25/bans.txt"
+ return "/var/db/max25/bans.txt"
+
+
+def supported_device_ids() -> frozenset[str]:
+ if is_linux():
+ return frozenset(
+ {
+ "tnc2c",
+ "pktnc2",
+ "tmodem",
+ "baycom-kiss",
+ "pccom-kiss",
+ # BayCom/based SER12 (max25-bcpr -> device max25e0; in scope, default ON in Lite)
+ "max25e0",
+ "max25e0:bc0",
+ "max25e0:bc1",
+ "soft-crdop",
+ "audio-dummy",
+ }
+ )
+ if is_freebsd():
+ # USB KISS (tmodem/c1224) + CRDOP/OSS + max25-bcpr (SER12 via sysarch I386_SET_IOPERM)
+ return frozenset({"tmodem", "soft-crdop", "audio-dummy", "max25e0", "max25e0:bc0", "max25e0:bc1"})
+ return frozenset({"soft-crdop"})
+
+
+def crdop_audio_backend() -> str:
+ """Host audio API for CRDOP sound-proxy."""
+ env = os.environ.get("MAX25_AUDIO_BACKEND", "").strip().lower()
+ if env in ("alsa", "oss"):
+ return env
+ if is_linux():
+ return "alsa"
+ if is_freebsd():
+ return "oss"
+ return "alsa"
+
+
+def platform_label() -> str:
+ if is_linux():
+ return "Linux/KLinux"
+ if is_freebsd():
+ return "FreeBSD"
+ return sys.platform
diff --git a/stacks/daemon/max25d b/stacks/daemon/max25d
new file mode 100755
index 0000000..d6c075b
--- /dev/null
+++ b/stacks/daemon/max25d
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+# max25d launcher — ps(1) shows this path, not "python3 …/max25d".
+set -euo pipefail
+ROOT="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
+case "${1:-}" in
+ session)
+ shift
+ for candidate in \
+ "${ROOT}/../../scripts/max25d-session.sh" \
+ "${ROOT}/../scripts/max25d-session.sh" \
+ "$(command -v max25d-session 2>/dev/null || true)"
+ do
+ if [[ -n "${candidate}" && -x "${candidate}" ]]; then
+ exec "${candidate}" "$@"
+ fi
+ done
+ echo "max25d: max25d-session helper not found" >&2
+ exit 1
+ ;;
+esac
+exec -a "$0" python3 "${ROOT}/max25d.py" "$@"
diff --git a/stacks/daemon/max25d.py b/stacks/daemon/max25d.py
new file mode 100755
index 0000000..c6c03f3
--- /dev/null
+++ b/stacks/daemon/max25d.py
@@ -0,0 +1,2213 @@
+#!/usr/bin/env python3
+"""
+max25d — MAX25-Stack-Lite daemon (localhost).
+
+Linux/*BSD/macOS: max25-terminal + CRDOP + BayCom/based on the same machine.
+"""
+from __future__ import annotations
+
+import argparse
+import configparser
+import os
+import re
+import select
+import signal
+import socket
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Optional, Set
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+from device_backends import ( # noqa: E402
+ DeviceBackend,
+ DeviceBackendConfig,
+ backend_serial_label,
+ baycom_ctl_device_id,
+ create_backend,
+ parse_device_spec,
+ registry_backend,
+ registry_tested,
+)
+from banlist import BanList, extract_ax25_source # noqa: E402
+from reporting_quality import DataQualityTracker, RxOutcome, classify_rx_line # noqa: E402
+from daemon_log import LOGGER, emit_startup_banner, emit_startup_complete # noqa: E402
+from kiss_bridge import KissBridge # noqa: E402 — tests patch this symbol
+from paths import ( # noqa: E402
+ ctl_path,
+ MAX25_BCPR_KISS_DEFAULT,
+ normalize_max25_bcpr_path,
+ resolve_baycom_ini,
+ resolve_layout,
+)
+from max25_platform import ( # noqa: E402
+ default_bans_file,
+ default_unix_socket,
+ max25d_supported,
+ platform_label,
+ supported_device_ids,
+)
+from privilege_drop import RunAsConfig, drop_privileges_or_exit, parse_run_as # noqa: E402
+from modular_tcp_server import ModularTcpMainService, ModularTcpConfig, load_modular_tcp # noqa: E402
+
+_EXE = Path(__file__).resolve()
+TREE, PREFIX = resolve_layout(_EXE)
+ROOT = TREE # dev checkout root or MAX25_ROOT / install prefix
+
+DEFAULT_TCP_PORT = 7325
+DEFAULT_UNIX = default_unix_socket()
+M25_MAX_LINE_BUF = 65536
+CALLSIGN_RE = re.compile(r"^[A-Z0-9]{1,6}(-(1[0-5]|[0-9]))?$")
+RESERVED_DEVICE_KEYS = frozenset({"default", "enabled"})
+
+
+def _device_is_baycom(dev: DeviceBackendConfig) -> bool:
+ if dev.device_id.startswith("baycom"):
+ return True
+ spec = (dev.device_spec or "").strip()
+ if spec.startswith("baycom:"):
+ return True
+ return dev.backend_type == "baycom-kiss" and dev.hardware == "modems"
+
+
+def _device_is_pccom(dev: DeviceBackendConfig) -> bool:
+ if "pccom" in dev.device_id.lower():
+ return True
+ ini = (dev.baycom_ini or "").lower()
+ return "pccom" in ini
+
+
+
+def _device_is_max25_bcpr(dev: DeviceBackendConfig) -> bool:
+ # Product device id = max25e0 (+ forks max25e0:bcN); backend = max25-bcpr
+ if dev.device_id == "max25e0" or dev.device_id.startswith("max25e0:"):
+ return True
+ spec = (dev.device_spec or "").strip()
+ if spec.startswith("max25-bcpr:") or spec.startswith("bcpr:"):
+ return True
+ return dev.backend_type in ("max25-bcpr-kiss", "bcpr-kiss")
+
+
+def _device_is_tmodem(dev: DeviceBackendConfig) -> bool:
+ return dev.device_id == "tmodem"
+
+
+def _device_allowed_by_features(dev: DeviceBackendConfig, cfg: DaemonConfig) -> bool:
+ if _device_is_baycom(dev) and not cfg.feature_baycom:
+ LOGGER.warn(
+ f"device {dev.device_id}: BayCom disabled — set [features] baycom=yes",
+ area="config",
+ )
+ return False
+ if _device_is_pccom(dev) and not cfg.feature_pccom:
+ LOGGER.warn(
+ f"device {dev.device_id}: PC-COM disabled — set [features] pccom=yes",
+ area="config",
+ )
+ return False
+ if _device_is_max25_bcpr(dev) and not cfg.feature_max25_bcpr:
+ LOGGER.warn(
+ f"device {dev.device_id}: max25-bcpr disabled — set [features] max25_bcpr=yes",
+ area="config",
+ )
+ return False
+ if _device_is_tmodem(dev) and not cfg.feature_tmodem:
+ LOGGER.warn(
+ f"device {dev.device_id}: T-Modem disabled — set [features] tmodem=yes",
+ area="config",
+ )
+ return False
+ return True
+
+
+@dataclass
+class TotConfig:
+ """Software TOT for BayCom/based (max25-bcpr) — host policy, not radio TOT."""
+
+ enabled: bool = True
+ max_key_sec: int = 25
+ min_gap_sec: float = 1.5
+ max_consecutive: int = 3
+ max_bursts: int = 8
+ recover_sec: int = 300
+
+
+@dataclass
+class DaemonConfig:
+ mode: str = "standalone"
+ hardware: str = "tncs"
+ device: str = "tnc2c"
+ default_device: str = ""
+ devices: list[DeviceBackendConfig] = field(default_factory=list)
+ tcp_host: str = "127.0.0.1"
+ tcp_port: int = DEFAULT_TCP_PORT
+ unix_socket: str = DEFAULT_UNIX
+ tcp_password: str = ""
+ callerid: str = "CB-0"
+ callid: str = "QST"
+ ax25_ui: bool = True
+ auto_start: bool = True
+ serial_enabled: bool = True
+ serial_watch: bool = True
+ serial_watch_interval: int = 60
+ serial_repair_cooldown: int = 20
+ serial_watch_startup_grace: int = 45
+ stack_recover_only: bool = True
+ stack_retry_interval: int = 120
+ serial_bootwait_escalate: bool = True
+ serial_bootwait_escalate_after: int = 3
+ serial_bootwait_escalate_cooldown: int = 300
+ bans_file: str = field(default_factory=default_bans_file)
+ config_path: str = ""
+ feature_baycom: bool = True
+ feature_pccom: bool = True
+ feature_max25_bcpr: bool = True
+ feature_tmodem: bool = False
+ hybbx_release_attach: bool = False
+ run_user: str = ""
+ run_group: str = ""
+ run_uid: Optional[int] = None
+ run_gid: Optional[int] = None
+ report_error_transmissions: bool = True
+ report_voice_transmissions: bool = True
+ report_data_passes: int = 3
+ report_data_quality_min: int = 50
+ report_data_pass_seconds: int = 20
+ # Legacy single-device [serial] overrides (used when [devices] absent).
+ serial_device: str = ""
+ serial_baud: int = 0
+ serial_line: str = ""
+ serial_dtr_rts: str = ""
+ serial_kiss_entry: str = ""
+ modular_tcp: ModularTcpConfig = field(default_factory=ModularTcpConfig)
+ tot: TotConfig = field(default_factory=TotConfig)
+
+
+@dataclass
+class DeviceRuntime:
+ cfg: DeviceBackendConfig
+ backend: Optional[DeviceBackend] = None
+ stack_proc: Optional[subprocess.Popen] = None
+ stack_status: str = "stopped"
+ link_status: str = "n/a"
+ last_watch: float = 0.0
+ last_repair: float = 0.0
+ last_stack_retry: float = 0.0
+ prep_done: bool = False
+ inline_repair_failures: int = 0
+ last_bootwait_escalate: float = 0.0
+ quality: DataQualityTracker = field(default_factory=DataQualityTracker)
+ tot_paused: bool = False
+ tot_paused_until: float = 0.0
+ tot_trip_reason: str = ""
+
+
+@dataclass
+class DaemonState:
+ cfg: DaemonConfig
+ connected: bool = False
+ monitor_only: bool = False
+ selected_device: str = ""
+ devices: dict[str, DeviceRuntime] = field(default_factory=dict)
+ clients: Set[socket.socket] = field(default_factory=set)
+ bans: BanList = field(default_factory=BanList)
+ lock: threading.Lock = field(default_factory=threading.Lock)
+ started_at: float = 0.0
+
+
+def log(msg: str) -> None:
+ """Legacy log hook — structured stderr (human + machine readable)."""
+ LOGGER.emit_unstructured(msg)
+
+
+def valid_callsign(value: str) -> bool:
+ return bool(value and CALLSIGN_RE.match(value.upper()))
+
+
+def _truthy(value: str) -> bool:
+ return value.lower() in ("1", "yes", "true", "on")
+
+
+def _ini_int(
+ cp: configparser.ConfigParser,
+ section: str,
+ key: str,
+ default: int,
+ *,
+ min_value: int = 1,
+ max_value: int = 86400,
+) -> int:
+ if not cp.has_option(section, key):
+ return default
+ raw = cp.get(section, key)
+ try:
+ value = int(str(raw).strip())
+ except (TypeError, ValueError):
+ LOGGER.warn(
+ f"[{section}] {key}={raw!r} invalid — using {default}",
+ area="config",
+ )
+ return default
+ if value < min_value:
+ LOGGER.warn(
+ f"[{section}] {key}={value} below {min_value} — using {min_value}",
+ area="config",
+ )
+ return min_value
+ if value > max_value:
+ LOGGER.warn(
+ f"[{section}] {key}={value} above {max_value} — using {max_value}",
+ area="config",
+ )
+ return max_value
+ return value
+
+
+def _safe_port(raw: str, default: int) -> int:
+ try:
+ port = int(str(raw).strip())
+ except (TypeError, ValueError):
+ return default
+ if port < 1 or port > 65535:
+ return default
+ return port
+
+
+def _serial_overrides_from_section(cp: configparser.ConfigParser, section: str) -> dict[str, str]:
+ if not cp.has_section(section):
+ return {}
+ out: dict[str, str] = {}
+ for key in ("device", "baud", "line", "dtr_rts", "kiss_entry"):
+ if cp.has_option(section, key):
+ out[key] = cp.get(section, key)
+ return out
+
+
+def _apply_serial_overrides(dev: DeviceBackendConfig, overrides: dict[str, str]) -> None:
+ if overrides.get("device"):
+ dev.serial_device = overrides["device"]
+ if overrides.get("baud"):
+ dev.serial_baud = int(overrides["baud"])
+ if overrides.get("line"):
+ dev.serial_line = overrides["line"]
+ if overrides.get("dtr_rts"):
+ dev.serial_dtr_rts = overrides["dtr_rts"]
+ if overrides.get("kiss_entry"):
+ dev.serial_kiss_entry = overrides["kiss_entry"]
+
+
+def parse_devices(cp: configparser.ConfigParser, cfg: DaemonConfig) -> list[DeviceBackendConfig]:
+ """Build device list from [devices] or legacy [daemon] device= + [serial]."""
+ defaults = {"hardware": cfg.hardware}
+ if cp.has_section("devices"):
+ default_id = cp.get("devices", "default", fallback="").strip()
+ enabled_raw = cp.get("devices", "enabled", fallback="").strip()
+ enabled_set: Optional[set[str]] = None
+ if enabled_raw:
+ enabled_set = {x.strip() for x in enabled_raw.split(",") if x.strip()}
+
+ entries: list[DeviceBackendConfig] = []
+ for key in cp.options("devices"):
+ if key.lower() in RESERVED_DEVICE_KEYS:
+ continue
+ device_id = key.strip()
+ spec = cp.get("devices", key, fallback="").strip()
+ dev = parse_device_spec(device_id, spec, cp, defaults)
+ if enabled_set is not None:
+ dev.enabled = device_id in enabled_set
+ entries.append(dev)
+
+ if not entries:
+ LOGGER.warn(
+ "[devices] section empty — falling back to legacy single device",
+ area="config",
+ )
+ else:
+ if default_id:
+ cfg.default_device = default_id
+ if enabled_set is not None and default_id not in enabled_set:
+ enabled_entries = [d for d in entries if d.enabled]
+ if enabled_entries:
+ fallback = enabled_entries[0].device_id
+ LOGGER.warn(
+ f"[devices] default={default_id} not enabled — using {fallback}",
+ area="config",
+ )
+ cfg.default_device = fallback
+ elif cfg.device:
+ cfg.default_device = cfg.device
+ else:
+ cfg.default_device = entries[0].device_id
+ return entries
+
+ # Legacy single device
+ device_id = cfg.device or "tnc2c"
+ dev = parse_device_spec(device_id, "", cp, defaults)
+ legacy = _serial_overrides_from_section(cp, "serial")
+ if cfg.serial_device:
+ legacy.setdefault("device", cfg.serial_device)
+ if cfg.serial_baud:
+ legacy.setdefault("baud", str(cfg.serial_baud))
+ if cfg.serial_line:
+ legacy.setdefault("line", cfg.serial_line)
+ if cfg.serial_dtr_rts:
+ legacy.setdefault("dtr_rts", cfg.serial_dtr_rts)
+ if cfg.serial_kiss_entry:
+ legacy.setdefault("kiss_entry", cfg.serial_kiss_entry)
+ _apply_serial_overrides(dev, legacy)
+ cfg.default_device = device_id
+ return [dev]
+
+
+def _ini_float(
+ cp: configparser.ConfigParser,
+ section: str,
+ key: str,
+ default: float,
+ *,
+ min_value: float = 0.0,
+ max_value: float = 3600.0,
+) -> float:
+ if not cp.has_option(section, key):
+ return default
+ raw = cp.get(section, key).strip()
+ try:
+ value = float(raw)
+ except ValueError:
+ LOGGER.warn(f"invalid float {section}.{key}={raw!r} — using {default}", area="config")
+ return default
+ if value < min_value:
+ return min_value
+ if value > max_value:
+ return max_value
+ return value
+
+
+def load_config(path: Optional[Path]) -> DaemonConfig:
+ cfg = DaemonConfig()
+ if path is None:
+ for candidate in (
+ Path(os.environ.get("MAX25D_INI", "")),
+ Path("/etc/max25/max25d.ini"),
+ ROOT / "share/max25/max25d.ini.example",
+ ):
+ if candidate and candidate.is_file():
+ path = candidate
+ break
+ if path is None or not path.is_file():
+ LOGGER.warn(f"using built-in defaults (no ini at {path})", area="config")
+ cfg.devices = [parse_device_spec(cfg.device, "", configparser.ConfigParser(), {"hardware": cfg.hardware})]
+ cfg.default_device = cfg.device
+ cfg.config_path = ""
+ return cfg
+
+ cfg.config_path = str(path)
+
+ cp = configparser.ConfigParser(strict=False)
+ cp.read(path)
+ if cp.has_section("daemon"):
+ cfg.mode = cp.get("daemon", "mode", fallback=cfg.mode)
+ cfg.hardware = cp.get("daemon", "hardware", fallback=cfg.hardware)
+ cfg.device = cp.get("daemon", "device", fallback=cfg.device)
+ run_as = parse_run_as(cp)
+ cfg.run_user = run_as.user
+ cfg.run_group = run_as.group
+ cfg.run_uid = run_as.uid
+ cfg.run_gid = run_as.gid
+ if cp.has_section("network"):
+ cfg.tcp_host = cp.get("network", "tcp_host", fallback=cfg.tcp_host)
+ cfg.tcp_port = _ini_int(cp, "network", "tcp_port", cfg.tcp_port, min_value=1, max_value=65535)
+ cfg.unix_socket = cp.get("network", "unix_socket", fallback=cfg.unix_socket)
+ cfg.tcp_password = cp.get("network", "tcp_password", fallback=cfg.tcp_password)
+ if cp.has_section("modem"):
+ cfg.callerid = cp.get("modem", "callerid", fallback=cfg.callerid).upper()
+ cfg.callid = cp.get("modem", "callid", fallback=cfg.callid).upper()
+ cfg.ax25_ui = _truthy(cp.get("modem", "ax25_ui", fallback="yes"))
+ cfg.bans_file = cp.get("modem", "bans_file", fallback=cfg.bans_file)
+ if cp.has_section("stack"):
+ cfg.auto_start = _truthy(cp.get("stack", "auto_start", fallback="yes"))
+ if cp.has_option("stack", "serial_watch"):
+ cfg.serial_watch = _truthy(cp.get("stack", "serial_watch"))
+ cfg.serial_watch_interval = _ini_int(
+ cp, "stack", "serial_watch_interval", cfg.serial_watch_interval
+ )
+ cfg.serial_repair_cooldown = _ini_int(
+ cp, "stack", "serial_repair_cooldown", cfg.serial_repair_cooldown
+ )
+ cfg.serial_watch_startup_grace = _ini_int(
+ cp, "stack", "serial_watch_startup_grace", cfg.serial_watch_startup_grace
+ )
+ if cp.has_option("stack", "stack_recover_only"):
+ cfg.stack_recover_only = _truthy(cp.get("stack", "stack_recover_only"))
+ cfg.stack_retry_interval = _ini_int(
+ cp, "stack", "stack_retry_interval", cfg.stack_retry_interval
+ )
+ if cp.has_option("stack", "serial_bootwait_escalate"):
+ cfg.serial_bootwait_escalate = _truthy(cp.get("stack", "serial_bootwait_escalate"))
+ cfg.serial_bootwait_escalate_after = _ini_int(
+ cp, "stack", "serial_bootwait_escalate_after", cfg.serial_bootwait_escalate_after
+ )
+ cfg.serial_bootwait_escalate_cooldown = _ini_int(
+ cp,
+ "stack",
+ "serial_bootwait_escalate_cooldown",
+ cfg.serial_bootwait_escalate_cooldown,
+ )
+ if cp.has_section("serial"):
+ cfg.serial_device = cp.get("serial", "device", fallback="")
+ cfg.serial_baud = cp.getint("serial", "baud", fallback=0)
+ cfg.serial_line = cp.get("serial", "line", fallback="")
+ cfg.serial_dtr_rts = cp.get("serial", "dtr_rts", fallback="")
+ cfg.serial_kiss_entry = cp.get("serial", "kiss_entry", fallback="")
+
+ if cp.has_section("features"):
+ cfg.feature_baycom = _truthy(cp.get("features", "baycom", fallback="yes"))
+ cfg.feature_pccom = _truthy(cp.get("features", "pccom", fallback="yes"))
+ # Product key max25_bcpr; legacy bcpr= accepted
+ cfg.feature_max25_bcpr = _truthy(
+ cp.get("features", "max25_bcpr", fallback=cp.get("features", "bcpr", fallback="yes"))
+ )
+ cfg.feature_tmodem = _truthy(cp.get("features", "tmodem", fallback="no"))
+ if cp.has_section("hybbx"):
+ cfg.hybbx_release_attach = _truthy(
+ cp.get("hybbx", "release_attach", fallback="no")
+ )
+ if cp.has_option("hybbx", "attach"):
+ cfg.hybbx_release_attach = _truthy(cp.get("hybbx", "attach"))
+ cfg.report_error_transmissions = _truthy(
+ cp.get("reporting", "error_transmissions", fallback="yes")
+ )
+ cfg.report_voice_transmissions = _truthy(
+ cp.get("reporting", "voice_transmissions", fallback="yes")
+ )
+ cfg.report_data_passes = _ini_int(
+ cp, "reporting", "data_passes", 3, min_value=1, max_value=32
+ )
+ cfg.report_data_quality_min = _ini_int(
+ cp, "reporting", "data_quality_min", 50, min_value=1, max_value=100
+ )
+ cfg.report_data_pass_seconds = _ini_int(
+ cp, "reporting", "data_pass_seconds", 20, min_value=1, max_value=300
+ )
+ if cp.has_section("tot"):
+ cfg.tot.enabled = _truthy(cp.get("tot", "enabled", fallback="yes"))
+ cfg.tot.max_key_sec = _ini_int(
+ cp, "tot", "max_key_sec", cfg.tot.max_key_sec, min_value=1, max_value=600
+ )
+ if cp.has_option("tot", "max_key_ms"):
+ cfg.tot.max_key_sec = max(
+ 1, _ini_int(cp, "tot", "max_key_ms", cfg.tot.max_key_sec * 1000) // 1000
+ )
+ cfg.tot.min_gap_sec = _ini_float(
+ cp, "tot", "min_gap_sec", cfg.tot.min_gap_sec, min_value=0.0, max_value=60.0
+ )
+ if cp.has_option("tot", "min_gap_ms"):
+ cfg.tot.min_gap_sec = _ini_int(cp, "tot", "min_gap_ms", 1500) / 1000.0
+ cfg.tot.max_consecutive = _ini_int(
+ cp, "tot", "max_consecutive", cfg.tot.max_consecutive, min_value=1, max_value=32
+ )
+ cfg.tot.max_bursts = _ini_int(
+ cp, "tot", "max_bursts", cfg.tot.max_bursts, min_value=1, max_value=64
+ )
+ cfg.tot.recover_sec = _ini_int(
+ cp, "tot", "recover_sec", cfg.tot.recover_sec, min_value=0, max_value=86400
+ )
+
+ cfg.devices = parse_devices(cp, cfg)
+ cfg.devices = [d for d in cfg.devices if _device_allowed_by_features(d, cfg)]
+ allowed = supported_device_ids()
+ if allowed:
+ filtered: list[DeviceBackendConfig] = []
+ for dev in cfg.devices:
+ if dev.device_id in allowed:
+ filtered.append(dev)
+ else:
+ LOGGER.warn(
+ f"device {dev.device_id} not supported on {platform_label()} — skipped",
+ area="config",
+ )
+ cfg.devices = filtered
+ if not cfg.devices:
+ LOGGER.warn("no devices remain after config/platform filter", area="config")
+ cfg.modular_tcp = load_modular_tcp(cp)
+ if not cfg.default_device and cfg.devices:
+ cfg.default_device = cfg.devices[0].device_id
+ cfg.device = cfg.default_device
+ enforce_lite_localhost(cfg)
+ return cfg
+
+
+def _is_loopback_tcp_host(host: str) -> bool:
+ """Lite allows TCP only on this machine."""
+ h = (host or "").strip().lower()
+ if not h:
+ return False
+ if h in ("127.0.0.1", "::1", "localhost"):
+ return True
+ # Accept IPv4-mapped IPv6 loopback forms if ever written in INI.
+ if h in ("::ffff:127.0.0.1", "[::1]"):
+ return True
+ return False
+
+
+def enforce_lite_localhost(cfg: DaemonConfig) -> None:
+ """Refuse LAN / wildcard TCP binds — one-PC product rule."""
+ if _is_loopback_tcp_host(cfg.tcp_host):
+ return
+ msg = (
+ f"max25d: Lite only allows TCP on this computer "
+ f"(127.0.0.1, ::1, or localhost) — got tcp_host={cfg.tcp_host!r}. "
+ "Fix your INI (use max25-lite-setup) or connect via the Unix socket."
+ )
+ log(msg)
+ raise SystemExit(1)
+
+
+def init_device_runtimes(state: DaemonState) -> None:
+ state.devices.clear()
+ for dev_cfg in state.cfg.devices:
+ if not dev_cfg.enabled:
+ continue
+ state.devices[dev_cfg.device_id] = DeviceRuntime(
+ cfg=dev_cfg,
+ quality=DataQualityTracker(
+ passes_required=state.cfg.report_data_passes,
+ min_good_percent=state.cfg.report_data_quality_min,
+ pass_window_sec=state.cfg.report_data_pass_seconds,
+ ),
+ )
+ if not registry_tested(dev_cfg.device_id):
+ LOGGER.warn(
+ f"backend={dev_cfg.backend_type or 'auto'} — not hardware-validated in CI",
+ area="devices",
+ device=dev_cfg.device_id,
+ )
+ if state.cfg.default_device in state.devices:
+ state.selected_device = state.cfg.default_device
+ elif state.devices:
+ state.selected_device = next(iter(state.devices))
+ else:
+ state.selected_device = state.cfg.default_device
+ LOGGER.warn("no enabled device runtimes — SEND/CONNECT unavailable", area="devices")
+
+
+def enabled_device_ids(state: DaemonState) -> list[str]:
+ return list(state.devices.keys())
+
+
+def device_hardware(state: DaemonState, dev_id: str) -> str:
+ rt = state.devices.get(dev_id)
+ if rt is None:
+ return state.cfg.hardware
+ return rt.cfg.hardware or state.cfg.hardware
+
+
+def device_backend_kind(state: DaemonState, dev_id: str) -> str:
+ rt = state.devices.get(dev_id)
+ if rt is None:
+ return "kiss-serial"
+ return rt.cfg.backend_type or "kiss-serial"
+
+
+def on_backend_rx(state: DaemonState, dev_id: str, line: str) -> None:
+ rt = state.devices.get(dev_id)
+ if rt is not None:
+ _sync_quality_tracker(state, rt)
+ rt.quality.record(classify_rx_line(line, state.cfg.callid))
+ src = extract_ax25_source(line)
+ if src and state.bans.is_banned(src):
+ return
+ log(f"rx {dev_id}: {line}")
+ broadcast(state, f"RX device={dev_id} {line}")
+
+
+def on_backend_invalid_frame(state: DaemonState, dev_id: str) -> None:
+ rt = state.devices.get(dev_id)
+ if rt is None:
+ return
+ _sync_quality_tracker(state, rt)
+ rt.quality.record_bad()
+ if state.cfg.report_error_transmissions:
+ broadcast(state, f"EVENT device={dev_id} error=invalid")
+
+
+VOICE_BACKEND_KINDS = frozenset({"crdop-tcp", "audio-dummy"})
+
+
+def device_has_voice_path(rt: DeviceRuntime) -> bool:
+ kind = rt.cfg.backend_type or registry_backend(rt.cfg.device_id)
+ if kind in VOICE_BACKEND_KINDS:
+ return True
+ hw = (rt.cfg.hardware or "").lower()
+ return hw in ("acoustic-bench", "soft-modems")
+
+
+def device_link_status(rt: DeviceRuntime) -> str:
+ if rt.backend is not None:
+ return backend_serial_label(rt.backend)
+ return rt.link_status
+
+
+def link_status_is_healthy(status: str) -> bool:
+ if status in ("ready", "open", "n/a"):
+ return True
+ if status.startswith("error") or status in ("closed", "stopped"):
+ return False
+ return True
+
+
+def _sync_quality_tracker(state: DaemonState, rt: DeviceRuntime) -> None:
+ required = state.cfg.report_data_passes
+ minimum = state.cfg.report_data_quality_min
+ window = state.cfg.report_data_pass_seconds
+ if (
+ rt.quality.passes_required == required
+ and rt.quality.min_good_percent == minimum
+ and rt.quality.pass_window_sec == window
+ and rt.quality.passes.maxlen == required
+ ):
+ return
+ kept = list(rt.quality.passes)[-required:]
+ voice = rt.quality.voice_activity
+ current = rt.quality.current
+ rt.quality = DataQualityTracker(
+ passes_required=required,
+ min_good_percent=minimum,
+ pass_window_sec=window,
+ voice_activity=voice,
+ )
+ for item in kept:
+ rt.quality.passes.append(item)
+ if current.started_at != 0.0:
+ rt.quality.current = current
+
+
+def device_reporting_error(state: DaemonState, rt: DeviceRuntime) -> str:
+ if not state.cfg.report_error_transmissions:
+ return "invalid"
+ if not link_status_is_healthy(device_link_status(rt)):
+ return "invalid"
+ _sync_quality_tracker(state, rt)
+ if rt.quality.data_error_valid(reporting_enabled=True):
+ return "valid"
+ return "invalid"
+
+
+def aggregate_reporting_error(state: DaemonState) -> str:
+ if not state.devices:
+ return "invalid" if not state.cfg.report_error_transmissions else "valid"
+ for rt in state.devices.values():
+ if device_reporting_error(state, rt) == "invalid":
+ return "invalid"
+ return "valid"
+
+
+def aggregate_reporting_voice(state: DaemonState) -> str:
+ if not state.cfg.report_voice_transmissions:
+ return "invalid"
+ voice_rts = [rt for rt in state.devices.values() if device_has_voice_path(rt)]
+ if not voice_rts:
+ return "valid"
+ for rt in voice_rts:
+ if not link_status_is_healthy(device_link_status(rt)):
+ return "invalid"
+ return "valid"
+
+
+def device_reporting_voice(state: DaemonState, rt: DeviceRuntime) -> str:
+ if not device_has_voice_path(rt):
+ return "n/a"
+ _sync_quality_tracker(state, rt)
+ healthy = link_status_is_healthy(device_link_status(rt))
+ if rt.quality.voice_signal_valid(
+ reporting_enabled=state.cfg.report_voice_transmissions,
+ link_healthy=healthy,
+ ):
+ return "valid"
+ return "invalid"
+
+
+BACKEND_POLL_OPEN_STATUSES = frozenset(
+ {
+ "closed",
+ "error-open",
+ "error-connect",
+ "error-no-device",
+ "error-no-path",
+ }
+)
+
+SERIAL_REPAIR_STATUSES = frozenset(
+ {
+ "error-host",
+ "error-kiss",
+ "error-tx",
+ "error-io",
+ "error-config",
+ }
+)
+
+BACKEND_RETRY_STATUSES = BACKEND_POLL_OPEN_STATUSES | SERIAL_REPAIR_STATUSES
+
+HYBBX_ATTACH_MODES = frozenset({"hybbx-host", "hybbx-main", "hybbx-cohost"})
+
+
+def hybbx_release_attach(state: DaemonState) -> bool:
+ """HyBBX opens serial/KISS after max25d prep — max25d must not hold the fd."""
+ mode = (state.cfg.mode or "").strip().lower()
+ if mode in HYBBX_ATTACH_MODES:
+ return True
+ return bool(getattr(state.cfg, "hybbx_release_attach", False))
+
+
+def uses_inline_tnc_prep(state: DaemonState, dev_id: str) -> bool:
+ """kiss-serial owned by max25d — no boot-wait subprocess (avoids port conflict)."""
+ if not state.cfg.stack_recover_only:
+ return False
+ return device_backend_kind(state, dev_id) == "kiss-serial"
+
+
+def hybbx_host_hybbx_owns_serial(state: DaemonState, dev_id: str) -> bool:
+ """In HyBBX attach mode HyBBX opens serial/KISS after max25d prep."""
+ if not hybbx_release_attach(state):
+ return False
+ kind = device_backend_kind(state, dev_id)
+ # kiss-raw + max25-bcpr: HyBBX owns the KISS attach; max25d must not hold the PTY.
+ if kind in ("kiss-raw-serial", "max25-bcpr-kiss", "bcpr-kiss"):
+ return True
+ if kind != "kiss-serial":
+ return False
+ rt = state.devices.get(dev_id)
+ return rt is not None and rt.prep_done and rt.backend is None
+
+
+def prep_inline_serial_device(state: DaemonState, dev_id: str) -> None:
+ """Open serial and run initial recovery while holding DTR (no subprocess)."""
+ rt = state.devices[dev_id]
+ rt.stack_status = "ready"
+ LOGGER.info("inline prep — max25d owns serial recovery", area="stack", device=dev_id)
+ if not backend_enabled(state, dev_id):
+ return
+ if not open_backend(state, dev_id):
+ LOGGER.error(f"serial prep open failed status={rt.link_status}", area="serial", device=dev_id)
+ return
+ backend = rt.backend
+ stabilize = getattr(backend, "stabilize_session", None)
+ if stabilize is None:
+ return
+ ok = stabilize(state.cfg.callerid, force=False)
+ rt.prep_done = True
+ rt.link_status = backend.status
+ if ok:
+ LOGGER.ok("serial prep complete — terminal + KISS ready", area="serial", device=dev_id)
+ if hybbx_release_attach(state):
+ close_backend(state, dev_id)
+ rt.link_status = "ready"
+ LOGGER.info(
+ "HyBBX attach: serial released for HyBBX KISS attach",
+ area="serial",
+ device=dev_id,
+ )
+ elif (
+ backend.status == "error-host"
+ and state.cfg.serial_bootwait_escalate
+ and rt.stack_proc is None
+ ):
+ LOGGER.warn(
+ "inline ladder exhausted (error-host) — escalating to boot-wait + power-cycle hint",
+ area="serial",
+ device=dev_id,
+ )
+ rt.last_bootwait_escalate = time.time()
+ escalate_to_bootwait_stack(state, dev_id)
+ else:
+ LOGGER.warn(
+ f"prep deferred status={backend.status} — serial watch will retry",
+ area="serial",
+ device=dev_id,
+ )
+
+
+def escalate_to_bootwait_stack(state: DaemonState, dev_id: str) -> None:
+ """Release inline serial and run boot-wait subprocess (DTR + power-cycle rescue)."""
+ rt = state.devices[dev_id]
+ close_backend(state, dev_id)
+ rt.prep_done = False
+ ctl = ctl_path(ROOT, PREFIX, _EXE)
+ if not ctl.is_file():
+ rt.stack_status = "error-no-ctl"
+ log(f"serial watch: boot-wait escalate failed — no ctl ({dev_id})")
+ return
+ hw = device_hardware(state, dev_id)
+ args = [
+ str(ctl),
+ "start",
+ "--mode",
+ state.cfg.mode,
+ "--hardware",
+ hw,
+ "--device",
+ dev_id,
+ ]
+ env = os.environ.copy()
+ env["MAX25_MODE"] = state.cfg.mode
+ env.pop("MAX25_TNC_PREP", None)
+ workdir = str(ROOT if (ROOT / "plugins").is_dir() else (PREFIX or ROOT))
+ try:
+ proc = subprocess.Popen(
+ args,
+ cwd=workdir,
+ env=env,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=True,
+ )
+ except OSError as exc:
+ log(f"serial watch: boot-wait escalate failed ({dev_id}): {exc}")
+ rt.stack_status = "error"
+ return
+ rt.stack_proc = proc
+ rt.stack_status = "running"
+ log(
+ f"serial watch: escalating to boot-wait ({dev_id}) pid={proc.pid} "
+ "— power OFF TNC 10s then ON while script runs (DTR held high)"
+ )
+ broadcast(state, f"EVENT device={dev_id} serial=boot-wait-escalate")
+
+
+def backend_needs_open(backend: Optional[DeviceBackend]) -> bool:
+ if backend is None:
+ return True
+ return backend.status in BACKEND_POLL_OPEN_STATUSES
+
+
+def open_backend(state: DaemonState, dev_id: str) -> bool:
+ rt = state.devices.get(dev_id)
+ if rt is None or not backend_enabled(state, dev_id):
+ if rt is not None:
+ rt.link_status = "n/a"
+ return False
+ if hybbx_host_hybbx_owns_serial(state, dev_id):
+ rt.link_status = "ready"
+ rt.prep_done = True
+ rt.stack_status = "ready"
+ return True
+ if rt.backend is not None and rt.backend.status not in BACKEND_RETRY_STATUSES:
+ return rt.backend.status in ("open", "ready")
+ if (
+ rt.backend is not None
+ and rt.backend.status in SERIAL_REPAIR_STATUSES
+ and rt.prep_done
+ ):
+ return rt.backend.status in ("open", "ready", "error-host", "error-kiss")
+ if rt.backend is not None and rt.backend.status != "closed":
+ rt.backend.close()
+ rt.backend = None
+ backend = create_backend(
+ rt.cfg,
+ str(ROOT),
+ lambda line, d=dev_id: on_backend_rx(state, d, line),
+ log,
+ prefix=str(PREFIX) if PREFIX else None,
+ on_invalid=lambda d=dev_id: on_backend_invalid_frame(state, d),
+ )
+ if not backend.open():
+ rt.backend = backend
+ rt.link_status = backend.status
+ return False
+ rt.backend = backend
+ rt.link_status = backend.status
+ return True
+
+
+def close_backend(state: DaemonState, dev_id: str) -> None:
+ rt = state.devices.get(dev_id)
+ if rt is None or rt.backend is None:
+ return
+ rt.backend.close()
+ rt.link_status = rt.backend.status
+ rt.backend = None
+
+
+def attach_backend_session(state: DaemonState, dev_id: str) -> bool:
+ if not backend_enabled(state, dev_id):
+ return True
+ if hybbx_host_hybbx_owns_serial(state, dev_id):
+ return True
+ rt = state.devices[dev_id]
+ if backend_needs_open(rt.backend):
+ if not open_backend(state, dev_id):
+ return False
+ assert rt.backend is not None
+ ok = rt.backend.attach_session(state.cfg.callerid)
+ rt.link_status = rt.backend.status
+ return ok
+
+
+def detach_backend_session(state: DaemonState, dev_id: str) -> None:
+ rt = state.devices.get(dev_id)
+ if rt is None or rt.backend is None:
+ return
+ rt.backend.detach_session()
+ rt.link_status = rt.backend.status
+
+
+def attach_all_sessions(state: DaemonState) -> bool:
+ ok = True
+ for dev_id in enabled_device_ids(state):
+ if backend_enabled(state, dev_id):
+ if not attach_backend_session(state, dev_id):
+ ok = False
+ return ok
+
+
+def detach_all_sessions(state: DaemonState) -> None:
+ for dev_id in enabled_device_ids(state):
+ detach_backend_session(state, dev_id)
+
+
+def backend_enabled(state: DaemonState, dev_id: str) -> bool:
+ if not state.cfg.serial_enabled:
+ return False
+ rt = state.devices.get(dev_id)
+ if rt is None:
+ return False
+ if rt.tot_paused:
+ return False
+ kind = rt.cfg.backend_type
+ return kind in ("kiss-serial", "baycom-kiss", "max25-bcpr-kiss", "bcpr-kiss", "kiss-raw-serial", "crdop-tcp")
+
+
+def aggregate_stack_status(state: DaemonState) -> str:
+ if not state.devices:
+ return "stopped"
+ statuses = {rt.stack_status for rt in state.devices.values()}
+ if "running" in statuses:
+ return "running"
+ if any(s.startswith("error") for s in statuses):
+ return "error"
+ if statuses == {"ready"} or statuses == {"stopped"}:
+ return next(iter(statuses))
+ if "ready" in statuses:
+ return "ready"
+ return "running" if "running" in statuses else "stopped"
+
+
+def aggregate_link_status(state: DaemonState) -> str:
+ if not state.devices:
+ return "n/a"
+ if len(state.devices) == 1:
+ rt = next(iter(state.devices.values()))
+ return backend_serial_label(rt.backend) if rt.backend is not None else rt.link_status
+ parts: list[str] = []
+ for dev_id in sorted(state.devices):
+ rt = state.devices[dev_id]
+ st = backend_serial_label(rt.backend) if rt.backend is not None else rt.link_status
+ parts.append(f"{dev_id}={st}")
+ return ",".join(parts)
+
+
+def status_line(state: DaemonState) -> str:
+ c = state.cfg
+ dev_list = ",".join(enabled_device_ids(state))
+ selected = state.selected_device or c.default_device or c.device
+ return (
+ f"STATUS hardware={c.hardware} device={selected} devices={dev_list} "
+ f"mode={c.mode} callerid={c.callerid} callid={c.callid} "
+ f"ax25_ui={'on' if c.ax25_ui else 'off'} "
+ f"connected={'yes' if state.connected else 'no'} "
+ f"stack={aggregate_stack_status(state)} serial={aggregate_link_status(state)} "
+ f"error={aggregate_reporting_error(state)} "
+ f"voice={aggregate_reporting_voice(state)}"
+ )
+
+
+def broadcast(state: DaemonState, line: str, skip: Optional[socket.socket] = None) -> None:
+ payload = (line + "\n").encode("utf-8")
+ dead: list[socket.socket] = []
+ with state.lock:
+ for sock in state.clients:
+ if sock is skip:
+ continue
+ try:
+ sock.sendall(payload)
+ except OSError:
+ dead.append(sock)
+ for sock in dead:
+ state.clients.discard(sock)
+
+
+def send_line(sock: socket.socket, line: str) -> None:
+ sock.sendall((line + "\n").encode("utf-8"))
+
+
+def unix_path_is_live(path: str, *, timeout: float = 0.3) -> bool:
+ """True if path exists and accepts a Unix connect (live max25d listener)."""
+ if not path or not os.path.exists(path):
+ return False
+ probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ try:
+ probe.settimeout(timeout)
+ probe.connect(path)
+ return True
+ except OSError:
+ return False
+ finally:
+ try:
+ probe.close()
+ except OSError:
+ pass
+
+
+def unix_path_id(path: str) -> tuple[int, int] | None:
+ """Filesystem identity of a Unix socket path (dev, ino), or None."""
+ try:
+ st = os.stat(path)
+ except OSError:
+ return None
+ return (st.st_dev, st.st_ino)
+
+
+def unlink_unix_if_ours(path: str, bind_id: tuple[int, int] | None) -> None:
+ """Unlink path only when it still names the inode we bound.
+
+ AF_UNIX: fstat(listen_fd) is sockfs — must not compare to path st_ino.
+ A second max25d that unlinks+rebinds must not lose its path when the
+ first instance exits (orphaned listen FD + ENOENT for clients).
+ """
+ if not path or bind_id is None:
+ return
+ cur = unix_path_id(path)
+ if cur is None or cur != bind_id:
+ return
+ try:
+ os.unlink(path)
+ except FileNotFoundError:
+ pass
+ except OSError:
+ pass
+
+
+def resolve_max25_bcpr_ini(explicit: str = "") -> Path | None:
+ """Resolve max25-bcpr.ini for userspace SER12 (max25e0)."""
+ from pathlib import Path as _P
+ if explicit:
+ p = _P(explicit)
+ return p if p.is_file() else None
+ for cand in (
+ ROOT / "local" / "max25-bcpr.ini",
+ _P("/etc/max25/max25-bcpr.ini"),
+ _P("/etc/max25/bcpr.ini"),
+ ROOT / "local" / "bcpr.ini",
+ ROOT / "stacks" / "max25-bcpr" / "share" / "max25-bcpr.ini.example",
+ ROOT / "share" / "max25-bcpr" / "max25-bcpr.ini.example",
+ ):
+ if cand.is_file():
+ return cand
+ return None
+
+
+def resolve_max25_bcpr_ctl() -> Path | None:
+ from pathlib import Path as _P
+ for cand in (
+ ROOT / "stacks" / "max25-bcpr" / "tools" / "max25-bcpr-ctl",
+ _P("/usr/local/sbin/max25-bcpr-ctl"),
+ _P("/usr/sbin/max25-bcpr-ctl"),
+ ):
+ if cand.is_file():
+ return cand
+ return None
+
+
+def bcpr_bc_index(cfg: DeviceBackendConfig) -> int:
+ tag = (cfg.max25_bcpr_device or cfg.bcpr_device or "").strip()
+ if not tag and cfg.device_id.startswith("max25e0:"):
+ tag = cfg.device_id.split(":", 1)[1]
+ if not tag:
+ tag = "bc0"
+ if tag.startswith("bc") and tag[2:].isdigit():
+ return int(tag[2:])
+ return 0
+
+
+def read_bcpr_state_dir(ini_path: Path) -> str:
+ cp = configparser.ConfigParser()
+ cp.read(ini_path)
+ for sect in ("max25-bcpr", "bcpr"):
+ if cp.has_section(sect) and cp.has_option(sect, "state_dir"):
+ return cp.get(sect, "state_dir").strip() or "/tmp/max25-bcpr"
+ return "/tmp/max25-bcpr"
+
+
+def sync_tot_to_bcpr_ini(ini_path: Path, tot: TotConfig) -> None:
+ """Push max25d [tot] policy into max25-bcpr.ini before bcprd start."""
+ if not ini_path.is_file():
+ return
+ cp = configparser.ConfigParser()
+ cp.read(ini_path)
+ sect = "max25-bcpr"
+ if not cp.has_section(sect):
+ if cp.has_section("bcpr"):
+ sect = "bcpr"
+ else:
+ cp.add_section(sect)
+ cp.set(sect, "tot", "yes" if tot.enabled else "no")
+ cp.set(sect, "tot_max_key_sec", str(tot.max_key_sec))
+ cp.set(sect, "tot_min_gap_ms", str(int(tot.min_gap_sec * 1000.0)))
+ cp.set(sect, "tot_max_consecutive", str(tot.max_consecutive))
+ cp.set(sect, "tot_max_bursts", str(tot.max_bursts))
+ with ini_path.open("w", encoding="utf-8") as fh:
+ cp.write(fh)
+
+
+def tot_trip_path(state_dir: str, bc_index: int) -> Path:
+ return Path(state_dir) / f"tot-trip-bc{bc_index}"
+
+
+def parse_tot_trip_file(path: Path) -> dict[str, str]:
+ out: dict[str, str] = {}
+ try:
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
+ if "=" not in line:
+ continue
+ key, val = line.split("=", 1)
+ out[key.strip()] = val.strip()
+ except OSError:
+ pass
+ return out
+
+
+def device_tot_operational(state: DaemonState, dev_id: str) -> bool:
+ rt = state.devices.get(dev_id)
+ if rt is None:
+ return False
+ return not rt.tot_paused
+
+
+def handle_tot_trip(state: DaemonState, dev_id: str, reason: str) -> None:
+ rt = state.devices.get(dev_id)
+ if rt is None or rt.tot_paused:
+ return
+ log(f"TOT trip ({dev_id}) reason={reason} — pause, stop stack, reset bcpr")
+ rt.tot_paused = True
+ rt.tot_trip_reason = reason or "unknown"
+ recover = state.cfg.tot.recover_sec
+ rt.tot_paused_until = time.time() + recover if recover > 0 else 0.0
+ stop_device_stack(state, dev_id)
+ rt.stack_status = "tot-paused"
+ rt.link_status = "tot-paused"
+ rt.prep_done = False
+ broadcast(
+ state,
+ f"EVENT device={dev_id} tot=trip reason={rt.tot_trip_reason} stack=tot-paused",
+ )
+
+
+def poll_tot_trips(state: DaemonState) -> None:
+ if not state.cfg.tot.enabled:
+ return
+ for dev_id, rt in state.devices.items():
+ kind = rt.cfg.backend_type or ""
+ if kind not in ("max25-bcpr-kiss", "bcpr-kiss"):
+ continue
+ if rt.tot_paused:
+ continue
+ explicit = rt.cfg.max25_bcpr_ini or rt.cfg.bcpr_ini or ""
+ resolved = resolve_max25_bcpr_ini(explicit)
+ if resolved is None:
+ continue
+ state_dir = read_bcpr_state_dir(resolved)
+ trip = tot_trip_path(state_dir, bcpr_bc_index(rt.cfg))
+ if not trip.is_file():
+ continue
+ meta = parse_tot_trip_file(trip)
+ if meta.get("tripped") != "1":
+ continue
+ handle_tot_trip(state, dev_id, meta.get("reason", "unknown"))
+
+
+def poll_tot_recovery(state: DaemonState) -> None:
+ if not state.cfg.tot.enabled:
+ return
+ now = time.time()
+ for dev_id, rt in state.devices.items():
+ if not rt.tot_paused:
+ continue
+ kind = rt.cfg.backend_type or ""
+ if kind not in ("max25-bcpr-kiss", "bcpr-kiss"):
+ continue
+ recover = state.cfg.tot.recover_sec
+ if recover <= 0:
+ continue
+ if rt.tot_paused_until > 0 and now < rt.tot_paused_until:
+ continue
+ explicit = rt.cfg.max25_bcpr_ini or rt.cfg.bcpr_ini or ""
+ resolved = resolve_max25_bcpr_ini(explicit)
+ if resolved is not None:
+ trip = tot_trip_path(read_bcpr_state_dir(resolved), bcpr_bc_index(rt.cfg))
+ try:
+ trip.unlink(missing_ok=True)
+ except OSError:
+ pass
+ rt.tot_paused = False
+ rt.tot_trip_reason = ""
+ rt.tot_paused_until = 0.0
+ log(f"TOT recover ({dev_id}) — restarting max25-bcpr stack")
+ if state.cfg.auto_start and backend_enabled(state, dev_id):
+ start_device_stack_for_tot(state, dev_id)
+
+
+def start_device_stack_for_tot(state: DaemonState, dev_id: str) -> None:
+ """Restart bcpr stack after TOT cooldown (single device, no full start_stacks)."""
+ rt = state.devices[dev_id]
+ kind = rt.cfg.backend_type or ""
+ if kind in ("max25-bcpr-kiss", "bcpr-kiss"):
+ explicit = rt.cfg.max25_bcpr_ini or rt.cfg.bcpr_ini or ""
+ resolved = resolve_max25_bcpr_ini(explicit)
+ if resolved is None:
+ rt.stack_status = "error-no-ini"
+ return
+ sync_tot_to_bcpr_ini(resolved, state.cfg.tot)
+ start_max25_bcpr_stack(state, dev_id, resolved)
+
+
+def _read_subprocess_capture(fh, limit: int = 4000) -> str:
+ """Read captured ctl stdout/stderr (UTF-8, truncated tail)."""
+ try:
+ fh.seek(0)
+ raw = fh.read()
+ except OSError:
+ return ""
+ text = raw.decode("utf-8", errors="replace").strip()
+ if len(text) > limit:
+ return f"...\n{text[-limit:]}"
+ return text
+
+
+def start_max25_bcpr_stack(state: DaemonState, dev_id: str, ini: Path) -> None:
+ """Start max25-bcprd once via max25-bcpr-ctl for shared ini."""
+ rt = state.devices[dev_id]
+ ctl = resolve_max25_bcpr_ctl()
+ if ctl is None:
+ rt.stack_status = "error-no-ctl"
+ log(f"max25-bcpr-ctl not found ({dev_id})")
+ return
+ args = [str(ctl), "-c", str(ini), "start"]
+ env = os.environ.copy()
+ # Temp file (not PIPE): bcprd backgrounded by ctl must not block communicate().
+ # Capture ctl output; log tail only when rc!=0 or after ctl timeout.
+ proc: subprocess.Popen[bytes] | None = None
+ ctl_output = ""
+ try:
+ with tempfile.TemporaryFile(mode="w+b") as capfh:
+ try:
+ proc = subprocess.Popen(
+ args,
+ cwd=str(ROOT),
+ env=env,
+ stdin=subprocess.DEVNULL,
+ stdout=capfh,
+ stderr=subprocess.STDOUT,
+ start_new_session=True,
+ )
+ except OSError as exc:
+ log(f"max25-bcpr start failed ({dev_id}): {exc}")
+ rt.stack_status = "error"
+ return
+ try:
+ proc.communicate(timeout=30)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ try:
+ proc.communicate(timeout=2)
+ except (subprocess.TimeoutExpired, OSError):
+ pass
+ ctl_output = _read_subprocess_capture(capfh)
+ # May still have spawned max25-bcprd — fall through to kiss probe.
+ log(f"max25-bcpr-ctl start timed out ({dev_id}) — probing live kiss/pid")
+ if ctl_output:
+ log(f"max25-bcpr-ctl output ({dev_id}): {ctl_output}")
+ else:
+ ctl_output = _read_subprocess_capture(capfh)
+ except OSError as exc:
+ log(f"max25-bcpr start failed ({dev_id}): {exc}")
+ rt.stack_status = "error"
+ return
+ if proc is None:
+ rt.stack_status = "error"
+ return
+ if proc.returncode not in (0, None, -9, -15):
+ # -9/-15: we killed a hung ctl; still probe kiss below.
+ if proc.returncode > 0:
+ rt.stack_status = "error"
+ if ctl_output:
+ log(f"max25-bcpr-ctl start rc={proc.returncode} ({dev_id}): {ctl_output}")
+ else:
+ log(f"max25-bcpr-ctl start rc={proc.returncode} ({dev_id})")
+ return
+ # max25-bcpr-ctl itself exits; live daemon is max25-bcprd (pidfile under state_dir).
+ rt.stack_proc = None
+ rt.stack_status = "running"
+ kiss = normalize_max25_bcpr_path(
+ (rt.cfg.kiss_link or "").strip() or MAX25_BCPR_KISS_DEFAULT
+ )
+ deadline = time.time() + 5.0
+ while time.time() < deadline:
+ if os.path.exists(kiss):
+ break
+ time.sleep(0.1)
+ if not os.path.exists(kiss):
+ rt.stack_status = "error-no-kiss"
+ log(f"max25-bcpr kiss_link missing after start ({dev_id}: {kiss})")
+ return
+ addrs = f"ipv4={rt.cfg.ipv4 or '-'} ipv6={rt.cfg.ipv6 or '-'}"
+ log(f"max25-bcpr started ({dev_id}, ini={ini}, kiss={kiss}, {addrs})")
+ rt.stack_status = "ready"
+ # HyBBX attach: HyBBX opens kiss_link — do not hold the PTY here.
+ if hybbx_release_attach(state):
+ rt.link_status = "ready"
+ rt.prep_done = True
+ log(f"max25-bcpr ready — HyBBX owns KISS attach ({dev_id}: {kiss})")
+ return
+ # Standalone: open+hold KISS so max25-terminal TX works (stack_proc=None).
+ if backend_enabled(state, dev_id):
+ if open_backend(state, dev_id):
+ if attach_backend_session(state, dev_id):
+ # UI/datagram TX uses CONNECT as session arm — arm at start so
+ # max25-terminal SEND keys MCR without a separate CONNECT race.
+ state.connected = True
+ log(f"max25-bcpr KISS open ({dev_id}: {kiss})")
+ else:
+ log(f"max25-bcpr KISS open but attach failed ({dev_id})")
+ else:
+ log(f"max25-bcpr KISS open failed ({dev_id}) status={rt.link_status}")
+
+
+def start_device_stack(state: DaemonState, dev_id: str) -> None:
+ if uses_inline_tnc_prep(state, dev_id):
+ prep_inline_serial_device(state, dev_id)
+ return
+ rt = state.devices[dev_id]
+ ctl = ctl_path(ROOT, PREFIX, _EXE)
+ if not ctl.is_file():
+ rt.stack_status = "error-no-ctl"
+ return
+ hw = device_hardware(state, dev_id)
+ dev_cfg = rt.cfg
+ ctl_device = dev_id
+ if (dev_cfg.backend_type or "") == "baycom-kiss":
+ ctl_device = baycom_ctl_device_id(dev_cfg)
+ args = [
+ str(ctl),
+ "start",
+ "--mode",
+ state.cfg.mode,
+ "--hardware",
+ hw,
+ "--device",
+ ctl_device,
+ ]
+ kind = dev_cfg.backend_type or ""
+ if kind == "baycom-kiss":
+ explicit = dev_cfg.baycom_ini or ""
+ resolved = resolve_baycom_ini(dev_id, ROOT, PREFIX, explicit)
+ if resolved:
+ args.extend(["--baycom-ini", str(resolved)])
+ env = os.environ.copy()
+ env["MAX25_MODE"] = state.cfg.mode
+ if hw == "tncs" and state.cfg.stack_recover_only:
+ env["MAX25_TNC_PREP"] = "recover"
+ workdir = str(ROOT if (ROOT / "plugins").is_dir() else (PREFIX or ROOT))
+ try:
+ proc = subprocess.Popen(
+ args,
+ cwd=workdir,
+ env=env,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=True,
+ )
+ except OSError as exc:
+ log(f"stack start failed ({dev_id}): {exc}")
+ rt.stack_status = "error"
+ return
+ rt.stack_proc = proc
+ rt.stack_status = "running"
+ log(f"stack started pid={proc.pid} ({hw}/{dev_id})")
+
+
+def start_stacks(state: DaemonState) -> None:
+ """Start per-device stacks; one max25-bcpr-ctl per shared ini."""
+ started_baycom_ini: dict[str, str] = {}
+ started_max25_bcpr_ini: dict[str, str] = {}
+ for dev_id in enabled_device_ids(state):
+ rt = state.devices[dev_id]
+ kind = rt.cfg.backend_type or ""
+ if uses_inline_tnc_prep(state, dev_id):
+ prep_inline_serial_device(state, dev_id)
+ continue
+ if kind in ("max25-bcpr-kiss", "bcpr-kiss"):
+ explicit = rt.cfg.max25_bcpr_ini or rt.cfg.bcpr_ini or ""
+ resolved = resolve_max25_bcpr_ini(explicit)
+ ini_key = str(resolved) if resolved else ""
+ if ini_key and ini_key in started_max25_bcpr_ini:
+ primary = started_max25_bcpr_ini[ini_key]
+ primary_rt = state.devices[primary]
+ rt.stack_proc = primary_rt.stack_proc
+ rt.stack_status = primary_rt.stack_status
+ log(f"max25-bcpr stack shared with {primary} ({dev_id}, ini={ini_key})")
+ # Shared max25-bcprd — open this device's kiss only when max25d owns it.
+ if (
+ not hybbx_release_attach(state)
+ and rt.stack_status in ("ready", "running")
+ and backend_enabled(state, dev_id)
+ ):
+ if open_backend(state, dev_id):
+ attach_backend_session(state, dev_id)
+ elif hybbx_release_attach(state):
+ rt.link_status = "ready"
+ rt.prep_done = True
+ continue
+ if resolved:
+ sync_tot_to_bcpr_ini(resolved, state.cfg.tot)
+ start_max25_bcpr_stack(state, dev_id, resolved)
+ started_max25_bcpr_ini[str(resolved)] = dev_id
+ else:
+ rt.stack_status = "error-no-ini"
+ log(f"max25-bcpr.ini not found ({dev_id})")
+ continue
+ if kind == "baycom-kiss":
+ explicit = rt.cfg.baycom_ini or ""
+ resolved = resolve_baycom_ini(dev_id, ROOT, PREFIX, explicit)
+ ini_key = str(resolved) if resolved else ""
+ if ini_key and ini_key in started_baycom_ini:
+ primary = started_baycom_ini[ini_key]
+ primary_rt = state.devices[primary]
+ rt.stack_proc = primary_rt.stack_proc
+ rt.stack_status = primary_rt.stack_status
+ log(f"stack shared with {primary} ({dev_id}, ini={ini_key})")
+ continue
+ start_device_stack(state, dev_id)
+ if kind == "baycom-kiss":
+ explicit = rt.cfg.baycom_ini or ""
+ resolved = resolve_baycom_ini(dev_id, ROOT, PREFIX, explicit)
+ if resolved:
+ started_baycom_ini[str(resolved)] = dev_id
+
+
+def stop_device_stack(state: DaemonState, dev_id: str) -> None:
+ close_backend(state, dev_id)
+ rt = state.devices[dev_id]
+ kind = rt.cfg.backend_type or ""
+ if kind in ("max25-bcpr-kiss", "bcpr-kiss"):
+ ctl = resolve_max25_bcpr_ctl()
+ explicit = rt.cfg.max25_bcpr_ini or rt.cfg.bcpr_ini or ""
+ resolved = resolve_max25_bcpr_ini(explicit)
+ if ctl is not None and resolved is not None:
+ subprocess.run(
+ [str(ctl), "-c", str(resolved), "stop"],
+ cwd=str(ROOT),
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ rt.stack_proc = None
+ rt.stack_status = "stopped"
+ return
+ proc = rt.stack_proc
+ if proc is not None and proc.poll() is None:
+ try:
+ os.killpg(proc.pid, signal.SIGTERM)
+ except ProcessLookupError:
+ pass
+ except OSError:
+ proc.terminate()
+ rt.stack_proc = None
+ rt.stack_status = "stopped"
+ hw = device_hardware(state, dev_id)
+ ctl = ctl_path(ROOT, PREFIX, _EXE)
+ if ctl.is_file():
+ workdir = str(ROOT if (ROOT / "plugins").is_dir() else (PREFIX or ROOT))
+ stop_args = [str(ctl), "stop", "--hardware", hw, "--device", dev_id]
+ if kind == "baycom-kiss":
+ explicit = rt.cfg.baycom_ini or ""
+ resolved = resolve_baycom_ini(dev_id, ROOT, PREFIX, explicit)
+ if resolved:
+ stop_args.extend(["--baycom-ini", str(resolved)])
+ subprocess.run(
+ stop_args,
+ cwd=workdir,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+
+
+def stop_stacks(state: DaemonState) -> None:
+ for dev_id in list(state.devices):
+ stop_device_stack(state, dev_id)
+ log("all stacks stopped")
+
+
+def poll_device_stack(state: DaemonState, dev_id: str) -> None:
+ rt = state.devices[dev_id]
+ proc = rt.stack_proc
+ if proc is None:
+ return
+ rc = proc.poll()
+ if rc is None:
+ return
+ rt.stack_proc = None
+ if rc == 0:
+ rt.stack_status = "ready"
+ log(f"stack boot-wait finished ({dev_id}) rc={rc}")
+ if backend_enabled(state, dev_id):
+ open_backend(state, dev_id)
+ backend = rt.backend
+ stabilize = getattr(backend, "stabilize_session", None) if backend else None
+ if stabilize is not None:
+ ok = stabilize(state.cfg.callerid, force=False)
+ rt.prep_done = True
+ rt.link_status = backend.status
+ rt.inline_repair_failures = 0
+ if ok:
+ log(f"serial post boot-wait OK ({dev_id})")
+ if state.connected:
+ attach_backend_session(state, dev_id)
+ else:
+ log(
+ f"serial post boot-wait deferred ({dev_id}) "
+ f"status={backend.status}"
+ )
+ else:
+ rt.stack_status = f"error-rc{rc}"
+ log(f"stack boot-wait failed ({dev_id}) rc={rc}")
+
+
+def poll_stacks(state: DaemonState) -> None:
+ for dev_id in enabled_device_ids(state):
+ poll_device_stack(state, dev_id)
+ retry_pending_backends(state)
+
+
+def retry_pending_backends(state: DaemonState) -> None:
+ """Re-attach KISS PTY/serial when stack is up but the link was not ready yet."""
+ for dev_id in enabled_device_ids(state):
+ if not backend_enabled(state, dev_id):
+ continue
+ rt = state.devices[dev_id]
+ if hybbx_host_hybbx_owns_serial(state, dev_id):
+ continue
+ # bcpr uses "running" until kiss open flips to "ready"; accept both.
+ if rt.stack_status not in ("ready", "stopped", "running"):
+ continue
+ if not backend_needs_open(rt.backend):
+ continue
+ if open_backend(state, dev_id):
+ attach_backend_session(state, dev_id)
+ if rt.stack_status == "running":
+ rt.stack_status = "ready"
+
+
+def poll_reporting_passes(state: DaemonState) -> None:
+ """Advance timed data-quality pass windows (default 20s each, 3 passes)."""
+ now = time.time()
+ for rt in state.devices.values():
+ _sync_quality_tracker(state, rt)
+ rt.quality.tick(now)
+
+
+def poll_serial_stability(state: DaemonState) -> None:
+ """Periodic TNC health probe + software recovery (no power cycle)."""
+ cfg = state.cfg
+ if not cfg.serial_watch:
+ return
+ now = time.time()
+ if state.started_at and now - state.started_at < cfg.serial_watch_startup_grace:
+ return
+ for dev_id in enabled_device_ids(state):
+ if device_backend_kind(state, dev_id) != "kiss-serial":
+ continue
+ if hybbx_host_hybbx_owns_serial(state, dev_id):
+ continue
+ rt = state.devices[dev_id]
+ if rt.stack_proc is not None and rt.stack_proc.poll() is None:
+ continue
+ if (
+ not uses_inline_tnc_prep(state, dev_id)
+ and cfg.stack_recover_only
+ and rt.stack_status.startswith("error")
+ and rt.stack_proc is None
+ and now - rt.last_stack_retry >= cfg.stack_retry_interval
+ ):
+ rt.last_stack_retry = now
+ log(f"serial watch: stack retry recover-only ({dev_id})")
+ start_device_stack(state, dev_id)
+ continue
+ if not backend_enabled(state, dev_id):
+ continue
+ backend = rt.backend
+ force = backend is not None and backend.status in SERIAL_REPAIR_STATUSES
+ due = now - rt.last_watch >= cfg.serial_watch_interval
+ if not force and not due:
+ continue
+ if backend is None:
+ if backend_needs_open(None):
+ open_backend(state, dev_id)
+ backend = rt.backend
+ if backend is None:
+ continue
+ if now - rt.last_repair < cfg.serial_repair_cooldown and not force:
+ continue
+ if backend.status == "ready" and not force:
+ if due:
+ rt.last_watch = now
+ continue
+ stabilize = getattr(backend, "stabilize_session", None)
+ if stabilize is None:
+ continue
+ rt.last_watch = now
+ rt.last_repair = now
+ ok = stabilize(state.cfg.callerid, force=force)
+ rt.link_status = backend.status
+ if ok:
+ rt.inline_repair_failures = 0
+ if force:
+ log(f"serial watch: repaired ({dev_id})")
+ broadcast(state, f"EVENT device={dev_id} serial=ready")
+ else:
+ log(f"serial watch: repair failed ({dev_id}) status={backend.status}")
+ if (
+ uses_inline_tnc_prep(state, dev_id)
+ and backend.status == "error-host"
+ and cfg.serial_bootwait_escalate
+ ):
+ rt.inline_repair_failures += 1
+ if (
+ rt.inline_repair_failures >= cfg.serial_bootwait_escalate_after
+ and now - rt.last_bootwait_escalate >= cfg.serial_bootwait_escalate_cooldown
+ ):
+ rt.last_bootwait_escalate = now
+ rt.inline_repair_failures = 0
+ escalate_to_bootwait_stack(state, dev_id)
+ elif rt.inline_repair_failures >= cfg.serial_bootwait_escalate_after:
+ log(
+ f"serial watch: boot-wait escalate cooldown ({dev_id}) "
+ f"— manual: stacks/tncs/{dev_id}-boot-wait.sh"
+ )
+ if backend.status in ("error-io", "error-open", "error-no-device"):
+ close_backend(state, dev_id)
+ if open_backend(state, dev_id) and state.connected:
+ attach_backend_session(state, dev_id)
+
+
+def format_tx(state: DaemonState, text: str) -> str:
+ if state.cfg.ax25_ui:
+ return f"[AX25 UI {state.cfg.callerid}>{state.cfg.callid}] {text}"
+ return text
+
+
+def resolve_selected_device(state: DaemonState) -> Optional[str]:
+ dev_id = state.selected_device
+ if dev_id in state.devices:
+ return dev_id
+ ids = enabled_device_ids(state)
+ return ids[0] if ids else None
+
+
+def device_line(state: DaemonState, dev_id: str) -> str:
+ rt = state.devices[dev_id]
+ link = backend_serial_label(rt.backend) if rt.backend is not None else rt.link_status
+ hw = device_hardware(state, dev_id)
+ backend = rt.cfg.backend_type or "auto"
+ enabled = "yes" if rt.cfg.enabled else "no"
+ return (
+ f"DEVICE id={dev_id} hardware={hw} backend={backend} serial={link} "
+ f"stack={rt.stack_status} enabled={enabled} "
+ f"error={device_reporting_error(state, rt)} "
+ f"voice={device_reporting_voice(state, rt)}"
+ )
+
+
+def handle_command(state: DaemonState, sock: socket.socket, line: str) -> None:
+ line = line.strip("\r\n")
+ if not line:
+ return
+ upper = line.upper()
+
+ if upper == "PING":
+ send_line(sock, "OK")
+ return
+
+ if upper == "GET STATUS":
+ send_line(sock, status_line(state))
+ send_line(sock, "OK")
+ return
+
+ if upper == "GET DEVICES":
+ for dev_id in sorted(state.devices):
+ send_line(sock, device_line(state, dev_id))
+ send_line(sock, "OK")
+ return
+
+ if upper.startswith("SET DEVICE ") or upper.startswith("SELECT DEVICE "):
+ prefix = "SET DEVICE " if upper.startswith("SET DEVICE ") else "SELECT DEVICE "
+ dev_id = line[len(prefix) :].strip()
+ if dev_id not in state.devices:
+ send_line(sock, f"ERR unknown device: {dev_id}")
+ return
+ state.selected_device = dev_id
+ state.cfg.device = dev_id
+ send_line(sock, "OK")
+ return
+
+ if upper.startswith("SET CALLERID "):
+ value = line[13:].strip().upper()
+ if not valid_callsign(value):
+ send_line(sock, "ERR invalid CALLERID")
+ return
+ state.cfg.callerid = value
+ send_line(sock, "OK")
+ return
+
+ if upper.startswith("SET CALLID "):
+ value = line[11:].strip().upper()
+ if not valid_callsign(value):
+ send_line(sock, "ERR invalid CALLID")
+ return
+ state.cfg.callid = value
+ send_line(sock, "OK")
+ return
+
+ if upper.startswith("SET AX25_UI "):
+ flag = line[12:].strip().lower()
+ if flag in ("on", "yes", "1", "true"):
+ state.cfg.ax25_ui = True
+ elif flag in ("off", "no", "0", "false"):
+ state.cfg.ax25_ui = False
+ else:
+ send_line(sock, "ERR ax25_ui on|off")
+ return
+ send_line(sock, "OK")
+ return
+
+ if upper == "CONNECT":
+ if not attach_all_sessions(state):
+ send_line(sock, "ERR link not ready")
+ return
+ state.connected = True
+ send_line(sock, "EVENT connected")
+ send_line(sock, "OK")
+ return
+
+ if upper == "DISCONNECT":
+ detach_all_sessions(state)
+ state.connected = False
+ send_line(sock, "EVENT disconnected")
+ send_line(sock, "OK")
+ return
+
+ if upper.startswith("MONITOR "):
+ flag = line[8:].strip().lower()
+ state.monitor_only = flag in ("on", "yes", "1", "true")
+ send_line(sock, "OK")
+ return
+
+ if upper.startswith("BAN "):
+ value = line[4:].strip().upper()
+ if not valid_callsign(value):
+ send_line(sock, "ERR invalid callsign")
+ return
+ try:
+ state.bans.add(value)
+ except OSError as exc:
+ send_line(sock, f"ERR ban save failed: {exc}")
+ return
+ send_line(sock, "OK")
+ return
+
+ if upper.startswith("UNBAN "):
+ value = line[6:].strip().upper()
+ if not valid_callsign(value):
+ send_line(sock, "ERR invalid callsign")
+ return
+ try:
+ if not state.bans.remove(value):
+ send_line(sock, "ERR not banned")
+ return
+ except OSError as exc:
+ send_line(sock, f"ERR ban save failed: {exc}")
+ return
+ send_line(sock, "OK")
+ return
+
+ if upper == "BANS":
+ for entry in state.bans.list():
+ send_line(sock, f"BAN {entry}")
+ send_line(sock, "OK")
+ return
+
+ if upper.startswith("SEND "):
+ if state.monitor_only:
+ send_line(sock, "ERR monitor-only")
+ return
+ # UI frames: auto-arm session if stack/KISS is up. Terminal Enter/F10→SEND
+ # must key PTT/MCR without requiring a prior CONNECT (L4 writes kiss
+ # directly and already keys; unix SEND must match).
+ if not state.connected:
+ if not attach_all_sessions(state):
+ send_line(sock, "ERR not connected")
+ return
+ state.connected = True
+ send_line(sock, "EVENT connected")
+ dev_id = resolve_selected_device(state)
+ if dev_id is None:
+ send_line(sock, "ERR no device configured")
+ return
+ payload = line[5:]
+ framed = format_tx(state, payload)
+ rt = state.devices[dev_id]
+ if backend_enabled(state, dev_id):
+ if rt.backend is None or rt.backend.status != "ready":
+ # Re-attach after DISCONNECT left kiss inactive but stack ready.
+ if not attach_backend_session(state, dev_id):
+ send_line(sock, "ERR link not ready")
+ return
+ if rt.backend is None or rt.backend.status != "ready":
+ send_line(sock, "ERR link not ready")
+ return
+ ok, display = rt.backend.transmit(
+ state.cfg.callerid,
+ state.cfg.callid,
+ payload,
+ state.cfg.ax25_ui,
+ )
+ if not ok and hasattr(rt.backend, "stabilize_session"):
+ log(f"serial watch: tx retry after repair ({dev_id})")
+ if rt.backend.stabilize_session(state.cfg.callerid, force=True):
+ rt.link_status = rt.backend.status
+ ok, display = rt.backend.transmit(
+ state.cfg.callerid,
+ state.cfg.callid,
+ payload,
+ state.cfg.ax25_ui,
+ )
+ if not ok:
+ send_line(sock, f"ERR {display}")
+ return
+ framed = f"device={dev_id} {display}"
+ log(f"tx {dev_id}: {framed}")
+ send_line(sock, f"RX {framed}")
+ broadcast(state, f"RX {framed}", skip=sock)
+ send_line(sock, "OK")
+ return
+
+ send_line(sock, f"ERR unknown command: {line.split()[0]}")
+
+
+def tcp_auth_ok(sock: socket.socket, expected: str, timeout: float = 30.0) -> bool:
+ if not expected:
+ return True
+ send_line(sock, "AUTH required")
+ sock.settimeout(timeout)
+ buf = b""
+ try:
+ while True:
+ try:
+ chunk = sock.recv(4096)
+ except socket.timeout:
+ return False
+ if not chunk:
+ return False
+ buf += chunk
+ if len(buf) > M25_MAX_LINE_BUF:
+ return False
+ while b"\n" in buf:
+ raw, buf = buf.split(b"\n", 1)
+ try:
+ line = raw.decode("utf-8").strip("\r")
+ except UnicodeDecodeError:
+ return False
+ if not line:
+ continue
+ if line.upper().startswith("AUTH "):
+ supplied = line[5:]
+ return supplied == expected
+ return False
+ finally:
+ sock.settimeout(300.0)
+
+
+def client_thread(state: DaemonState, sock: socket.socket, from_tcp: bool) -> None:
+ sock.settimeout(300.0)
+ buf = b""
+ try:
+ if from_tcp and state.cfg.tcp_password:
+ if not tcp_auth_ok(sock, state.cfg.tcp_password):
+ send_line(sock, "ERR auth failed")
+ return
+ send_line(sock, "OK")
+ send_line(sock, status_line(state))
+ while True:
+ try:
+ chunk = sock.recv(4096)
+ except socket.timeout:
+ continue
+ if not chunk:
+ break
+ buf += chunk
+ if len(buf) > M25_MAX_LINE_BUF:
+ send_line(sock, "ERR line too long")
+ break
+ while b"\n" in buf:
+ raw, buf = buf.split(b"\n", 1)
+ try:
+ line = raw.decode("utf-8")
+ except UnicodeDecodeError:
+ send_line(sock, "ERR invalid utf-8")
+ continue
+ handle_command(state, sock, line)
+ except OSError:
+ pass
+ finally:
+ with state.lock:
+ state.clients.discard(sock)
+ try:
+ sock.close()
+ except OSError:
+ pass
+
+
+def serve(
+ state: DaemonState,
+ listeners: list[tuple[socket.socket, bool, tuple[int, int] | None]],
+) -> None:
+ running = True
+
+ def on_signal(_signum, _frame):
+ nonlocal running
+ running = False
+
+ signal.signal(signal.SIGTERM, on_signal)
+ signal.signal(signal.SIGINT, on_signal)
+
+ if state.cfg.auto_start:
+ start_stacks(state)
+
+ state.started_at = time.time()
+
+ device_lines: list[tuple[str, str, str]] = []
+ for dev_id in enabled_device_ids(state):
+ rt = state.devices[dev_id]
+ link = backend_serial_label(rt.backend) if rt.backend is not None else rt.link_status
+ device_lines.append((dev_id, rt.stack_status, link))
+
+ emit_startup_complete(
+ device_lines=device_lines,
+ tcp_host=state.cfg.tcp_host,
+ tcp_port=state.cfg.tcp_port,
+ unix_socket=state.cfg.unix_socket,
+ )
+
+ drop_privileges_or_exit(
+ RunAsConfig(
+ user=state.cfg.run_user,
+ group=state.cfg.run_group,
+ uid=state.cfg.run_uid,
+ gid=state.cfg.run_gid,
+ ),
+ log=lambda msg, area="privilege": LOGGER.info(msg, area=area),
+ )
+
+ while running:
+ poll_stacks(state)
+ poll_tot_trips(state)
+ poll_tot_recovery(state)
+ poll_reporting_passes(state)
+ poll_serial_stability(state)
+ socks = [lsock for lsock, _tcp, _bid in listeners]
+ rlist, _, _ = select.select(socks, [], [], 1.0)
+ for lsock, from_tcp, _bid in listeners:
+ if lsock not in rlist:
+ continue
+ try:
+ client, _addr = lsock.accept()
+ except OSError:
+ continue
+ client.setblocking(True)
+ with state.lock:
+ state.clients.add(client)
+ threading.Thread(
+ target=client_thread,
+ args=(state, client, from_tcp),
+ daemon=True,
+ ).start()
+
+ stop_stacks(state)
+ for lsock, from_tcp, bind_id in listeners:
+ if not from_tcp and state.cfg.unix_socket:
+ unlink_unix_if_ours(state.cfg.unix_socket, bind_id)
+ lsock.close()
+
+
+def make_listeners(
+ cfg: DaemonConfig,
+) -> list[tuple[socket.socket, bool, tuple[int, int] | None]]:
+ listeners: list[tuple[socket.socket, bool, tuple[int, int] | None]] = []
+
+ tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ tcp.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ try:
+ tcp.bind((cfg.tcp_host, cfg.tcp_port))
+ except OSError as exc:
+ tcp.close()
+ log(f"TCP bind {cfg.tcp_host}:{cfg.tcp_port} failed: {exc}")
+ raise SystemExit(1) from exc
+ tcp.listen(32)
+ tcp.setblocking(False)
+ listeners.append((tcp, True, None))
+
+ if cfg.unix_socket:
+ sock_path = Path(cfg.unix_socket)
+ try:
+ sock_path.parent.mkdir(parents=True, exist_ok=True)
+ except OSError:
+ fallback = Path("/tmp/max25/modem.sock")
+ log(f"unix {cfg.unix_socket} unavailable, using {fallback}")
+ cfg.unix_socket = str(fallback)
+ sock_path = fallback
+ try:
+ sock_path.parent.mkdir(parents=True, exist_ok=True)
+ except OSError:
+ log("unix socket disabled (no writable path)")
+ cfg.unix_socket = ""
+ return listeners
+ # Never unlink a live peer path — that orphans the other max25d FD
+ # (ss still shows the name; clients get ENOENT).
+ if unix_path_is_live(cfg.unix_socket):
+ log(
+ f"unix socket {cfg.unix_socket} already live — not stealing "
+ "(refuse second max25d unix bind)"
+ )
+ else:
+ try:
+ os.unlink(cfg.unix_socket)
+ except FileNotFoundError:
+ pass
+ except OSError:
+ pass
+ unix = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ try:
+ unix.bind(cfg.unix_socket)
+ except OSError as exc:
+ log(f"unix socket {cfg.unix_socket} skipped ({exc})")
+ unix.close()
+ else:
+ try:
+ os.chmod(cfg.unix_socket, 0o660)
+ except OSError:
+ pass
+ unix.listen(32)
+ unix.setblocking(False)
+ listeners.append((unix, False, unix_path_id(cfg.unix_socket)))
+
+ return listeners
+
+
+def main(argv: Optional[list[str]] = None) -> int:
+ if not max25d_supported():
+ log(f"max25d is not supported on {sys.platform}")
+ return 1
+
+ parser = argparse.ArgumentParser(description=f"MAX25 daemon ({platform_label()})")
+ parser.add_argument(
+ "-c",
+ "--config",
+ type=Path,
+ default=None,
+ help="Path to max25d.ini",
+ )
+ parser.add_argument(
+ "--no-stack",
+ action="store_true",
+ help="Do not auto-start hardware stack",
+ )
+ parser.add_argument(
+ "--tcp-port",
+ type=int,
+ default=None,
+ help="Override TCP listen port",
+ )
+ parser.add_argument(
+ "--no-serial",
+ action="store_true",
+ help="Disable KISS serial bridge (loopback SEND only)",
+ )
+ parser.add_argument(
+ "--session",
+ choices=("tmux", "screen"),
+ metavar="BACKEND",
+ help="Re-exec via max25d-session (detach in tmux/screen); use max25d-session attach",
+ )
+ args = parser.parse_args(argv)
+
+ if args.session:
+ session_sh = ROOT / "scripts" / "max25d-session.sh"
+ if not session_sh.is_file():
+ session_sh = Path(PREFIX) / "bin" / "max25d-session" if PREFIX else session_sh
+ if not session_sh.is_file():
+ LOGGER.error(
+ "max25d-session not found — install scripts/max25d-session.sh or use tmux/screen manually",
+ area="session",
+ )
+ return 1
+ cmd = [str(session_sh), "start", f"--{args.session}"]
+ if args.config:
+ cmd.extend(["-c", str(args.config)])
+ os.execv(cmd[0], cmd)
+
+ cfg = load_config(args.config)
+ if args.no_stack:
+ cfg.auto_start = False
+ if args.tcp_port is not None:
+ cfg.tcp_port = args.tcp_port
+ if args.no_serial:
+ cfg.serial_enabled = False
+ enforce_lite_localhost(cfg)
+
+ if cfg.modular_tcp.enabled and cfg.modular_tcp.role == "main":
+ svc = ModularTcpMainService(cfg.modular_tcp, cfg.tcp_host, cfg.tcp_port, log)
+ svc.start()
+ log(
+ f"modular TCP/IP Servers Service — Main '{cfg.modular_tcp.service_name}' "
+ f"({len(cfg.modular_tcp.secondaries)} secondaries)"
+ )
+ try:
+ while True:
+ time.sleep(1.0)
+ except KeyboardInterrupt:
+ pass
+ finally:
+ svc.stop()
+ return 0
+
+ state = DaemonState(cfg=cfg, bans=BanList(cfg.bans_file))
+ init_device_runtimes(state)
+ emit_startup_banner(
+ config_path=cfg.config_path or None,
+ cfg=cfg,
+ devices=cfg.devices,
+ tested_fn=registry_tested,
+ )
+ listeners = make_listeners(cfg)
+ try:
+ serve(state, listeners)
+ except KeyboardInterrupt:
+ stop_stacks(state)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/stacks/daemon/modular_tcp_server.py b/stacks/daemon/modular_tcp_server.py
new file mode 100644
index 0000000..b7f5612
--- /dev/null
+++ b/stacks/daemon/modular_tcp_server.py
@@ -0,0 +1,208 @@
+"""
+Modular TCP/IP Servers Service — central Main + Secondary routing for MAX25.
+
+Public name in docs: "modular TCP/IP Servers Service".
+Main instance accepts M25/1 clients and forwards sessions to Secondary max25d peers.
+"""
+from __future__ import annotations
+
+import configparser
+import selectors
+import socket
+import threading
+from dataclasses import dataclass, field
+from typing import Callable, Optional
+
+
+LogFn = Callable[[str], None]
+
+
+@dataclass
+class SecondaryEndpoint:
+ name: str
+ host: str
+ port: int
+
+
+@dataclass
+class ModularTcpConfig:
+ enabled: bool = False
+ role: str = "standalone" # standalone | main | secondary
+ service_name: str = "modular-tcp-server"
+ instance_id: str = ""
+ secondaries: list[SecondaryEndpoint] = field(default_factory=list)
+ connect_timeout: float = 5.0
+ buffer_size: int = 65536
+
+
+def load_modular_tcp(cp: configparser.ConfigParser) -> ModularTcpConfig:
+ cfg = ModularTcpConfig()
+ if not cp.has_section("modular_tcp"):
+ return cfg
+ sec = cp["modular_tcp"]
+ cfg.enabled = sec.get("enabled", "no").strip().lower() in ("1", "yes", "true", "on")
+ cfg.role = sec.get("role", cfg.role).strip().lower() or cfg.role
+ cfg.service_name = sec.get("service_name", cfg.service_name).strip() or cfg.service_name
+ cfg.instance_id = sec.get("instance_id", cfg.instance_id).strip()
+ if cp.has_option("modular_tcp", "connect_timeout"):
+ try:
+ cfg.connect_timeout = float(cp.get("modular_tcp", "connect_timeout"))
+ except (TypeError, ValueError):
+ cfg.connect_timeout = 5.0
+ if cfg.connect_timeout <= 0.0:
+ cfg.connect_timeout = 5.0
+ if cp.has_section("modular_tcp.secondaries"):
+ for key in cp.options("modular_tcp.secondaries"):
+ raw = cp.get("modular_tcp.secondaries", key).strip()
+ if not raw:
+ continue
+ host, _, port_s = raw.partition(":")
+ host = host.strip()
+ if not host:
+ continue
+ try:
+ port = int(port_s or "7326")
+ except (TypeError, ValueError):
+ port = 7326
+ if port < 1 or port > 65535:
+ port = 7326
+ cfg.secondaries.append(SecondaryEndpoint(name=key.strip(), host=host, port=port))
+ if cfg.enabled and cfg.role == "main" and not cfg.secondaries:
+ # Legacy comma list: secondaries = host:port,host:port
+ raw = sec.get("secondaries", "").strip()
+ if raw:
+ for idx, item in enumerate(x.strip() for x in raw.split(",") if x.strip()):
+ host, _, port_s = item.partition(":")
+ host = host.strip()
+ if not host:
+ continue
+ try:
+ port = int(port_s or "7326")
+ except (TypeError, ValueError):
+ port = 7326
+ if port < 1 or port > 65535:
+ port = 7326
+ cfg.secondaries.append(
+ SecondaryEndpoint(
+ name=f"secondary-{idx + 1}",
+ host=host,
+ port=port,
+ )
+ )
+ return cfg
+
+
+class ModularTcpMainService:
+ """Bidirectional relay: operator client <-> Secondary max25d M25/1."""
+
+ def __init__(
+ self,
+ cfg: ModularTcpConfig,
+ listen_host: str,
+ listen_port: int,
+ log: LogFn,
+ ) -> None:
+ self.cfg = cfg
+ self.listen_host = listen_host
+ self.listen_port = listen_port
+ self.log = log
+ self._stop = threading.Event()
+ self._thread: Optional[threading.Thread] = None
+ self._rr_index = 0
+ self._lock = threading.Lock()
+
+ def _pick_secondary(self) -> Optional[SecondaryEndpoint]:
+ with self._lock:
+ if not self.cfg.secondaries:
+ return None
+ ep = self.cfg.secondaries[self._rr_index % len(self.cfg.secondaries)]
+ self._rr_index += 1
+ return ep
+
+ def _relay(self, client: socket.socket, upstream: socket.socket) -> None:
+ sel = selectors.DefaultSelector()
+ sel.register(client, selectors.EVENT_READ)
+ sel.register(upstream, selectors.EVENT_READ)
+ try:
+ while not self._stop.is_set():
+ for key, _ in sel.select(timeout=0.5):
+ sock = key.fileobj
+ assert isinstance(sock, socket.socket)
+ try:
+ data = sock.recv(self.cfg.buffer_size)
+ except OSError:
+ return
+ if not data:
+ return
+ target = upstream if sock is client else client
+ try:
+ target.sendall(data)
+ except OSError:
+ return
+ finally:
+ sel.close()
+
+ def _handle_client(self, client: socket.socket, addr: tuple[str, int]) -> None:
+ ep = self._pick_secondary()
+ if ep is None:
+ self.log(f"modular_tcp: no secondaries configured — drop {addr}")
+ client.close()
+ return
+ upstream = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ upstream.settimeout(self.cfg.connect_timeout)
+ try:
+ upstream.connect((ep.host, ep.port))
+ except OSError as exc:
+ self.log(f"modular_tcp: upstream {ep.name} {ep.host}:{ep.port} failed ({exc})")
+ client.close()
+ return
+ upstream.settimeout(None)
+ self.log(f"modular_tcp: {addr} -> {ep.name} {ep.host}:{ep.port}")
+ try:
+ self._relay(client, upstream)
+ finally:
+ for s in (client, upstream):
+ try:
+ s.shutdown(socket.SHUT_RDWR)
+ except OSError:
+ pass
+ s.close()
+
+ def _serve(self) -> None:
+ srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ srv.bind((self.listen_host, self.listen_port))
+ srv.listen(64)
+ srv.settimeout(1.0)
+ self.log(
+ f"modular_tcp: Main service '{self.cfg.service_name}' "
+ f"listen {self.listen_host}:{self.listen_port} "
+ f"secondaries={len(self.cfg.secondaries)}"
+ )
+ try:
+ while not self._stop.is_set():
+ try:
+ client, addr = srv.accept()
+ except socket.timeout:
+ continue
+ except OSError:
+ break
+ threading.Thread(
+ target=self._handle_client,
+ args=(client, addr),
+ daemon=True,
+ ).start()
+ finally:
+ srv.close()
+
+ def start(self) -> None:
+ if self._thread is not None:
+ return
+ self._thread = threading.Thread(target=self._serve, name="modular-tcp-main", daemon=True)
+ self._thread.start()
+
+ def stop(self) -> None:
+ self._stop.set()
+ if self._thread is not None:
+ self._thread.join(timeout=3.0)
+ self._thread = None
diff --git a/stacks/daemon/paths.py b/stacks/daemon/paths.py
new file mode 100644
index 0000000..62d4974
--- /dev/null
+++ b/stacks/daemon/paths.py
@@ -0,0 +1,281 @@
+"""MAX25 layout detection — dev checkout vs cmake install prefix."""
+from __future__ import annotations
+
+import configparser
+import os
+import sys
+from pathlib import Path
+
+MAX25_BCPR_KISS_DEFAULT = "/tmp/max25-bcpr/kiss-bc0"
+
+
+def normalize_max25_bcpr_path(path: str) -> str:
+ """Map legacy /tmp/bcpr and /var/run/bcpr paths to canonical max25-bcpr layout."""
+ p = (path or "").strip()
+ if not p:
+ return p
+ for old, new in (
+ ("/tmp/bcpr/", "/tmp/max25-bcpr/"),
+ ("/tmp/bcpr", "/tmp/max25-bcpr"),
+ ("/var/run/bcpr/", "/tmp/max25-bcpr/"),
+ ("/var/run/bcpr", "/tmp/max25-bcpr"),
+ ):
+ if p == old:
+ return new
+ if p.startswith(old + "/") or (old.endswith("/") and p.startswith(old)):
+ return new + p[len(old) :]
+ return p
+
+
+_MANIFEST = Path("plugins/manifest.yaml")
+_SHARE_MAX25 = Path("share/max25")
+_STACKS = Path("stacks")
+
+
+def _repo_from_daemon_dir(here: Path) -> Path | None:
+ if here.name == "daemon" and (here.parent / "tncs").is_dir():
+ repo = here.parent.parent
+ if (repo / _MANIFEST).is_file():
+ return repo
+ return None
+
+
+def _prefix_from_bindir(bindir: Path) -> Path | None:
+ prefix = bindir.parent
+ if (prefix / _SHARE_MAX25).is_dir() and not (prefix / _MANIFEST).is_file():
+ return prefix
+ return None
+
+
+def resolve_layout(exe: Path) -> tuple[Path, Path | None]:
+ """Return (tree_root, install_prefix).
+
+ tree_root is the repo root in dev, or MAX25_ROOT / prefix when installed.
+ install_prefix is set when exe lives under PREFIX/bin, else None.
+ """
+ exe = exe.resolve()
+ here = exe.parent
+
+ repo = _repo_from_daemon_dir(here)
+ if repo is not None:
+ return repo, None
+
+ prefix = _prefix_from_bindir(here)
+ if prefix is not None:
+ tree = Path(os.environ.get("MAX25_ROOT", str(prefix)))
+ return tree, prefix
+
+ if (here.parent / _MANIFEST).is_file():
+ return here.parent, None
+
+ return exe.parents[2], None
+
+
+def share_max25_dir(tree: Path, prefix: Path | None) -> Path:
+ if prefix is not None:
+ share = prefix / _SHARE_MAX25
+ if share.is_dir():
+ return share
+ return tree / _SHARE_MAX25
+
+
+def stacks_dir(tree: Path, prefix: Path | None) -> Path:
+ tree_stacks = tree / _STACKS
+ if tree_stacks.is_dir():
+ return tree_stacks
+ if prefix is not None:
+ installed = prefix / _SHARE_MAX25 / _STACKS
+ if installed.is_dir():
+ return installed
+ return tree_stacks
+
+
+def tnc_serial_recovery_path(tree: Path, prefix: Path | None) -> Path | None:
+ """Locate tnc_serial_recovery.py (dev checkout or cmake install)."""
+ for candidate in (
+ stacks_dir(tree, prefix) / "tncs" / "tnc_serial_recovery.py",
+ tree / _STACKS / "tncs" / "tnc_serial_recovery.py",
+ ):
+ if candidate.is_file():
+ return candidate
+ return None
+
+
+def ctl_path(tree: Path, prefix: Path | None, exe: Path) -> Path:
+ if prefix is not None:
+ sibling = exe.resolve().parent / "max25-ctl"
+ if sibling.is_file():
+ return sibling
+ script = tree / "scripts" / "max25-ctl"
+ if script.is_file():
+ return script
+ if prefix is not None:
+ return prefix / "bin" / "max25-ctl"
+ return script
+
+
+def default_ini_candidates(tree: Path, prefix: Path | None) -> list[Path]:
+ candidates: list[Path] = []
+ env_ini = os.environ.get("MAX25D_INI", "").strip()
+ if env_ini:
+ candidates.append(Path(env_ini))
+ candidates.append(Path("/etc/max25/max25d.ini"))
+ candidates.append(share_max25_dir(tree, prefix) / "max25d.ini.example")
+ if sys.platform.startswith("freebsd"):
+ candidates.append(share_max25_dir(tree, prefix) / "max25d.freebsd.ini.example")
+ if prefix is None or tree != prefix:
+ candidates.append(tree / _SHARE_MAX25 / "max25d.ini.example")
+ return candidates
+
+
+def serial_env_candidates(device_id: str, tree: Path, prefix: Path | None) -> list[Path]:
+ paths: list[Path] = [Path(f"/etc/max25/{device_id}-serial.env")]
+ env_root = os.environ.get("MAX25_ROOT", "").strip()
+ if env_root:
+ paths.append(Path(env_root) / "local" / f"{device_id}-serial.env")
+ paths.append(Path(env_root) / _STACKS / "tncs" / f"{device_id}-serial.env")
+ cwd = os.environ.get("PWD", "").strip()
+ if cwd:
+ paths.append(Path(cwd) / "local" / f"{device_id}-serial.env")
+ if prefix is not None:
+ paths.append(prefix / _SHARE_MAX25 / "serial" / f"{device_id}-serial.env")
+ paths.append(prefix / _SHARE_MAX25 / _STACKS / "tncs" / f"{device_id}-serial.env")
+ paths.append(tree / "local" / f"{device_id}-serial.env")
+ paths.append(tree / _STACKS / "tncs" / f"{device_id}-serial.env")
+ return paths
+
+
+def baycom_share_dir(tree: Path, prefix: Path | None) -> Path:
+ if prefix is not None:
+ installed = prefix / _SHARE_MAX25 / "baycom"
+ if installed.is_dir():
+ return installed
+ return tree / "share" / "baycom"
+
+
+_SITE_BAYCOM_INI = Path("/etc/baycom/baycom-pr.ini")
+_SINGLE_SER12_EXAMPLE = "baycom-pr.pccom-ttyS0-only.ini.example"
+
+
+def is_dual_baycom_ini(path: Path) -> bool:
+ """True when profile lists more than one modem or name is 'dual'."""
+ if not path.is_file():
+ return False
+ cp = configparser.ConfigParser()
+ try:
+ cp.read(path, encoding="utf-8")
+ except OSError:
+ return False
+ if "profile" not in cp:
+ return False
+ prof = cp["profile"]
+ name = prof.get("name", "").strip().lower()
+ modem_ids = [x.strip() for x in prof.get("modems", "").split(",") if x.strip()]
+ if len(modem_ids) > 1:
+ return True
+ return name == "dual"
+
+
+def canonical_dual_baycom_example(
+ device_id: str, tree: Path, prefix: Path | None
+) -> Path | None:
+ """Shipped dual-modem template for kernel-ser12 service mode."""
+ if device_id not in ("baycom-ser12", "baycom-par96"):
+ return None
+ for candidate in (
+ tree / _STACKS / "baycom-pr" / "config" / "examples" / "baycom-pr.dual.ini",
+ stacks_dir(tree, prefix) / "baycom-pr" / "config" / "examples" / "baycom-pr.dual.ini",
+ ):
+ if candidate.is_file():
+ return candidate
+ site = _SITE_BAYCOM_INI
+ if site.is_file() and is_dual_baycom_ini(site):
+ return site
+ return None
+
+
+def canonical_single_baycom_example(
+ device_id: str, tree: Path, prefix: Path | None
+) -> Path | None:
+ """Shipped single-modem template for kernel BayCom devices."""
+ share = baycom_share_dir(tree, prefix)
+ if device_id == "baycom-par96":
+ for candidate in (
+ share / "baycom-pr.par96-single.ini.example",
+ tree / _STACKS / "baycom-pr" / "config" / "examples" / "baycom-pr.par96.ini",
+ stacks_dir(tree, prefix) / "baycom-pr" / "config" / "examples" / "baycom-pr.par96.ini",
+ ):
+ if candidate.is_file():
+ return candidate
+ return None
+ ser12 = share / _SINGLE_SER12_EXAMPLE
+ if ser12.is_file():
+ return ser12
+ return None
+
+
+def baycom_ini_candidates(device_id: str, tree: Path, prefix: Path | None) -> list[Path]:
+ """Search order for baycom-pr.ini (local → site single → shipped example)."""
+ paths: list[Path] = []
+ env_ini = os.environ.get("BAYCOM_INI", "").strip()
+ if env_ini:
+ paths.append(Path(env_ini))
+ env_root = os.environ.get("MAX25_ROOT", "").strip()
+ if env_root:
+ paths.append(Path(env_root) / "local" / "baycom-pr.ini")
+ cwd = os.environ.get("PWD", "").strip()
+ if cwd:
+ paths.append(Path(cwd) / "local" / "baycom-pr.ini")
+ paths.append(tree / "local" / "baycom-pr.ini")
+
+ # Site INI before shipped example so operator edits in /etc win when single-modem.
+ paths.append(_SITE_BAYCOM_INI)
+ paths.append(Path("/etc/baycom/baycom-pr-single.ini"))
+
+ canonical = canonical_single_baycom_example(device_id, tree, prefix)
+ if canonical is not None:
+ paths.append(canonical)
+
+ if prefix is not None:
+ paths.append(prefix / _SHARE_MAX25 / "baycom" / _SINGLE_SER12_EXAMPLE)
+ paths.append(prefix / "etc" / "baycom" / "baycom-pr.ini.example")
+
+ paths.append(tree / _STACKS / "baycom-pr" / "config" / "baycom-pr.ini")
+ return paths
+
+
+def resolve_baycom_profile(
+ profile: str,
+ device_id: str,
+ tree: Path,
+ prefix: Path | None,
+) -> Path | None:
+ """Resolve named profile: single (default) or dual (service mode)."""
+ name = profile.strip().lower()
+ if name == "dual":
+ return canonical_dual_baycom_example(device_id, tree, prefix)
+ if name in ("single", "default", ""):
+ return resolve_baycom_ini(device_id, tree, prefix)
+ return None
+
+
+def resolve_baycom_ini(
+ device_id: str,
+ tree: Path,
+ prefix: Path | None,
+ explicit: str = "",
+) -> Path | None:
+ """Return first existing baycom-pr.ini path, or None.
+
+ Skips dual-modem /etc/baycom/baycom-pr.ini unless explicit (or env BAYCOM_INI).
+ """
+ if explicit:
+ path = Path(explicit)
+ return path if path.is_file() else None
+ for candidate in baycom_ini_candidates(device_id, tree, prefix):
+ if not candidate.is_file():
+ continue
+ if candidate == _SITE_BAYCOM_INI and is_dual_baycom_ini(candidate):
+ continue
+ return candidate
+ return None
diff --git a/stacks/daemon/privilege_drop.py b/stacks/daemon/privilege_drop.py
new file mode 100644
index 0000000..5e68ca8
--- /dev/null
+++ b/stacks/daemon/privilege_drop.py
@@ -0,0 +1,110 @@
+"""Drop max25d privileges after hardware stack is up (Linux)."""
+from __future__ import annotations
+
+import configparser
+import grp
+import os
+import pwd
+import sys
+from dataclasses import dataclass
+from typing import Optional
+
+
+@dataclass
+class RunAsConfig:
+ user: str = ""
+ group: str = ""
+ uid: Optional[int] = None
+ gid: Optional[int] = None
+
+
+def _parse_optional_int(raw: str) -> Optional[int]:
+ text = str(raw).strip()
+ if not text:
+ return None
+ return int(text)
+
+
+def parse_run_as(cp: configparser.ConfigParser) -> RunAsConfig:
+ cfg = RunAsConfig()
+ if not cp.has_section("daemon"):
+ return cfg
+ sec = cp["daemon"]
+ cfg.user = sec.get("user", fallback="").strip()
+ cfg.group = sec.get("group", fallback="").strip()
+ if cp.has_option("daemon", "uid"):
+ cfg.uid = _parse_optional_int(sec.get("uid", fallback=""))
+ if cp.has_option("daemon", "gid"):
+ cfg.gid = _parse_optional_int(sec.get("gid", fallback=""))
+ return cfg
+
+
+def run_as_configured(cfg: RunAsConfig) -> bool:
+ return bool(
+ cfg.user
+ or cfg.group
+ or cfg.uid is not None
+ or cfg.gid is not None
+ )
+
+
+def resolve_run_as(cfg: RunAsConfig) -> tuple[int, int, str]:
+ pw: pwd.struct_passwd | None = None
+ name = cfg.user
+
+ if cfg.user:
+ pw = pwd.getpwnam(cfg.user)
+ uid = pw.pw_uid
+ gid = pw.pw_gid
+ elif cfg.uid is not None:
+ pw = pwd.getpwuid(cfg.uid)
+ uid = pw.pw_uid
+ gid = pw.pw_gid
+ name = pw.pw_name
+ else:
+ raise ValueError("[daemon] user= or uid= required for privilege drop")
+
+ if cfg.uid is not None:
+ uid = cfg.uid
+ if cfg.group:
+ gid = grp.getgrnam(cfg.group).gr_gid
+ elif cfg.gid is not None:
+ gid = cfg.gid
+
+ return uid, gid, name
+
+
+def drop_privileges(cfg: RunAsConfig, *, log) -> None:
+ if os.name != "posix":
+ return
+ if not run_as_configured(cfg):
+ if os.geteuid() == 0:
+ log(
+ "running as root after stack start — set [daemon] user= to drop",
+ area="privilege",
+ )
+ return
+ if os.geteuid() != 0:
+ return
+
+ uid, gid, name = resolve_run_as(cfg)
+
+ os.initgroups(name, gid)
+ os.setgid(gid)
+ os.setuid(uid)
+
+ if os.geteuid() == 0:
+ raise OSError("privilege drop failed — still root")
+
+ log(
+ f"dropped privileges to uid={uid} gid={gid} ({name})",
+ area="privilege",
+ )
+
+
+def drop_privileges_or_exit(cfg: RunAsConfig, *, log) -> None:
+ try:
+ drop_privileges(cfg, log=log)
+ except (KeyError, ValueError, OSError) as exc:
+ log(f"privilege drop failed: {exc}", area="privilege")
+ raise SystemExit(1) from exc
diff --git a/stacks/daemon/reporting_quality.py b/stacks/daemon/reporting_quality.py
new file mode 100644
index 0000000..f755f08
--- /dev/null
+++ b/stacks/daemon/reporting_quality.py
@@ -0,0 +1,178 @@
+"""RX data-quality window for max25d STATUS error=/voice= reporting."""
+from __future__ import annotations
+
+import re
+import time
+from collections import deque
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Deque, Optional
+
+_AX25_UI_LINE_RE = re.compile(
+ r"^\[(?:CRDOP )?AX25 UI ([^>]+)>([^\]]+)\]\s?(.*)$",
+ re.DOTALL,
+)
+_AX25_UI_PARTIAL_RE = re.compile(r"\[AX25 UI ", re.IGNORECASE)
+_VOICE_NOISE_LINE_RE = re.compile(
+ r"^\[(?:CRDOP RX|AUDIO RX|VOICE)[^\]]*\]",
+ re.IGNORECASE,
+)
+
+
+class RxOutcome(str, Enum):
+ GOOD = "good"
+ BAD = "bad"
+ IGNORE = "ignore"
+
+
+def classify_rx_line(line: str, callid: str) -> RxOutcome:
+ """
+ Classify an RX display line for data-quality tracking.
+
+ - GOOD: complete AX.25 UI frame whose destination matches configured CALLID.
+ - BAD: incomplete AX.25 UI or wrong CALLID (incomplete data counts as error).
+ - IGNORE: voice/noise/interference — excluded from data pass statistics.
+ """
+ text = line.strip()
+ if not text:
+ return RxOutcome.IGNORE
+
+ match = _AX25_UI_LINE_RE.match(text)
+ if match:
+ dest = match.group(2).strip().upper()
+ expected = callid.strip().upper()
+ if not expected or dest != expected:
+ return RxOutcome.BAD
+ return RxOutcome.GOOD
+
+ if _AX25_UI_PARTIAL_RE.search(text):
+ return RxOutcome.BAD
+
+ if _VOICE_NOISE_LINE_RE.match(text):
+ return RxOutcome.IGNORE
+
+ return RxOutcome.IGNORE
+
+
+@dataclass
+class _PassWindow:
+ good: int = 0
+ bad: int = 0
+ ignored: bool = False
+ started_at: float = 0.0
+
+
+@dataclass
+class DataQualityTracker:
+ """
+ Rolling pass window: each pass lasts up to pass_window_sec (default 20s).
+ passes_required consecutive passes (default 3) form one evaluation cycle
+ (3 × max 20s = up to 60s). Voice/noise does not fill a pass slot.
+ """
+
+ passes_required: int = 3
+ min_good_percent: int = 50
+ pass_window_sec: int = 20
+ passes: Deque[str] = field(default_factory=deque)
+ voice_activity: bool = False
+ current: _PassWindow = field(default_factory=_PassWindow)
+
+ def __post_init__(self) -> None:
+ if self.passes_required < 1:
+ self.passes_required = 1
+ if self.min_good_percent < 1:
+ self.min_good_percent = 1
+ elif self.min_good_percent > 100:
+ self.min_good_percent = 100
+ if self.pass_window_sec < 1:
+ self.pass_window_sec = 1
+ elif self.pass_window_sec > 300:
+ self.pass_window_sec = 300
+ self.passes = deque(maxlen=self.passes_required)
+
+ def _now(self, now: Optional[float]) -> float:
+ return time.time() if now is None else now
+
+ def _ensure_window(self, now: float) -> None:
+ if self.current.started_at == 0.0:
+ self.current.started_at = now
+
+ def _pass_outcome_from_counts(self, good: int, bad: int) -> str:
+ if bad > 0:
+ return "bad"
+ if good > 0:
+ return "good"
+ return "bad"
+
+ def _finalize_current_pass(self) -> None:
+ good = self.current.good
+ bad = self.current.bad
+ if good == 0 and bad == 0:
+ if self.current.ignored:
+ return
+ self.passes.append("bad")
+ return
+ self.passes.append(self._pass_outcome_from_counts(good, bad))
+
+ def _roll_window(self, now: float) -> None:
+ self._finalize_current_pass()
+ self.current = _PassWindow(started_at=now)
+
+ def tick(self, now: Optional[float] = None) -> None:
+ """Expire the active pass when pass_window_sec elapsed."""
+ ts = self._now(now)
+ if self.current.started_at == 0.0:
+ return
+ if ts - self.current.started_at < self.pass_window_sec:
+ return
+ self._roll_window(ts)
+
+ def record(self, outcome: RxOutcome, now: Optional[float] = None) -> None:
+ ts = self._now(now)
+ self.tick(ts)
+ self._ensure_window(ts)
+
+ if outcome == RxOutcome.IGNORE:
+ self.voice_activity = True
+ self.current.ignored = True
+ return
+ if outcome == RxOutcome.GOOD:
+ self.current.good += 1
+ elif outcome == RxOutcome.BAD:
+ self.current.bad += 1
+
+ def record_bad(self, now: Optional[float] = None) -> None:
+ self.record(RxOutcome.BAD, now)
+
+ def good_ratio_percent(self) -> Optional[int]:
+ if len(self.passes) < self.passes_required:
+ return None
+ good = sum(1 for item in self.passes if item == "good")
+ total = len(self.passes)
+ if total == 0:
+ return None
+ return (100 * good) // total
+
+ def data_error_valid(self, *, reporting_enabled: bool) -> bool:
+ if not reporting_enabled:
+ return False
+ ratio = self.good_ratio_percent()
+ if ratio is None:
+ return False
+ return ratio >= self.min_good_percent
+
+ def voice_signal_valid(self, *, reporting_enabled: bool, link_healthy: bool) -> bool:
+ """
+ Voice/noise/disturbance path: when link is healthy and voice activity was
+ seen (non-data RX), treat as 100% valid even if the content was noise only.
+ """
+ if not reporting_enabled:
+ return False
+ if not link_healthy:
+ return False
+ if self.voice_activity:
+ return True
+ return True
+
+ def max_cycle_seconds(self) -> int:
+ return self.passes_required * self.pass_window_sec
diff --git a/stacks/daemon/test_audio_dummy_backend.py b/stacks/daemon/test_audio_dummy_backend.py
new file mode 100644
index 0000000..c5ba31d
--- /dev/null
+++ b/stacks/daemon/test_audio_dummy_backend.py
@@ -0,0 +1,49 @@
+#!/usr/bin/env python3
+"""Acoustic engine loopback + audio-dummy backend smoke tests."""
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+from unittest.mock import MagicMock
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT / "stacks" / "daemon"))
+sys.path.insert(0, str(ROOT / "stacks" / "crdop" / "lib"))
+
+from acoustic_engine import AcousticEngine # noqa: E402
+from device_backends import AudioDummyBackend, DeviceBackendConfig, registry_tested # noqa: E402
+
+
+def test_loopback_decode() -> None:
+ eng = AcousticEngine()
+ rep = eng.loopback_self_test("TST-0", "QST")
+ assert rep.symbols > 0
+ assert rep.transitions > 0
+
+
+def test_audio_dummy_backend_loopback_tx() -> None:
+ cfg = DeviceBackendConfig(device_id="audio-dummy", backend_type="audio-dummy", audio_mode="loopback")
+ rx: list[str] = []
+ backend = AudioDummyBackend(cfg, on_rx=rx.append)
+ assert backend.open()
+ assert backend.attach_session("TST-0")
+ ok, display = backend.transmit("TST-0", "QST", "hi", ax25_ui=True)
+ assert ok
+ assert "QST" in display
+ backend.close()
+
+
+def test_registry() -> None:
+ assert registry_tested("audio-dummy") is True
+
+
+def main() -> int:
+ test_loopback_decode()
+ test_audio_dummy_backend_loopback_tx()
+ test_registry()
+ print("OK: audio-dummy tests")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/stacks/daemon/test_auth.py b/stacks/daemon/test_auth.py
new file mode 100644
index 0000000..263a2e5
--- /dev/null
+++ b/stacks/daemon/test_auth.py
@@ -0,0 +1,95 @@
+#!/usr/bin/env python3
+"""TCP auth smoke test for max25d M25/1."""
+import socket
+import subprocess
+import sys
+import tempfile
+import time
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+DAEMON = ROOT / "stacks/daemon/max25d"
+BASE_INI = ROOT / "share/max25/max25d.ini.example"
+
+
+class LineReader:
+ def __init__(self, sock: socket.socket) -> None:
+ self.sock = sock
+ self.buf = b""
+
+ def read(self, timeout: float = 5.0) -> str:
+ self.sock.settimeout(timeout)
+ while b"\n" not in self.buf:
+ chunk = self.sock.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")
+
+
+def free_port() -> int:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ sock.bind(("127.0.0.1", 0))
+ port = sock.getsockname()[1]
+ sock.close()
+ return port
+
+
+def main() -> int:
+ port = free_port()
+ password = "preview-secret"
+
+ with tempfile.NamedTemporaryFile("w", suffix=".ini", delete=False) as tmp:
+ tmp.write(BASE_INI.read_text())
+ tmp.write(f"\n[network]\ntcp_password = {password}\n")
+ ini_path = Path(tmp.name)
+
+ proc = subprocess.Popen(
+ [str(DAEMON), "--no-stack", "-c", str(ini_path), "--tcp-port", str(port)],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.PIPE,
+ text=True,
+ )
+ time.sleep(0.6)
+ try:
+ sock = socket.create_connection(("127.0.0.1", port), timeout=3)
+ except OSError as exc:
+ proc.terminate()
+ proc.wait(timeout=3)
+ print(f"FAIL: connect: {exc}", file=sys.stderr)
+ return 1
+
+ reader = LineReader(sock)
+ try:
+ assert reader.read() == "AUTH required"
+
+ sock.sendall(b"AUTH wrong\n")
+ assert reader.read() == "ERR auth failed"
+ sock.close()
+
+ sock = socket.create_connection(("127.0.0.1", port), timeout=3)
+ reader = LineReader(sock)
+ assert reader.read() == "AUTH required"
+ sock.sendall(f"AUTH {password}\n".encode())
+ assert reader.read() == "OK"
+ status = reader.read()
+ assert status.startswith("STATUS ")
+
+ sock.sendall(b"PING\n")
+ assert reader.read() == "OK"
+
+ print("OK: max25d TCP auth smoke")
+ return 0
+ finally:
+ try:
+ sock.close()
+ except OSError:
+ pass
+ proc.terminate()
+ proc.wait(timeout=5)
+ ini_path.unlink(missing_ok=True)
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/stacks/daemon/test_ax25_codec.py b/stacks/daemon/test_ax25_codec.py
new file mode 100644
index 0000000..b12e43d
--- /dev/null
+++ b/stacks/daemon/test_ax25_codec.py
@@ -0,0 +1,97 @@
+#!/usr/bin/env python3
+"""Unit tests for ax25_codec (RFC 1171 FCS + libax25 address rules)."""
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+from ax25_codec import ( # noqa: E402
+ ax25_build_ui,
+ ax25_crc,
+ ax25_crc_valid,
+ ax25_encode_address,
+ ax25_parse_ui,
+ format_callsign,
+ parse_callsign,
+ validate_callsign,
+)
+
+
+def test_rfc1171_crc_vector() -> None:
+ body = bytes(range(256))
+ crc = ax25_crc(body)
+ frame = body + bytes((crc & 0xFF, crc >> 8))
+ assert ax25_crc_valid(frame)
+ assert crc == 0x303C
+
+
+def test_address_roundtrip() -> None:
+ raw = ax25_encode_address("DG1ABC", 7, last=True)
+ frame = ax25_build_ui("DG1ABC-7", "CQ", b"73")
+ parsed = ax25_parse_ui(frame)
+ assert parsed == ("DG1ABC-7", "CQ", b"73")
+
+
+def test_format_callsign_omit_zero_ssid() -> None:
+ assert format_callsign("DG1ABC", 0) == "DG1ABC"
+ assert format_callsign("CB", 0) == "CB"
+
+
+def test_parse_callsign_legacy() -> None:
+ assert parse_callsign("DK0WC-7") == ("DK0WC", 7)
+ assert parse_callsign("bad-ssid") == ("BAD", 0)
+
+
+def test_validate_callsign_strict() -> None:
+ assert validate_callsign("CB-0") == ("CB", 0)
+ assert validate_callsign("N0CALL-15") == ("N0CALL", 15)
+ try:
+ validate_callsign("CB_0")
+ raise AssertionError("expected ValueError")
+ except ValueError:
+ pass
+
+
+def test_ui_frame_without_fcs() -> None:
+ frame = ax25_build_ui("DG1ABC", "QST", b"hi")
+ body = frame[:-2]
+ parsed = ax25_parse_ui(body)
+ assert parsed == ("DG1ABC", "QST", b"hi")
+
+
+def test_invalid_callsign_build() -> None:
+ try:
+ ax25_build_ui("BAD_CALL", "CQ", b"x")
+ raise AssertionError("expected ValueError")
+ except ValueError:
+ pass
+
+
+def test_non_ui_rejected() -> None:
+ frame = ax25_build_ui("A", "B", b"x")
+ frame = bytearray(frame)
+ frame[14] = 0x00 # not UI control
+ assert ax25_parse_ui(bytes(frame)) is None
+
+
+def main() -> int:
+ tests = [
+ test_rfc1171_crc_vector,
+ test_address_roundtrip,
+ test_format_callsign_omit_zero_ssid,
+ test_parse_callsign_legacy,
+ test_validate_callsign_strict,
+ test_ui_frame_without_fcs,
+ test_invalid_callsign_build,
+ test_non_ui_rejected,
+ ]
+ for fn in tests:
+ fn()
+ print("OK: ax25_codec unit tests")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/stacks/daemon/test_bans.py b/stacks/daemon/test_bans.py
new file mode 100644
index 0000000..80176ae
--- /dev/null
+++ b/stacks/daemon/test_bans.py
@@ -0,0 +1,199 @@
+#!/usr/bin/env python3
+"""Unit and protocol tests for max25d AX.25 source ban list."""
+from __future__ import annotations
+
+import importlib.util
+import socket
+import subprocess
+import sys
+import tempfile
+import time
+from pathlib import Path
+from unittest.mock import patch
+
+ROOT = Path(__file__).resolve().parents[2]
+LAUNCHER = ROOT / "stacks/daemon/max25d"
+DAEMON = ROOT / "stacks/daemon/max25d.py"
+
+sys.path.insert(0, str(DAEMON.parent))
+from banlist import BanList, callsign_banned, extract_ax25_source # noqa: E402
+
+
+def load_max25d_module():
+ from importlib.machinery import SourceFileLoader
+
+ loader = SourceFileLoader("max25d", str(DAEMON))
+ spec = importlib.util.spec_from_loader("max25d", loader)
+ mod = importlib.util.module_from_spec(spec)
+ sys.modules["max25d"] = mod
+ loader.exec_module(mod)
+ return mod
+
+
+class LineReader:
+ def __init__(self, sock: socket.socket) -> None:
+ self.sock = sock
+ self.buf = b""
+
+ def read(self, timeout: float = 5.0) -> str:
+ self.sock.settimeout(timeout)
+ while b"\n" not in self.buf:
+ chunk = self.sock.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")
+
+
+def free_port() -> int:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ sock.bind(("127.0.0.1", 0))
+ port = sock.getsockname()[1]
+ sock.close()
+ return port
+
+
+def test_extract_ax25_source() -> None:
+ assert extract_ax25_source("[AX25 UI DG1ABC>QST] 73") == "DG1ABC"
+ assert extract_ax25_source("[AX25 UI DK0WC-7>CB-0] hi") == "DK0WC-7"
+ assert extract_ax25_source("plain text") is None
+ assert extract_ax25_source("[CRDOP RX soft-crdop] hello") is None
+
+
+def test_callsign_banned_matching() -> None:
+ assert callsign_banned("DG1ABC", "DG1ABC")
+ assert callsign_banned("DG1ABC", "DG1ABC-7")
+ assert not callsign_banned("DG1ABC", "DG1ABD")
+ assert callsign_banned("DK0WC-7", "DK0WC-7")
+ assert not callsign_banned("DK0WC-7", "DK0WC")
+ assert not callsign_banned("DK0WC-7", "DK0WC-8")
+
+
+def test_banlist_file_roundtrip() -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "bans.txt"
+ path.write_text("# ignored comment\nDG1ABC\n\nDK0WC-7\n", encoding="utf-8")
+ bans = BanList(path)
+ assert bans.list() == ["DG1ABC", "DK0WC-7"]
+ assert bans.is_banned("DG1ABC-3")
+ assert bans.is_banned("DK0WC-7")
+ assert not bans.is_banned("DK0WC")
+
+ bans.add("DL1TEST")
+ bans.remove("DG1ABC")
+ assert bans.list() == ["DK0WC-7", "DL1TEST"]
+ reloaded = BanList(path)
+ assert reloaded.list() == ["DK0WC-7", "DL1TEST"]
+
+
+def test_on_backend_rx_silent_drop() -> None:
+ mod = load_max25d_module()
+ with tempfile.TemporaryDirectory() as tmp:
+ bans_path = Path(tmp) / "bans.txt"
+ bans_path.write_text("DG1ABC\n", encoding="utf-8")
+ cfg = mod.DaemonConfig(bans_file=str(bans_path))
+ state = mod.DaemonState(cfg=cfg, bans=BanList(bans_path))
+ mod.init_device_runtimes(state)
+
+ sent: list[str] = []
+
+ def capture(_state, line, skip=None):
+ sent.append(line)
+
+ with patch.object(mod, "broadcast", side_effect=capture), patch.object(mod, "log"):
+ mod.on_backend_rx(state, "tnc2c", "[AX25 UI DG1ABC>QST] blocked")
+ assert sent == []
+
+ mod.on_backend_rx(state, "tnc2c", "[AX25 UI DK0WC>QST] allowed")
+ assert sent == ["RX device=tnc2c [AX25 UI DK0WC>QST] allowed"]
+
+
+def test_ban_commands_protocol() -> None:
+ port = free_port()
+ with tempfile.TemporaryDirectory() as tmp:
+ bans_path = Path(tmp) / "bans.txt"
+ ini_path = Path(tmp) / "max25d.ini"
+ ini_path.write_text(
+ f"""
+[daemon]
+device = tnc2c
+hardware = tncs
+[network]
+tcp_host = 127.0.0.1
+tcp_port = {port}
+[modem]
+bans_file = {bans_path}
+[stack]
+auto_start = no
+""",
+ encoding="utf-8",
+ )
+
+ proc = subprocess.Popen(
+ [
+ str(LAUNCHER),
+ "--no-stack",
+ "--no-serial",
+ "-c",
+ str(ini_path),
+ "--tcp-port",
+ str(port),
+ ],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.PIPE,
+ text=True,
+ )
+ time.sleep(0.6)
+ try:
+ sock = socket.create_connection(("127.0.0.1", port), timeout=3)
+ except OSError as exc:
+ proc.terminate()
+ proc.wait(timeout=3)
+ raise RuntimeError(f"connect failed: {exc}") from exc
+
+ reader = LineReader(sock)
+ try:
+ assert reader.read() == "OK"
+ reader.read() # STATUS
+
+ sock.sendall(b"BAN DG1ABC\n")
+ assert reader.read() == "OK"
+ assert bans_path.read_text(encoding="utf-8").strip() == "DG1ABC"
+
+ sock.sendall(b"BANS\n")
+ assert reader.read() == "BAN DG1ABC"
+ assert reader.read() == "OK"
+
+ sock.sendall(b"UNBAN DG1ABC\n")
+ assert reader.read() == "OK"
+ assert bans_path.read_text(encoding="utf-8") == ""
+
+ sock.sendall(b"UNBAN DG1ABC\n")
+ assert reader.read() == "ERR not banned"
+
+ sock.sendall(b"BAN BAD!\n")
+ assert reader.read() == "ERR invalid callsign"
+ finally:
+ sock.close()
+ proc.terminate()
+ proc.wait(timeout=5)
+
+
+def main() -> int:
+ tests = [
+ test_extract_ax25_source,
+ test_callsign_banned_matching,
+ test_banlist_file_roundtrip,
+ test_on_backend_rx_silent_drop,
+ test_ban_commands_protocol,
+ ]
+ for test in tests:
+ test()
+ print(f"OK: {test.__name__}")
+ print("OK: banlist tests")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/stacks/daemon/test_baycom_backend.py b/stacks/daemon/test_baycom_backend.py
new file mode 100644
index 0000000..ebff5a4
--- /dev/null
+++ b/stacks/daemon/test_baycom_backend.py
@@ -0,0 +1,95 @@
+#!/usr/bin/env python3
+"""Offline unit tests for BayComKissBackend (no kernel hardware)."""
+from __future__ import annotations
+
+import os
+import sys
+from pathlib import Path
+from unittest.mock import patch
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT / "stacks" / "daemon"))
+
+from device_backends import BayComKissBackend, DeviceBackendConfig # noqa: E402
+from ax25_codec import ax25_build_ui, ax25_parse_ui # noqa: E402
+from kiss_bridge import KissDecoder, format_rx_line, kiss_data_frame # noqa: E402
+
+
+def test_baycom_kiss_default_link() -> None:
+ cfg = DeviceBackendConfig(device_id="baycom-ser12", backend_type="baycom-kiss", baycom_modem="a")
+ backend = BayComKissBackend(cfg, on_rx=lambda _l: None)
+ assert backend._path in ("/var/run/baycom-pr/kiss", f"/var/run/baycom-pr/kiss-a")
+ assert backend.backend_type == "baycom-kiss"
+ assert backend._is_pty is True
+
+
+def test_baycom_kiss_transmit_kiss_frame() -> None:
+ cfg = DeviceBackendConfig(
+ device_id="baycom-ser12",
+ backend_type="baycom-kiss",
+ kiss_link="/var/run/baycom-pr/kiss",
+ )
+ backend = BayComKissBackend(cfg, on_rx=lambda _l: None)
+ backend._fd = 99
+ backend._kiss_active = True
+ written: list[bytes] = []
+
+ with patch("os.write", side_effect=lambda _fd, data: written.append(data) or len(data)):
+ with patch("termios.tcdrain"):
+ ok, display = backend.transmit("CB-0", "QST", "hello", ax25_ui=True)
+ assert ok, display
+ assert written and written[0].startswith(b"\xc0")
+ assert "hello" in display
+
+
+def test_baycom_kiss_rx_decode_path() -> None:
+ """RX path: KISS bytes → AX.25 UI → on_rx line (same decoder as _rx_loop)."""
+ rx_lines: list[str] = []
+ frame = ax25_build_ui("REMOTE-0", "CB-0", b"73")
+ pkt = kiss_data_frame(0, frame)
+ decoder = KissDecoder()
+ for _port, payload in decoder.feed(pkt):
+ parsed = ax25_parse_ui(payload)
+ assert parsed is not None
+ src, dst, info = parsed
+ assert src == "REMOTE"
+ assert dst == "CB"
+ assert info == b"73"
+ from kiss_bridge import format_rx_line
+
+ rx_lines.append(format_rx_line(src, dst, info, ax25_ui=True))
+ assert rx_lines and "73" in rx_lines[0]
+
+
+def test_baycom_kiss_modem_b_link() -> None:
+ cfg = DeviceBackendConfig(
+ device_id="baycom-b",
+ backend_type="baycom-kiss",
+ baycom_modem="b",
+ kiss_link="/var/run/baycom-pr/kiss-b",
+ )
+ backend = BayComKissBackend(cfg, on_rx=lambda _l: None)
+ assert backend._path == "/var/run/baycom-pr/kiss-b"
+
+
+def test_baycom_kiss_open_missing_path() -> None:
+ backend = BayComKissBackend(
+ DeviceBackendConfig(device_id="baycom-ser12", kiss_link="/nonexistent/kiss/path"),
+ on_rx=lambda _l: None,
+ )
+ assert not backend.open()
+ assert backend.status == "error-no-device"
+
+
+def main() -> int:
+ test_baycom_kiss_default_link()
+ test_baycom_kiss_modem_b_link()
+ test_baycom_kiss_transmit_kiss_frame()
+ test_baycom_kiss_rx_decode_path()
+ test_baycom_kiss_open_missing_path()
+ print("OK: baycom backend tests")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/stacks/daemon/test_bcpr_backend.py b/stacks/daemon/test_bcpr_backend.py
new file mode 100644
index 0000000..4d196ad
--- /dev/null
+++ b/stacks/daemon/test_bcpr_backend.py
@@ -0,0 +1,194 @@
+#!/usr/bin/env python3
+"""Offline unit tests for bcpr device parse + BcprKissBackend (no UART)."""
+from __future__ import annotations
+
+import configparser
+import importlib.util
+import sys
+from pathlib import Path
+from unittest.mock import patch
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT / "stacks" / "daemon"))
+
+from device_backends import ( # noqa: E402
+ BcprKissBackend,
+ DeviceBackendConfig,
+ parse_device_spec,
+)
+
+
+def load_max25d():
+ from importlib.machinery import SourceFileLoader
+
+ loader = SourceFileLoader("max25d", str(ROOT / "stacks" / "daemon" / "max25d.py"))
+ spec = importlib.util.spec_from_loader("max25d", loader)
+ mod = importlib.util.module_from_spec(spec)
+ sys.modules["max25d"] = mod
+ loader.exec_module(mod)
+ return mod
+
+
+def test_bcpr_kiss_default_link() -> None:
+ cfg = DeviceBackendConfig(
+ device_id="max25e0",
+ backend_type="max25-bcpr-kiss",
+ bcpr_device="bc0",
+ )
+ backend = BcprKissBackend(cfg, on_rx=lambda _l: None)
+ assert backend._path == "/tmp/max25-bcpr/kiss-bc0"
+ assert backend.backend_type == "max25-bcpr-kiss"
+ assert backend._is_pty is True
+
+
+def test_max25e0_default_addrs() -> None:
+ from device_backends import MAX25E0_DEFAULT_IPV4, MAX25E0_DEFAULT_IPV6
+ text = """
+[devices]
+max25e0 = max25-bcpr:bc0
+"""
+ cp = configparser.ConfigParser()
+ cp.read_string(text)
+ from device_backends import parse_device_spec
+ dev = parse_device_spec("max25e0", "max25-bcpr:bc0", cp, {"hardware": "modems"})
+ assert dev.ipv4 == MAX25E0_DEFAULT_IPV4
+ assert dev.ipv6 == MAX25E0_DEFAULT_IPV6
+ # override
+ text2 = """
+[devices]
+max25e0 = max25-bcpr:bc0
+[device.max25e0]
+ipv4 = 10.0.0.25/24
+ipv6 = fd00::25/128
+"""
+ cp2 = configparser.ConfigParser()
+ cp2.read_string(text2)
+ dev2 = parse_device_spec("max25e0", "max25-bcpr:bc0", cp2, {"hardware": "modems"})
+ assert dev2.ipv4 == "10.0.0.25/24"
+ assert dev2.ipv6 == "fd00::25/128"
+ # fork inherits from [device.max25e0] (INI colon keys need care — parse by id)
+ text3 = """
+[device.max25e0]
+ipv4 = 10.0.0.25/24
+ipv6 = fd00::25/128
+"""
+ cp3 = configparser.ConfigParser()
+ cp3.read_string(text3)
+ fork = parse_device_spec("max25e0:bc1", "max25-bcpr:bc1", cp3, {"hardware": "modems"})
+ assert fork.ipv4 == "10.0.0.25/24"
+ assert fork.ipv6 == "fd00::25/128"
+
+
+def test_parse_bcpr_spec() -> None:
+ cp = configparser.ConfigParser()
+ cp.read_string(
+ """
+[device.max25e0]
+kiss_link = /var/run/max25-bcpr/kiss-bc0
+max25_bcpr_ini = /etc/max25/max25-bcpr.ini
+"""
+ )
+ dev = parse_device_spec("max25e0", "max25-bcpr:bc0", cp, {"hardware": "tncs"})
+ assert dev.backend_type == "max25-bcpr-kiss"
+ assert dev.max25_bcpr_device == "bc0"
+ assert dev.kiss_link == "/var/run/max25-bcpr/kiss-bc0"
+ assert dev.max25_bcpr_ini == "/etc/max25/max25-bcpr.ini"
+
+
+def test_feature_gate_bcpr() -> None:
+ mod = load_max25d()
+ cp = configparser.ConfigParser()
+ cp.read_string(
+ """
+[features]
+max25_bcpr = no
+[devices]
+default = max25e0
+max25e0 = max25-bcpr:bc0
+"""
+ )
+ cfg = mod.DaemonConfig()
+ cfg.feature_max25_bcpr = False
+ devices = mod.parse_devices(cp, cfg)
+ # parse_devices itself may include; load_config filters by features
+ assert any(d.device_id == "max25e0" for d in devices)
+ filtered = [d for d in devices if mod._device_allowed_by_features(d, cfg)]
+ assert filtered == []
+
+ cfg.feature_max25_bcpr = True
+ filtered = [d for d in devices if mod._device_allowed_by_features(d, cfg)]
+ assert len(filtered) == 1
+ assert filtered[0].backend_type == "max25-bcpr-kiss"
+
+
+def test_bcpr_kiss_pty_skips_tcdrain() -> None:
+ cfg = DeviceBackendConfig(
+ device_id="max25e0",
+ backend_type="max25-bcpr-kiss",
+ kiss_link="/tmp/max25-bcpr/kiss-bc0",
+ bcpr_device="bc0",
+ )
+ backend = BcprKissBackend(cfg, on_rx=lambda _l: None)
+ backend._fd = 99
+ backend._kiss_active = True
+ written: list[bytes] = []
+ drained = {"n": 0}
+
+ def fake_drain(_fd: int) -> None:
+ drained["n"] += 1
+
+ with patch("os.write", side_effect=lambda _fd, data: written.append(data) or len(data)):
+ with patch("termios.tcdrain", side_effect=fake_drain):
+ ok, display = backend.transmit("CB-0", "QST", "hello", ax25_ui=True)
+ assert ok, display
+ assert written and written[0].startswith(b"\xc0")
+ assert drained["n"] == 0
+
+
+def test_bcpr_kiss_stabilize_reopens_on_inode_mismatch() -> None:
+ cfg = DeviceBackendConfig(
+ device_id="max25e0",
+ backend_type="max25-bcpr-kiss",
+ kiss_link="/tmp/max25-bcpr/kiss-bc0",
+ bcpr_device="bc0",
+ )
+ backend = BcprKissBackend(cfg, on_rx=lambda _l: None)
+ backend._fd = 7
+ backend._kiss_active = True
+ backend.status = "ready"
+ backend._mycall = "CB-0"
+ calls: list[str] = []
+
+ class _St:
+ def __init__(self, ino: int) -> None:
+ self.st_ino = ino
+ self.st_dev = 1
+
+ with patch("os.path.exists", return_value=True):
+ with patch("os.stat", return_value=_St(200)):
+ with patch("os.fstat", return_value=_St(100)):
+ with patch.object(backend, "close", side_effect=lambda: calls.append("close")):
+ with patch.object(backend, "open", side_effect=lambda: calls.append("open") or True):
+ with patch.object(
+ backend,
+ "attach_session",
+ side_effect=lambda c: calls.append(f"attach:{c}") or True,
+ ):
+ ok = backend.stabilize_session("CB-0", force=False)
+ assert ok
+ assert calls == ["close", "open", "attach:CB-0"]
+
+
+def main() -> int:
+ test_bcpr_kiss_default_link()
+ test_max25e0_default_addrs()
+ test_parse_bcpr_spec()
+ test_feature_gate_bcpr()
+ test_bcpr_kiss_pty_skips_tcdrain()
+ test_bcpr_kiss_stabilize_reopens_on_inode_mismatch()
+ print("OK: bcpr backend tests")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/stacks/daemon/test_crdop_backend.py b/stacks/daemon/test_crdop_backend.py
new file mode 100644
index 0000000..a83f54b
--- /dev/null
+++ b/stacks/daemon/test_crdop_backend.py
@@ -0,0 +1,130 @@
+#!/usr/bin/env python3
+"""Offline unit tests for CrdopTcpBackend (no crdopc process)."""
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT / "stacks" / "daemon"))
+
+from device_backends import CrdopTcpBackend, DeviceBackendConfig # noqa: E402
+
+
+class _MockSock:
+ """Minimal socket double for ctrl/data channels."""
+
+ def __init__(self, term: str = "\n") -> None:
+ self.sent: list[bytes] = []
+ self._closed = False
+ self._term = term
+
+ def settimeout(self, _t: float) -> None:
+ return None
+
+ def sendall(self, data: bytes) -> None:
+ self.sent.append(data)
+
+ def recv(self, _n: int) -> bytes:
+ if self._closed:
+ return b""
+ return f"OK{self._term}".encode("ascii")
+
+ def close(self) -> None:
+ self._closed = True
+
+
+def _mock_connections(ctrl: _MockSock, data: _MockSock):
+ def _create(addr, timeout=5.0):
+ _host, port = addr
+ return ctrl if port == 8515 else data
+
+ return _create
+
+
+def _open_ctx(ctrl: _MockSock, data: _MockSock):
+ mock_thread = MagicMock()
+ return (
+ patch("device_backends.socket.create_connection", side_effect=_mock_connections(ctrl, data)),
+ patch("device_backends.threading.Thread", return_value=mock_thread),
+ )
+
+
+def test_crdop_backend_open_and_attach() -> None:
+ ctrl = _MockSock()
+ data = _MockSock()
+ cfg = DeviceBackendConfig(
+ device_id="soft-crdop",
+ backend_type="crdop-tcp",
+ crdop_host="127.0.0.1",
+ crdop_port=8515,
+ crdop_listen=True,
+ )
+ backend = CrdopTcpBackend(cfg, on_rx=lambda _l: None)
+ p_sock, p_thread = _open_ctx(ctrl, data)
+ with p_sock, p_thread:
+ assert backend.open()
+ assert backend.attach_session("CB-0")
+ assert backend.status == "ready"
+ joined = b"".join(ctrl.sent).decode("ascii")
+ assert "INITIALIZE" in joined
+ assert "PROTOCOLMODE KISS" in joined
+ assert "LISTEN TRUE" in joined
+ backend.close()
+ assert backend.status == "closed"
+
+
+def test_crdop_backend_transmit() -> None:
+ ctrl = _MockSock()
+ data = _MockSock()
+ cfg = DeviceBackendConfig(
+ device_id="soft-crdop",
+ backend_type="crdop-tcp",
+ crdop_host="127.0.0.1",
+ crdop_port=8515,
+ )
+ backend = CrdopTcpBackend(cfg, on_rx=lambda _l: None)
+ p_sock, p_thread = _open_ctx(ctrl, data)
+ with p_sock, p_thread:
+ assert backend.open()
+ assert backend.attach_session("CB-0")
+ ok, display = backend.transmit("CB-0", "QST", "hello", ax25_ui=True)
+ assert ok, display
+ assert len(data.sent) == 1
+ assert "FECSEND" not in b"".join(ctrl.sent).decode("ascii")
+ assert "[CRDOP AX25 UI CB-0>QST]" in display
+ backend.close()
+ assert backend.status == "closed"
+
+
+def test_crdop_backend_connect_failure() -> None:
+ cfg = DeviceBackendConfig(
+ device_id="soft-crdop",
+ backend_type="crdop-tcp",
+ crdop_host="127.0.0.1",
+ crdop_port=59999,
+ )
+ backend = CrdopTcpBackend(cfg, on_rx=lambda _l: None)
+ with patch("device_backends.socket.create_connection", side_effect=OSError("refused")):
+ assert not backend.open()
+ assert backend.status == "error-connect"
+
+
+def test_crdop_registry_tested() -> None:
+ from device_backends import registry_tested # noqa: E402
+
+ assert registry_tested("soft-crdop") is True
+
+
+def main() -> int:
+ test_crdop_backend_connect_failure()
+ test_crdop_backend_open_and_attach()
+ test_crdop_backend_transmit()
+ test_crdop_registry_tested()
+ print("OK: crdop backend tests")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/stacks/daemon/test_daemon_log.py b/stacks/daemon/test_daemon_log.py
new file mode 100644
index 0000000..0569ca1
--- /dev/null
+++ b/stacks/daemon/test_daemon_log.py
@@ -0,0 +1,99 @@
+#!/usr/bin/env python3
+"""Unit tests for max25d structured logging."""
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT / "stacks" / "daemon"))
+
+from daemon_log import DaemonLogger, emit_startup_banner # noqa: E402
+from device_backends import DeviceBackendConfig # noqa: E402
+
+
+def test_structured_levels() -> None:
+ lines: list[str] = []
+ log = DaemonLogger(emit=lines.append)
+ log.ok("prep complete", area="serial", device="tnc2c")
+ log.recovery("JHOST 0 — banner", device="pktnc2")
+ log.warn("no password", area="security")
+ assert lines[0] == "max25d [OK] [serial] [tnc2c] prep complete"
+ assert lines[1] == "max25d [RECOVERY] [serial] [pktnc2] JHOST 0 — banner"
+ assert lines[2] == "max25d [WARN] [security] no password"
+
+
+def test_emit_unstructured_device_prefix() -> None:
+ lines: list[str] = []
+ log = DaemonLogger(emit=lines.append)
+ log.emit_unstructured("tnc2c: recovery: OK after JHOST 0")
+ assert "[tnc2c]" in lines[0]
+ assert "[RECOVERY]" in lines[0]
+ assert "OK after JHOST 0" in lines[0]
+
+
+def test_startup_banner_sections() -> None:
+ from dataclasses import dataclass
+
+ @dataclass
+ class Cfg:
+ mode: str = "standalone"
+ device: str = "tnc2c"
+ default_device: str = "tnc2c"
+ tcp_host: str = "127.0.0.1"
+ tcp_port: int = 7325
+ unix_socket: str = "/run/max25/modem.sock"
+ tcp_password: str = ""
+ callerid: str = "CB-0"
+ callid: str = "QST"
+ ax25_ui: bool = True
+ bans_file: str = ""
+ auto_start: bool = True
+ serial_enabled: bool = True
+ stack_recover_only: bool = True
+ serial_watch: bool = True
+ serial_watch_interval: int = 60
+ serial_watch_startup_grace: int = 45
+ serial_bootwait_escalate: bool = True
+ hardware: str = "tncs"
+
+ lines: list[str] = []
+ from daemon_log import LOGGER
+
+ orig = LOGGER._emit
+ try:
+ LOGGER._emit = lines.append
+ dev = DeviceBackendConfig(device_id="tnc2c", backend_type="kiss-serial")
+ dev.serial_device = "/dev/ttyS4"
+ dev.serial_baud = 19200
+ dev.serial_line = "8n1"
+ emit_startup_banner(
+ config_path="/etc/max25/max25d.ini",
+ cfg=Cfg(),
+ devices=[dev],
+ tested_fn=lambda _d: True,
+ )
+ finally:
+ LOGGER._emit = orig
+
+ text = "\n".join(lines)
+ assert "=== MAX25d starting ===" in text
+ assert "[config]" in text
+ assert "ini=/etc/max25/max25d.ini" in text
+ assert "[devices]" in text
+ assert "tnc2c=" in text
+
+
+def main() -> int:
+ test_structured_levels()
+ print("OK test_structured_levels")
+ test_emit_unstructured_device_prefix()
+ print("OK test_emit_unstructured_device_prefix")
+ test_startup_banner_sections()
+ print("OK test_startup_banner_sections")
+ print("All daemon_log tests passed")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/stacks/daemon/test_kiss_bridge.py b/stacks/daemon/test_kiss_bridge.py
new file mode 100644
index 0000000..066d1d0
--- /dev/null
+++ b/stacks/daemon/test_kiss_bridge.py
@@ -0,0 +1,161 @@
+#!/usr/bin/env python3
+"""Offline unit tests for kiss_bridge (no serial hardware)."""
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+from ax25_codec import ax25_build_ui, ax25_crc, ax25_parse_ui, parse_callsign # noqa: E402
+from kiss_bridge import ( # noqa: E402
+ KissDecoder,
+ format_rx_line,
+ kiss_data_frame,
+ kiss_encode,
+ serial_profile_for_device,
+)
+
+
+def test_callsign_parse() -> None:
+ assert parse_callsign("DG1ABC") == ("DG1ABC", 0)
+ assert parse_callsign("DK0WC-7") == ("DK0WC", 7)
+
+
+def test_ax25_roundtrip() -> None:
+ frame = ax25_build_ui("DG1ABC", "CQ", b"73")
+ parsed = ax25_parse_ui(frame)
+ assert parsed is not None
+ src, dst, payload = parsed
+ assert src == "DG1ABC"
+ assert dst == "CQ"
+ assert payload == b"73"
+
+
+def test_kiss_fcs_strip() -> None:
+ frame = ax25_build_ui("CB-0", "QST", b"hi")
+ pkt = kiss_data_frame(0, frame)
+ assert pkt.startswith(b"\xc0")
+ assert pkt.endswith(b"\xc0")
+ dec = KissDecoder()
+ frames = dec.feed(pkt)
+ assert len(frames) == 1
+ _port, payload = frames[0]
+ assert len(payload) == len(frame) - 2
+ parsed = ax25_parse_ui(payload)
+ assert parsed is not None
+ src, dst, info = parsed
+ assert src == "CB"
+ assert dst == "QST"
+ assert info == b"hi"
+
+
+def test_kiss_escape_roundtrip() -> None:
+ raw = bytes([0xC0, 0xDB, 0x00, 0xFF])
+ pkt = kiss_encode(0, 0x00, raw)
+ dec = KissDecoder()
+ frames = dec.feed(pkt)
+ assert frames[0][1] == raw
+
+
+def test_format_rx() -> None:
+ line = format_rx_line("DG1ABC", "QST", b"73", ax25_ui=True)
+ assert line == "[AX25 UI DG1ABC>QST] 73"
+
+
+def test_serial_profile_tnc2c() -> None:
+ root = str(Path(__file__).resolve().parents[2])
+ prof = serial_profile_for_device("tnc2c", root, {})
+ assert prof.baud == 19200
+ assert prof.dtr_rts is True
+ assert prof.kiss_entry == "kiss_on"
+
+
+def test_serial_profile_pktnc2() -> None:
+ root = str(Path(__file__).resolve().parents[2])
+ prof = serial_profile_for_device("pktnc2", root, {})
+ assert prof.baud == 9600
+ assert prof.dtr_rts is False
+ assert prof.kiss_entry == "auto"
+
+
+def test_serial_profile_pccom_kiss_env() -> None:
+ import os
+ import tempfile
+
+ tmp = Path(tempfile.mkdtemp())
+ env = tmp / "local" / "pccom-kiss-serial.env"
+ env.parent.mkdir(parents=True)
+ env.write_text(
+ "PCCOM_KISS_DEV=/dev/ttyUSB2\nPCCOM_KISS_BAUD=9600\nPCCOM_KISS_DTR_RTS=no\n",
+ encoding="utf-8",
+ )
+ old = os.environ.get("MAX25_ROOT")
+ os.environ["MAX25_ROOT"] = str(tmp)
+ try:
+ prof = serial_profile_for_device("pccom-kiss", str(tmp), {})
+ assert prof.device == "/dev/ttyUSB2"
+ assert prof.baud == 9600
+ assert prof.dtr_rts is False
+ assert prof.kiss_entry == "none"
+ finally:
+ if old is None:
+ os.environ.pop("MAX25_ROOT", None)
+ else:
+ os.environ["MAX25_ROOT"] = old
+
+
+def test_crc_known_vector() -> None:
+ body = bytes(range(256))
+ assert ax25_crc(body) == 0x303C
+
+
+def test_kiss_non_data_ignored() -> None:
+ pkt = kiss_encode(0, 0x06, b"ignored") # TXDELAY cmd
+ dec = KissDecoder()
+ assert dec.feed(pkt) == []
+
+
+def test_invalid_callsign_tx() -> None:
+ from kiss_bridge import KissBridge, SerialProfile
+
+ bridge = KissBridge(SerialProfile(), on_rx=lambda _m: None)
+ bridge._kiss_active = True
+ bridge._fd = -1 # not used — validate before write
+ ok, msg = bridge.transmit("BAD!", "CQ", "hi", ax25_ui=True)
+ assert not ok
+ assert "invalid" in msg.lower()
+
+
+def test_rx_thread_not_started_on_open() -> None:
+ from kiss_bridge import KissBridge, SerialProfile
+
+ bridge = KissBridge(SerialProfile(), on_rx=lambda _m: None)
+ bridge._fd = 1
+ bridge.status = "open"
+ assert bridge._thread is None
+
+
+def main() -> int:
+ tests = [
+ test_callsign_parse,
+ test_ax25_roundtrip,
+ test_kiss_fcs_strip,
+ test_kiss_escape_roundtrip,
+ test_format_rx,
+ test_serial_profile_tnc2c,
+ test_serial_profile_pktnc2,
+ test_serial_profile_pccom_kiss_env,
+ test_crc_known_vector,
+ test_kiss_non_data_ignored,
+ test_invalid_callsign_tx,
+ test_rx_thread_not_started_on_open,
+ ]
+ for fn in tests:
+ fn()
+ print("OK: kiss_bridge unit tests")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/stacks/daemon/test_max25_platform.py b/stacks/daemon/test_max25_platform.py
new file mode 100644
index 0000000..5073e01
--- /dev/null
+++ b/stacks/daemon/test_max25_platform.py
@@ -0,0 +1,54 @@
+#!/usr/bin/env python3
+"""Platform helpers for max25d."""
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+from max25_platform import ( # noqa: E402
+ crdop_audio_backend,
+ default_bans_file,
+ default_unix_socket,
+ is_freebsd,
+ is_linux,
+ max25d_supported,
+ supported_device_ids,
+)
+
+
+def test_linux_defaults() -> None:
+ # Run on Linux CI — skip assertions on other hosts
+ if not is_linux():
+ return
+ assert max25d_supported()
+ assert "tnc2c" in supported_device_ids()
+ assert "baycom-kiss" in supported_device_ids()
+ assert "pccom-kiss" in supported_device_ids()
+ assert "max25e0" in supported_device_ids()
+ assert "max25e0" in supported_device_ids()
+ assert "max25e0:bc1" in supported_device_ids()
+ assert "baycom-ser12" not in supported_device_ids()
+ assert "baycom-a" not in supported_device_ids()
+ assert crdop_audio_backend() == "alsa"
+ assert default_unix_socket() == "/run/max25/modem.sock"
+ assert default_bans_file() == "/var/lib/max25/bans.txt"
+
+
+def test_freebsd_profile() -> None:
+ if not is_freebsd():
+ return
+ assert max25d_supported()
+ assert "soft-crdop" in supported_device_ids()
+ assert "tmodem" in supported_device_ids()
+ assert "max25e0" in supported_device_ids()
+ assert "tnc2c" not in supported_device_ids()
+ assert crdop_audio_backend() == "oss"
+ assert default_unix_socket() == "/var/run/max25/modem.sock"
+ assert default_bans_file() == "/var/db/max25/bans.txt"
+
+
+if __name__ == "__main__":
+ test_linux_defaults()
+ test_freebsd_profile()
+ print("test_max25_platform: ok")
diff --git a/stacks/daemon/test_modular_tcp.py b/stacks/daemon/test_modular_tcp.py
new file mode 100644
index 0000000..a0b8551
--- /dev/null
+++ b/stacks/daemon/test_modular_tcp.py
@@ -0,0 +1,35 @@
+#!/usr/bin/env python3
+"""Offline tests for modular_tcp_server."""
+from __future__ import annotations
+
+import configparser
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+from modular_tcp_server import load_modular_tcp # noqa: E402
+
+
+def test_load_main_secondaries() -> None:
+ cp = configparser.ConfigParser()
+ cp.read_string(
+ """
+[modular_tcp]
+enabled = yes
+role = main
+
+[modular_tcp.secondaries]
+a = 127.0.0.1:7326
+b = 127.0.0.1:7327
+"""
+ )
+ cfg = load_modular_tcp(cp)
+ assert cfg.enabled is True
+ assert cfg.role == "main"
+ assert len(cfg.secondaries) == 2
+ assert cfg.secondaries[0].port == 7326
+
+
+if __name__ == "__main__":
+ test_load_main_secondaries()
+ print("test_modular_tcp: ok")
diff --git a/stacks/daemon/test_multi_device.py b/stacks/daemon/test_multi_device.py
new file mode 100644
index 0000000..a46a449
--- /dev/null
+++ b/stacks/daemon/test_multi_device.py
@@ -0,0 +1,518 @@
+#!/usr/bin/env python3
+"""Multi-device config and M25/1 protocol tests for max25d (no serial hardware)."""
+from __future__ import annotations
+
+import configparser
+import importlib.util
+import socket
+import subprocess
+import sys
+import tempfile
+import time
+from pathlib import Path
+from typing import Callable
+from unittest.mock import patch
+
+ROOT = Path(__file__).resolve().parents[2]
+LAUNCHER = ROOT / "stacks/daemon/max25d"
+DAEMON = ROOT / "stacks/daemon/max25d.py"
+
+
+def load_max25d_module():
+ from importlib.machinery import SourceFileLoader
+
+ loader = SourceFileLoader("max25d", str(DAEMON))
+ spec = importlib.util.spec_from_loader("max25d", loader)
+ mod = importlib.util.module_from_spec(spec)
+ sys.modules["max25d"] = mod
+ loader.exec_module(mod)
+ return mod
+
+
+class LineReader:
+ def __init__(self, sock: socket.socket) -> None:
+ self.sock = sock
+ self.buf = b""
+
+ def read(self, timeout: float = 5.0) -> str:
+ self.sock.settimeout(timeout)
+ while b"\n" not in self.buf:
+ chunk = self.sock.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")
+
+
+def free_port() -> int:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ sock.bind(("127.0.0.1", 0))
+ port = sock.getsockname()[1]
+ sock.close()
+ return port
+
+
+class FakeBackend:
+ backend_type = "mock"
+
+ def __init__(self, cfg, root, on_rx, log=None):
+ self.device_id = cfg.device_id
+ self._on_rx = on_rx
+ self.status = "ready"
+
+ def open(self):
+ return True
+
+ def attach_session(self, mycall):
+ return True
+
+ def transmit(self, src, dst, text, ax25_ui):
+ return True, f"[AX25 UI {src}>{dst}] {text}"
+
+ def close(self):
+ self.status = "closed"
+
+ def detach_session(self):
+ pass
+
+
+def test_parse_legacy_single_device() -> None:
+ mod = load_max25d_module()
+ cp = configparser.ConfigParser()
+ cp.read_string(
+ """
+[daemon]
+device = tnc2c
+hardware = tncs
+[serial]
+device = /dev/ttyS4
+baud = 19200
+"""
+ )
+ cfg = mod.DaemonConfig(device="tnc2c", hardware="tncs")
+ devices = mod.parse_devices(cp, cfg)
+ assert len(devices) == 1
+ assert devices[0].device_id == "tnc2c"
+ assert devices[0].serial_device == "/dev/ttyS4"
+ assert devices[0].serial_baud == 19200
+ assert devices[0].backend_type == "kiss-serial"
+
+
+def test_parse_multi_devices() -> None:
+ mod = load_max25d_module()
+ cp = configparser.ConfigParser()
+ cp.read_string(
+ """
+[devices]
+default = tnc2c
+tnc2c = /dev/ttyS4
+pktnc2 = /dev/ttyS5
+dev3 = /dev/ttyUSB0
+dev4 = /dev/ttyUSB1
+dev5 = /dev/ttyUSB2
+[serial.dev3]
+baud = 9600
+"""
+ )
+ cfg = mod.DaemonConfig()
+ devices = mod.parse_devices(cp, cfg)
+ assert len(devices) == 5
+ ids = {d.device_id for d in devices}
+ assert ids == {"tnc2c", "pktnc2", "dev3", "dev4", "dev5"}
+ by_id = {d.device_id: d for d in devices}
+ assert by_id["tnc2c"].serial_device == "/dev/ttyS4"
+ assert by_id["dev3"].serial_baud == 9600
+ assert cfg.default_device == "tnc2c"
+
+
+def test_parse_pccom_kiss_device() -> None:
+ mod = load_max25d_module()
+ cp = configparser.ConfigParser()
+ cp.read_string(
+ """
+[features]
+baycom = yes
+pccom = yes
+[devices]
+default = pccom-kiss
+pccom-kiss = /dev/ttyUSB0
+"""
+ )
+ cfg = mod.DaemonConfig()
+ cfg.feature_baycom = True
+ cfg.feature_pccom = True
+ devices = mod.parse_devices(cp, cfg)
+ by_id = {d.device_id: d for d in devices}
+ assert by_id["pccom-kiss"].backend_type == "kiss-raw-serial"
+ assert by_id["pccom-kiss"].serial_device == "/dev/ttyUSB0"
+
+
+def test_parse_heterogeneous_devices() -> None:
+ mod = load_max25d_module()
+ cp = configparser.ConfigParser()
+ cp.read_string(
+ """
+[devices]
+default = tnc2c
+tnc2c = /dev/ttyS4
+baycom-ser12 = baycom:a
+baycom-kiss = /dev/ttyUSB0
+soft-crdop = crdop:default
+[device.baycom-ser12]
+kiss_link = /var/run/baycom-pr/kiss
+modem = a
+baycom_ini = /etc/baycom/baycom-pr.ini
+[device.soft-crdop]
+port = 8515
+"""
+ )
+ cfg = mod.DaemonConfig()
+ devices = mod.parse_devices(cp, cfg)
+ by_id = {d.device_id: d for d in devices}
+ assert by_id["tnc2c"].backend_type == "kiss-serial"
+ assert by_id["baycom-ser12"].backend_type == "baycom-kiss"
+ assert by_id["baycom-ser12"].kiss_link == "/var/run/baycom-pr/kiss"
+ assert by_id["baycom-ser12"].baycom_ini == "/etc/baycom/baycom-pr.ini"
+ assert by_id["baycom-kiss"].backend_type == "kiss-raw-serial"
+ assert by_id["soft-crdop"].backend_type == "crdop-tcp"
+ assert by_id["soft-crdop"].crdop_port == 8515
+
+
+def test_parse_enabled_filter() -> None:
+ mod = load_max25d_module()
+ cp = configparser.ConfigParser()
+ cp.read_string(
+ """
+[devices]
+enabled = tnc2c,pktnc2
+tnc2c = /dev/ttyS4
+pktnc2 = /dev/ttyS5
+extra = /dev/ttyUSB9
+"""
+ )
+ cfg = mod.DaemonConfig()
+ devices = mod.parse_devices(cp, cfg)
+ enabled = {d.device_id for d in devices if d.enabled}
+ disabled = {d.device_id for d in devices if not d.enabled}
+ assert enabled == {"tnc2c", "pktnc2"}
+ assert disabled == {"extra"}
+
+
+def test_backend_open_retry_on_error() -> None:
+ """CONNECT retries KISS open after a transient error-no-device."""
+ mod = load_max25d_module()
+ cfg = mod.DaemonConfig(hardware="modems", serial_enabled=True)
+ cfg.devices = [
+ mod.DeviceBackendConfig(
+ device_id="baycom-ser12",
+ backend_type="baycom-kiss",
+ kiss_link="/nonexistent/kiss",
+ enabled=True,
+ )
+ ]
+ cfg.default_device = "baycom-ser12"
+ state = mod.DaemonState(cfg=cfg)
+ mod.init_device_runtimes(state)
+
+ class ErrorThenReady(FakeBackend):
+ attempts = 0
+
+ def open(self):
+ ErrorThenReady.attempts += 1
+ if ErrorThenReady.attempts == 1:
+ self.status = "error-no-device"
+ return False
+ self.status = "ready"
+ return True
+
+ ErrorThenReady.attempts = 0
+ with patch.object(
+ mod,
+ "create_backend",
+ side_effect=lambda c, r, rx, log, prefix=None, on_invalid=None: ErrorThenReady(c, r, rx, log),
+ ):
+ assert not mod.attach_backend_session(state, "baycom-ser12")
+ assert mod.attach_backend_session(state, "baycom-ser12")
+ assert ErrorThenReady.attempts == 2
+
+
+def test_five_mock_backends() -> None:
+ """Five concurrent backend instances via mocked create_backend."""
+ mod = load_max25d_module()
+ opened: list[str] = []
+
+ class CountingBackend(FakeBackend):
+ def open(self):
+ opened.append(self.device_id)
+ return True
+
+ cfg = mod.DaemonConfig(hardware="tncs", serial_enabled=True)
+ cfg.devices = [
+ mod.DeviceBackendConfig(device_id=f"d{i}", serial_device=f"/dev/fake{i - 1}", enabled=True)
+ for i in range(1, 6)
+ ]
+ for d in cfg.devices:
+ d.backend_type = "kiss-serial"
+ cfg.default_device = "d1"
+ state = mod.DaemonState(cfg=cfg)
+ mod.init_device_runtimes(state)
+
+ with patch.object(
+ mod,
+ "create_backend",
+ side_effect=lambda c, r, rx, log, prefix=None, on_invalid=None: CountingBackend(c, r, rx, log),
+ ):
+ assert mod.attach_all_sessions(state)
+ assert len(opened) == 5
+ assert set(opened) == {f"d{i}" for i in range(1, 6)}
+
+ state.selected_device = "d3"
+ dev_id = mod.resolve_selected_device(state)
+ assert dev_id == "d3"
+ rt = state.devices["d3"]
+ ok, display = rt.backend.transmit("CB-0", "QST", "hi", True)
+ assert ok
+ assert "hi" in display
+
+
+def run_daemon_test(ini_text: str, fn: Callable[[LineReader, socket.socket], None]) -> None:
+ port = free_port()
+ with tempfile.NamedTemporaryFile("w", suffix=".ini", delete=False) as tmp:
+ tmp.write(ini_text)
+ ini_path = Path(tmp.name)
+
+ proc = subprocess.Popen(
+ [
+ str(LAUNCHER),
+ "--no-stack",
+ "--no-serial",
+ "-c",
+ str(ini_path),
+ "--tcp-port",
+ str(port),
+ ],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.PIPE,
+ text=True,
+ )
+ time.sleep(0.6)
+ try:
+ sock = socket.create_connection(("127.0.0.1", port), timeout=3)
+ except OSError as exc:
+ proc.terminate()
+ proc.wait(timeout=3)
+ ini_path.unlink(missing_ok=True)
+ raise RuntimeError(f"connect failed: {exc}") from exc
+
+ reader = LineReader(sock)
+ try:
+ assert reader.read() == "OK"
+ fn(reader, sock)
+ finally:
+ sock.close()
+ proc.terminate()
+ proc.wait(timeout=5)
+ ini_path.unlink(missing_ok=True)
+
+
+def test_parse_dual_baycom_devices() -> None:
+ mod = load_max25d_module()
+ cp = configparser.ConfigParser()
+ cp.read_string(
+ """
+[devices]
+default = baycom-a
+baycom-a = baycom:a
+baycom-b = baycom:b
+[device.baycom-a]
+kiss_link = /var/run/baycom-pr/kiss-a
+modem = a
+baycom_ini = /etc/baycom/baycom-pr.ini
+[device.baycom-b]
+kiss_link = /var/run/baycom-pr/kiss-b
+modem = b
+baycom_ini = /etc/baycom/baycom-pr.ini
+"""
+ )
+ cfg = mod.DaemonConfig()
+ devices = mod.parse_devices(cp, cfg)
+ by_id = {d.device_id: d for d in devices}
+ assert set(by_id) == {"baycom-a", "baycom-b"}
+ assert by_id["baycom-a"].backend_type == "baycom-kiss"
+ assert by_id["baycom-b"].backend_type == "baycom-kiss"
+ assert by_id["baycom-a"].baycom_modem == "a"
+ assert by_id["baycom-b"].baycom_modem == "b"
+ assert by_id["baycom-a"].kiss_link == "/var/run/baycom-pr/kiss-a"
+ assert by_id["baycom-b"].kiss_link == "/var/run/baycom-pr/kiss-b"
+ assert cfg.default_device == "baycom-a"
+
+
+def test_protocol_dual_baycom() -> None:
+ # Legacy ids baycom-a/baycom-b are not on the Linux allowlist (product path: max25e0).
+ # Protocol dual-device coverage uses allowlisted modem-class ids instead.
+ ini = """
+[features]
+baycom = yes
+pccom = yes
+[daemon]
+mode = service
+hardware = modems
+device = baycom-kiss
+[devices]
+default = baycom-kiss
+baycom-kiss = /dev/null
+pccom-kiss = /dev/null
+[network]
+tcp_host = 127.0.0.1
+tcp_port = 7325
+[modem]
+callerid = N0CALL-0
+callid = QST
+[stack]
+auto_start = no
+"""
+
+ def exercise(reader: LineReader, sock: socket.socket) -> None:
+ status = reader.read()
+ assert "devices=baycom-kiss,pccom-kiss" in status
+ assert "device=baycom-kiss" in status
+
+ sock.sendall(b"GET DEVICES\n")
+ lines = []
+ while True:
+ line = reader.read()
+ lines.append(line)
+ if line == "OK":
+ break
+ device_lines = [l for l in lines if l.startswith("DEVICE ")]
+ assert len(device_lines) == 2
+ assert any("id=baycom-kiss" in l for l in device_lines)
+ assert any("id=pccom-kiss" in l for l in device_lines)
+
+ sock.sendall(b"SET DEVICE pccom-kiss\n")
+ assert reader.read() == "OK"
+
+ sock.sendall(b"GET STATUS\n")
+ st = reader.read()
+ assert "device=pccom-kiss" in st
+ assert reader.read() == "OK"
+
+ run_daemon_test(ini, exercise)
+
+
+def test_protocol_multi_device() -> None:
+ # Explicit two-device INI — share/max25d.ini.example is single-device by design.
+ ini = """
+[daemon]
+mode = standalone
+hardware = tncs
+device = tnc2c
+[devices]
+default = tnc2c
+tnc2c = /dev/ttyS4
+pktnc2 = /dev/ttyS5
+[network]
+tcp_host = 127.0.0.1
+tcp_port = 7325
+[modem]
+callerid = CB-0
+callid = QST
+[stack]
+auto_start = no
+"""
+
+ def exercise(reader: LineReader, sock: socket.socket) -> None:
+ status = reader.read()
+ assert status.startswith("STATUS ")
+ assert "devices=tnc2c,pktnc2" in status
+ assert "device=tnc2c" in status
+
+ sock.sendall(b"GET DEVICES\n")
+ lines = []
+ while True:
+ line = reader.read()
+ lines.append(line)
+ if line == "OK":
+ break
+ device_lines = [l for l in lines if l.startswith("DEVICE ")]
+ assert len(device_lines) == 2
+ assert any("id=tnc2c" in l for l in device_lines)
+ assert any("id=pktnc2" in l for l in device_lines)
+ assert any("backend=kiss-serial" in l for l in device_lines)
+
+ sock.sendall(b"SET DEVICE pktnc2\n")
+ assert reader.read() == "OK"
+
+ sock.sendall(b"GET STATUS\n")
+ st = reader.read()
+ assert "device=pktnc2" in st
+ assert reader.read() == "OK"
+
+ sock.sendall(b"SELECT DEVICE tnc2c\n")
+ assert reader.read() == "OK"
+
+ sock.sendall(b"CONNECT\n")
+ assert reader.read() == "EVENT connected"
+ assert reader.read() == "OK"
+
+ sock.sendall(b"SEND 73\n")
+ rx = reader.read()
+ assert rx.startswith("RX ")
+ assert reader.read() == "OK"
+
+ sock.sendall(b"DISCONNECT\n")
+ assert reader.read() == "EVENT disconnected"
+ assert reader.read() == "OK"
+
+ run_daemon_test(ini, exercise)
+
+
+def test_protocol_legacy_single() -> None:
+ ini = """
+[daemon]
+mode = standalone
+hardware = tncs
+device = tnc2c
+[network]
+tcp_host = 127.0.0.1
+tcp_port = 7325
+[modem]
+callerid = CB-0
+callid = QST
+[stack]
+auto_start = no
+"""
+
+ def exercise(reader: LineReader, sock: socket.socket) -> None:
+ status = reader.read()
+ assert "device=tnc2c" in status
+ assert "devices=tnc2c" in status
+
+ sock.sendall(b"SET DEVICE tnc2c\n")
+ assert reader.read() == "OK"
+
+ run_daemon_test(ini, exercise)
+
+
+def main() -> int:
+ tests = [
+ test_parse_legacy_single_device,
+ test_parse_multi_devices,
+ test_parse_heterogeneous_devices,
+ test_parse_dual_baycom_devices,
+ test_parse_enabled_filter,
+ test_backend_open_retry_on_error,
+ test_five_mock_backends,
+ test_protocol_multi_device,
+ test_protocol_dual_baycom,
+ test_protocol_legacy_single,
+ ]
+ for fn in tests:
+ fn()
+ print("OK: multi-device tests")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/stacks/daemon/test_path_normalize.py b/stacks/daemon/test_path_normalize.py
new file mode 100644
index 0000000..c7b24e6
--- /dev/null
+++ b/stacks/daemon/test_path_normalize.py
@@ -0,0 +1,17 @@
+"""Tests for legacy bcpr path normalization."""
+
+from paths import MAX25_BCPR_KISS_DEFAULT, normalize_max25_bcpr_path
+
+
+def test_normalize_legacy_tmp_bcpr():
+ assert normalize_max25_bcpr_path("/tmp/bcpr/kiss-bc0") == "/tmp/max25-bcpr/kiss-bc0"
+ assert normalize_max25_bcpr_path("/tmp/bcpr") == "/tmp/max25-bcpr"
+ assert normalize_max25_bcpr_path("") == ""
+
+
+def test_canonical_unchanged():
+ assert (
+ normalize_max25_bcpr_path("/tmp/max25-bcpr/kiss-bc0")
+ == "/tmp/max25-bcpr/kiss-bc0"
+ )
+ assert MAX25_BCPR_KISS_DEFAULT == "/tmp/max25-bcpr/kiss-bc0"
diff --git a/stacks/daemon/test_paths.py b/stacks/daemon/test_paths.py
new file mode 100644
index 0000000..6a3d7f5
--- /dev/null
+++ b/stacks/daemon/test_paths.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+"""Offline tests for install/dev path resolution."""
+from __future__ import annotations
+
+import os
+import sys
+import tempfile
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+from paths import ( # noqa: E402
+ baycom_ini_candidates,
+ canonical_dual_baycom_example,
+ canonical_single_baycom_example,
+ ctl_path,
+ default_ini_candidates,
+ is_dual_baycom_ini,
+ resolve_baycom_ini,
+ resolve_baycom_profile,
+ resolve_layout,
+ serial_env_candidates,
+ share_max25_dir,
+ stacks_dir,
+)
+
+REPO = Path(__file__).resolve().parents[2]
+
+
+def test_dev_layout() -> None:
+ exe = REPO / "stacks/daemon/max25d"
+ tree, prefix = resolve_layout(exe)
+ assert prefix is None
+ assert tree == REPO
+ assert (stacks_dir(tree, prefix) / "crdop").is_dir()
+ assert ctl_path(tree, prefix, exe) == REPO / "scripts/max25-ctl"
+
+
+def test_installed_layout() -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ prefix = Path(tmp) / "usr/local"
+ bindir = prefix / "bin"
+ share = prefix / "share/max25"
+ bindir.mkdir(parents=True)
+ share.mkdir(parents=True)
+ (share / "max25d.ini.example").write_text("; test\n", encoding="utf-8")
+ (bindir / "max25d").write_text("#!/bin/sh\n", encoding="utf-8")
+ (bindir / "max25-ctl").write_text("#!/bin/sh\n", encoding="utf-8")
+
+ tree, detected = resolve_layout(bindir / "max25d")
+ assert detected == prefix
+ assert tree == prefix
+
+ cands = default_ini_candidates(tree, detected)
+ assert share / "max25d.ini.example" in cands
+ assert ctl_path(tree, detected, bindir / "max25d") == bindir / "max25-ctl"
+
+
+def test_max25_root_override() -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ prefix = Path(tmp) / "opt/max25"
+ bindir = prefix / "bin"
+ share = prefix / "share/max25"
+ bindir.mkdir(parents=True)
+ share.mkdir(parents=True)
+ (bindir / "max25d").write_text("#!/bin/sh\n", encoding="utf-8")
+
+ checkout = Path(tmp) / "checkout"
+ (checkout / "stacks/tncs").mkdir(parents=True)
+ (checkout / "plugins").mkdir()
+ (checkout / "plugins/manifest.yaml").write_text("plugins: []\n", encoding="utf-8")
+
+ old = os.environ.get("MAX25_ROOT")
+ os.environ["MAX25_ROOT"] = str(checkout)
+ try:
+ tree, detected = resolve_layout(bindir / "max25d")
+ assert detected == prefix
+ assert tree == checkout
+ assert stacks_dir(tree, detected) == checkout / "stacks"
+ finally:
+ if old is None:
+ os.environ.pop("MAX25_ROOT", None)
+ else:
+ os.environ["MAX25_ROOT"] = old
+
+
+def test_serial_env_order() -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ prefix = Path(tmp) / "usr/local"
+ etc = Path(tmp) / "etc/max25"
+ etc.mkdir(parents=True)
+ (etc / "tnc2c-serial.env").write_text("TNC2C_BAUD=38400\n", encoding="utf-8")
+ share = prefix / "share/max25/serial"
+ share.mkdir(parents=True)
+ (share / "tnc2c-serial.env").write_text("TNC2C_BAUD=19200\n", encoding="utf-8")
+
+ # /etc/max25 is checked first when present on disk
+ cands = serial_env_candidates("tnc2c", REPO, prefix)
+ assert cands[0] == Path("/etc/max25/tnc2c-serial.env")
+ assert prefix / "share/max25/serial/tnc2c-serial.env" in cands
+
+
+def test_baycom_ini_resolution_legacy_removed() -> None:
+ """Kernel baycom-pr examples removed 2026-07-18 — no in-tree templates."""
+ example = REPO / "share/baycom/baycom-pr.pccom-ttyS0-only.ini.example"
+ dual = REPO / "stacks/baycom-pr/config/examples/baycom-pr.dual.ini"
+ assert not example.is_file()
+ assert not dual.is_file()
+ assert canonical_single_baycom_example("baycom-ser12", REPO, None) is None
+ assert canonical_dual_baycom_example("baycom-ser12", REPO, None) is None
+ with tempfile.NamedTemporaryFile("w", suffix=".ini", delete=False) as tmp:
+ tmp.write("[stack]\n")
+ explicit = Path(tmp.name)
+ try:
+ assert resolve_baycom_ini("baycom-ser12", REPO, None, str(explicit)) == explicit
+ # Without explicit: may still resolve site /etc/baycom/baycom-pr.ini if present
+ resolved = resolve_baycom_ini("baycom-ser12", REPO, None)
+ if resolved is not None:
+ assert resolved.is_file()
+ assert "baycom-pr" in resolved.name or resolved.parent.name == "baycom"
+ finally:
+ explicit.unlink(missing_ok=True)
+
+
+def test_baycom_profile_dual_legacy_removed() -> None:
+ assert resolve_baycom_profile("dual", "baycom-ser12", REPO, None) is None
+
+
+def main() -> int:
+ tests = [
+ test_dev_layout,
+ test_installed_layout,
+ test_max25_root_override,
+ test_serial_env_order,
+ test_baycom_ini_resolution_legacy_removed,
+ test_baycom_profile_dual_legacy_removed,
+ ]
+ for fn in tests:
+ fn()
+ print("OK: paths unit tests")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/stacks/daemon/test_privilege_drop.py b/stacks/daemon/test_privilege_drop.py
new file mode 100644
index 0000000..3a611c9
--- /dev/null
+++ b/stacks/daemon/test_privilege_drop.py
@@ -0,0 +1,55 @@
+#!/usr/bin/env python3
+"""Tests for max25d privilege_drop (no root required)."""
+from __future__ import annotations
+
+import configparser
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent
+sys.path.insert(0, str(ROOT))
+
+from privilege_drop import RunAsConfig, parse_run_as, resolve_run_as, run_as_configured # noqa: E402
+
+
+def test_parse_run_as_empty() -> None:
+ cp = configparser.ConfigParser()
+ cp.read_string("[daemon]\nmode = standalone\n")
+ cfg = parse_run_as(cp)
+ assert cfg.user == ""
+ assert not run_as_configured(cfg)
+
+
+def test_parse_run_as_user_group() -> None:
+ cp = configparser.ConfigParser()
+ cp.read_string(
+ "[daemon]\nuser = max25\ngroup = dialout\nuid = 1001\n"
+ )
+ cfg = parse_run_as(cp)
+ assert cfg.user == "max25"
+ assert cfg.group == "dialout"
+ assert cfg.uid == 1001
+ assert run_as_configured(cfg)
+
+
+def test_resolve_run_as_current_user() -> None:
+ import os
+ import pwd
+
+ pw = pwd.getpwuid(os.getuid())
+ uid, gid, name = resolve_run_as(RunAsConfig(user=pw.pw_name))
+ assert uid == pw.pw_uid
+ assert gid == pw.pw_gid
+ assert name == pw.pw_name
+
+
+def main() -> int:
+ test_parse_run_as_empty()
+ test_parse_run_as_user_group()
+ test_resolve_run_as_current_user()
+ print("privilege_drop: OK")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/stacks/daemon/test_proto.py b/stacks/daemon/test_proto.py
new file mode 100644
index 0000000..50ced93
--- /dev/null
+++ b/stacks/daemon/test_proto.py
@@ -0,0 +1,106 @@
+#!/usr/bin/env python3
+"""Offline smoke test for max25d M25/1 protocol."""
+import socket
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+DAEMON = ROOT / "stacks/daemon/max25d"
+INI = ROOT / "share/max25/max25d.ini.example"
+
+
+class LineReader:
+ def __init__(self, sock: socket.socket) -> None:
+ self.sock = sock
+ self.buf = b""
+
+ def read(self, timeout: float = 5.0) -> str:
+ self.sock.settimeout(timeout)
+ while b"\n" not in self.buf:
+ chunk = self.sock.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")
+
+
+def free_port() -> int:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ sock.bind(("127.0.0.1", 0))
+ port = sock.getsockname()[1]
+ sock.close()
+ return port
+
+
+def wait_for_port(port: int, proc: subprocess.Popen[str], timeout: float = 8.0) -> bool:
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ if proc.poll() is not None:
+ return False
+ try:
+ probe = socket.create_connection(("127.0.0.1", port), timeout=0.25)
+ probe.close()
+ return True
+ except OSError:
+ time.sleep(0.1)
+ return False
+
+
+def main() -> int:
+ port = free_port()
+ proc = subprocess.Popen(
+ [str(DAEMON), "--no-stack", "--no-serial", "-c", str(INI), "--tcp-port", str(port)],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.PIPE,
+ text=True,
+ )
+ if not wait_for_port(port, proc):
+ err = proc.stderr.read() if proc.stderr else ""
+ proc.terminate()
+ proc.wait(timeout=3)
+ print(f"FAIL: max25d not listening on {port}: {err.strip()}", file=sys.stderr)
+ return 1
+ try:
+ sock = socket.create_connection(("127.0.0.1", port), timeout=3)
+ except OSError as exc:
+ proc.terminate()
+ proc.wait(timeout=3)
+ print(f"FAIL: connect: {exc}", file=sys.stderr)
+ return 1
+
+ reader = LineReader(sock)
+ try:
+ assert reader.read() == "OK"
+ status = reader.read()
+ assert status.startswith("STATUS ")
+
+ sock.sendall(b"SET CALLERID DG1ABC\n")
+ assert reader.read() == "OK"
+
+ sock.sendall(b"CONNECT\n")
+ assert reader.read() == "EVENT connected"
+ assert reader.read() == "OK"
+
+ sock.sendall(b"SEND 73\n")
+ rx = reader.read()
+ assert rx.startswith("RX ")
+ assert reader.read() == "OK"
+
+ sock.sendall(b"GET STATUS\n")
+ st = reader.read()
+ assert "callerid=DG1ABC" in st
+ assert reader.read() == "OK"
+
+ print("OK: max25d protocol smoke")
+ return 0
+ finally:
+ sock.close()
+ proc.terminate()
+ proc.wait(timeout=5)
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/stacks/daemon/test_reporting.py b/stacks/daemon/test_reporting.py
new file mode 100644
index 0000000..54f73df
--- /dev/null
+++ b/stacks/daemon/test_reporting.py
@@ -0,0 +1,163 @@
+#!/usr/bin/env python3
+"""INI reporting switches — error= / voice= in STATUS."""
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+from device_backends import DeviceBackendConfig # noqa: E402
+import max25d # noqa: E402
+from reporting_quality import DataQualityTracker, RxOutcome # noqa: E402
+
+T0 = 2_000_000.0
+WIN = 20
+
+
+def _close_pass(rt: max25d.DeviceRuntime, index: int, start: float = T0) -> None:
+ rt.quality.tick(start + (index + 1) * WIN + 1)
+
+
+def _fill_good(rt: max25d.DeviceRuntime, count: int = 3, window: int = WIN) -> None:
+ for i in range(count):
+ rt.quality.record(RxOutcome.GOOD, T0 + i * window + 1)
+ rt.quality.tick(T0 + (i + 1) * window + 1)
+
+
+def _state(
+ *,
+ error_on: bool = True,
+ voice_on: bool = True,
+ link: str = "ready",
+ passes: int = 3,
+ quality_min: int = 50,
+ pass_seconds: int = 20,
+ backend_type: str = "kiss-serial",
+ hardware: str = "tncs",
+ prefill_good: bool = True,
+) -> max25d.DaemonState:
+ cfg = max25d.DaemonConfig(
+ report_error_transmissions=error_on,
+ report_voice_transmissions=voice_on,
+ report_data_passes=passes,
+ report_data_quality_min=quality_min,
+ report_data_pass_seconds=pass_seconds,
+ )
+ dev = DeviceBackendConfig(
+ device_id="tnc2c",
+ enabled=True,
+ backend_type=backend_type,
+ hardware=hardware,
+ )
+ rt = max25d.DeviceRuntime(
+ cfg=dev,
+ link_status=link,
+ quality=DataQualityTracker(
+ passes_required=passes,
+ min_good_percent=quality_min,
+ pass_window_sec=pass_seconds,
+ ),
+ )
+ if prefill_good:
+ _fill_good(rt, passes, pass_seconds)
+ state = max25d.DaemonState(cfg=cfg)
+ state.devices["tnc2c"] = rt
+ state.selected_device = "tnc2c"
+ return state
+
+
+def test_error_valid_when_enabled_and_ready() -> None:
+ line = max25d.status_line(_state())
+ assert "error=valid" in line
+
+
+def test_error_invalid_when_disabled() -> None:
+ line = max25d.status_line(_state(error_on=False))
+ assert "error=invalid" in line
+
+
+def test_error_invalid_on_link_fault() -> None:
+ line = max25d.status_line(_state(link="error-io"))
+ assert "error=invalid" in line
+
+
+def test_error_invalid_before_passes_complete() -> None:
+ line = max25d.status_line(_state(prefill_good=False))
+ assert "error=invalid" in line
+
+
+def test_error_invalid_on_bad_frame_ratio() -> None:
+ state = _state(prefill_good=False)
+ rt = state.devices["tnc2c"]
+ rt.quality.record(RxOutcome.BAD, T0 + 1)
+ _close_pass(rt, 0)
+ rt.quality.record(RxOutcome.BAD, T0 + 21)
+ _close_pass(rt, 1)
+ rt.quality.record(RxOutcome.GOOD, T0 + 41)
+ _close_pass(rt, 2)
+ line = max25d.status_line(state)
+ assert "error=invalid" in line
+
+
+def test_voice_valid_tnc_only() -> None:
+ line = max25d.status_line(_state())
+ assert "voice=valid" in line
+
+
+def test_voice_invalid_when_disabled() -> None:
+ line = max25d.status_line(_state(voice_on=False))
+ assert "voice=invalid" in line
+
+
+def test_voice_invalid_crdop_not_ready() -> None:
+ line = max25d.status_line(
+ _state(
+ backend_type="crdop-tcp",
+ hardware="soft-modems",
+ link="error-connect",
+ )
+ )
+ assert "voice=invalid" in line
+
+
+def test_device_line_includes_reporting() -> None:
+ state = _state()
+ dev = max25d.device_line(state, "tnc2c")
+ assert "error=valid" in dev
+ assert "voice=n/a" in dev
+
+
+def test_on_backend_rx_tracks_callid_in_pass_window() -> None:
+ state = _state(prefill_good=False)
+ max25d.on_backend_rx(state, "tnc2c", "[AX25 UI DG1ABC>QST] 73")
+ max25d.on_backend_rx(state, "tnc2c", "[AX25 UI DG1ABC>CB-0] bad")
+ max25d.on_backend_rx(state, "tnc2c", "[CRDOP RX soft-crdop] noise")
+ rt = state.devices["tnc2c"]
+ start = rt.quality.current.started_at
+ assert rt.quality.current.good == 1
+ assert rt.quality.current.bad == 1
+ rt.quality.tick(start + WIN + 1)
+ assert list(rt.quality.passes) == ["bad"]
+ max25d.on_backend_rx(state, "tnc2c", "[AX25 UI DG1ABC>QST] ok")
+ start = rt.quality.current.started_at
+ rt.quality.tick(start + WIN + 1)
+ max25d.on_backend_rx(state, "tnc2c", "[AX25 UI DG1ABC>QST] ok2")
+ start = rt.quality.current.started_at
+ rt.quality.tick(start + WIN + 1)
+ line = max25d.status_line(state)
+ assert "error=valid" in line
+
+
+if __name__ == "__main__":
+ test_error_valid_when_enabled_and_ready()
+ test_error_invalid_when_disabled()
+ test_error_invalid_on_link_fault()
+ test_error_invalid_before_passes_complete()
+ test_error_invalid_on_bad_frame_ratio()
+ test_voice_valid_tnc_only()
+ test_voice_invalid_when_disabled()
+ test_voice_invalid_crdop_not_ready()
+ test_device_line_includes_reporting()
+ test_on_backend_rx_tracks_callid_in_pass_window()
+ print("test_reporting: ok")
diff --git a/stacks/daemon/test_reporting_quality.py b/stacks/daemon/test_reporting_quality.py
new file mode 100644
index 0000000..15f0d4d
--- /dev/null
+++ b/stacks/daemon/test_reporting_quality.py
@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+"""Data-quality window — CALLID match, timed passes, configurable min % good."""
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+from reporting_quality import ( # noqa: E402
+ DataQualityTracker,
+ RxOutcome,
+ classify_rx_line,
+)
+
+T0 = 1_000_000.0
+WIN = 20
+
+
+def test_classify_good_callid() -> None:
+ assert classify_rx_line("[AX25 UI DG1ABC>QST] 73", "QST") == RxOutcome.GOOD
+
+
+def test_classify_bad_callid() -> None:
+ assert classify_rx_line("[AX25 UI DG1ABC>CB-0] 73", "QST") == RxOutcome.BAD
+
+
+def test_classify_incomplete_ax25() -> None:
+ assert classify_rx_line("[AX25 UI DG1ABC>QST", "QST") == RxOutcome.BAD
+
+
+def test_classify_voice_noise_ignored() -> None:
+ assert classify_rx_line("[CRDOP RX soft-crdop] hiss", "QST") == RxOutcome.IGNORE
+
+
+def test_classify_crdop_ax25_good() -> None:
+ line = "[CRDOP AX25 UI CB-0>QST] ping"
+ assert classify_rx_line(line, "QST") == RxOutcome.GOOD
+
+
+def _close_pass(q: DataQualityTracker, index: int, start: float = T0) -> None:
+ q.tick(start + (index + 1) * WIN + 1)
+
+
+def test_pass_window_max_twenty_seconds() -> None:
+ q = DataQualityTracker(passes_required=3, min_good_percent=50, pass_window_sec=20)
+ q.record(RxOutcome.GOOD, T0 + 1)
+ q.tick(T0 + 19)
+ assert len(q.passes) == 0
+ _close_pass(q, 0)
+ assert list(q.passes) == ["good"]
+
+
+def test_three_passes_fifty_percent() -> None:
+ q = DataQualityTracker(passes_required=3, min_good_percent=50, pass_window_sec=20)
+ q.record(RxOutcome.GOOD, T0 + 1)
+ _close_pass(q, 0)
+ q.record(RxOutcome.BAD, T0 + 20 + 1)
+ _close_pass(q, 1)
+ assert q.data_error_valid(reporting_enabled=True) is False
+ q.record(RxOutcome.GOOD, T0 + 40 + 1)
+ _close_pass(q, 2)
+ assert q.data_error_valid(reporting_enabled=True) is True
+
+
+def test_three_passes_below_fifty_percent() -> None:
+ q = DataQualityTracker(passes_required=3, min_good_percent=50, pass_window_sec=20)
+ q.record(RxOutcome.BAD, T0 + 1)
+ _close_pass(q, 0)
+ q.record(RxOutcome.BAD, T0 + 21)
+ _close_pass(q, 1)
+ q.record(RxOutcome.GOOD, T0 + 41)
+ _close_pass(q, 2)
+ assert q.data_error_valid(reporting_enabled=True) is False
+
+
+def test_ignore_does_not_consume_pass() -> None:
+ q = DataQualityTracker(passes_required=3, min_good_percent=50, pass_window_sec=20)
+ q.record(RxOutcome.IGNORE, T0 + 1)
+ _close_pass(q, 0)
+ assert len(q.passes) == 0
+ q.record(RxOutcome.GOOD, T0 + 21)
+ _close_pass(q, 1)
+ q.record(RxOutcome.GOOD, T0 + 41)
+ _close_pass(q, 2)
+ q.record(RxOutcome.GOOD, T0 + 61)
+ _close_pass(q, 3)
+ assert q.data_error_valid(reporting_enabled=True) is True
+ assert q.voice_activity is True
+
+
+def test_silent_window_counts_as_bad() -> None:
+ q = DataQualityTracker(passes_required=1, min_good_percent=50, pass_window_sec=20)
+ q.current.started_at = T0
+ _close_pass(q, 0)
+ assert list(q.passes) == ["bad"]
+
+
+def test_max_cycle_seconds() -> None:
+ q = DataQualityTracker(passes_required=3, pass_window_sec=20)
+ assert q.max_cycle_seconds() == 60
+
+
+def test_voice_valid_on_activity() -> None:
+ q = DataQualityTracker()
+ q.record(RxOutcome.IGNORE, T0 + 1)
+ assert q.voice_signal_valid(reporting_enabled=True, link_healthy=True) is True
+
+
+if __name__ == "__main__":
+ test_classify_good_callid()
+ test_classify_bad_callid()
+ test_classify_incomplete_ax25()
+ test_classify_voice_noise_ignored()
+ test_classify_crdop_ax25_good()
+ test_pass_window_max_twenty_seconds()
+ test_three_passes_fifty_percent()
+ test_three_passes_below_fifty_percent()
+ test_ignore_does_not_consume_pass()
+ test_silent_window_counts_as_bad()
+ test_max_cycle_seconds()
+ test_voice_valid_on_activity()
+ print("test_reporting_quality: ok")
diff --git a/stacks/daemon/test_serial_watch.py b/stacks/daemon/test_serial_watch.py
new file mode 100644
index 0000000..7b4d8ad
--- /dev/null
+++ b/stacks/daemon/test_serial_watch.py
@@ -0,0 +1,241 @@
+#!/usr/bin/env python3
+"""Offline tests for max25d TNC serial watch / stabilize."""
+from __future__ import annotations
+
+import configparser
+import importlib.util
+import sys
+import tempfile
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+ROOT = Path(__file__).resolve().parents[2]
+DAEMON = ROOT / "stacks/daemon/max25d.py"
+
+
+def load_max25d():
+ from importlib.machinery import SourceFileLoader
+
+ loader = SourceFileLoader("max25d", str(DAEMON))
+ spec = importlib.util.spec_from_loader("max25d", loader)
+ assert spec and spec.loader
+ mod = importlib.util.module_from_spec(spec)
+ sys.modules["max25d"] = mod
+ loader.exec_module(mod)
+ return mod
+
+
+def test_stack_serial_watch_config() -> None:
+ mod = load_max25d()
+ cp = configparser.ConfigParser()
+ cp.read_string(
+ """
+[stack]
+auto_start = yes
+serial_watch = no
+serial_watch_interval = 45
+serial_repair_cooldown = 10
+stack_recover_only = no
+stack_retry_interval = 90
+"""
+ )
+ cfg = mod.DaemonConfig()
+ cfg.auto_start = mod._truthy(cp.get("stack", "auto_start"))
+ cfg.serial_watch = mod._truthy(cp.get("stack", "serial_watch"))
+ cfg.serial_watch_interval = cp.getint("stack", "serial_watch_interval")
+ cfg.serial_repair_cooldown = cp.getint("stack", "serial_repair_cooldown")
+ cfg.stack_recover_only = mod._truthy(cp.get("stack", "stack_recover_only"))
+ cfg.stack_retry_interval = cp.getint("stack", "stack_retry_interval")
+ assert cfg.serial_watch is False
+ assert cfg.serial_watch_interval == 45
+ assert cfg.stack_recover_only is False
+
+
+def test_serial_repair_statuses() -> None:
+ mod = load_max25d()
+ assert "error-io" in mod.BACKEND_RETRY_STATUSES
+ assert "error-host" in mod.SERIAL_REPAIR_STATUSES
+ assert "open" not in mod.SERIAL_REPAIR_STATUSES
+
+
+def test_inline_tnc_prep() -> None:
+ mod = load_max25d()
+ cfg = mod.DaemonConfig(stack_recover_only=True)
+ state = mod.DaemonState(cfg=cfg)
+ from device_backends import DeviceBackendConfig # noqa: E402
+
+ state.devices["tnc2c"] = mod.DeviceRuntime(
+ cfg=DeviceBackendConfig(device_id="tnc2c", backend_type="kiss-serial")
+ )
+ state.devices["baycom-ser12"] = mod.DeviceRuntime(
+ cfg=DeviceBackendConfig(device_id="baycom-ser12", backend_type="baycom-kiss")
+ )
+ assert mod.uses_inline_tnc_prep(state, "tnc2c")
+ assert not mod.uses_inline_tnc_prep(state, "baycom-ser12")
+
+
+def test_stabilize_ready_kiss_skips_probe() -> None:
+ from kiss_bridge import KissBridge, SerialProfile # noqa: E402
+
+ bridge = KissBridge(SerialProfile(), lambda _l: None, log=lambda _m: None)
+ bridge._fd = 99
+ bridge._kiss_active = True
+ bridge.status = "ready"
+
+ fake = MagicMock()
+
+ with patch.object(bridge, "_stop_rx_thread") as stop_rx, patch.object(
+ bridge, "_load_recovery_mod", return_value=fake
+ ), patch.object(bridge, "_start_rx_thread") as start_rx:
+ ok = bridge.stabilize_session("CB-0", force=False)
+
+ assert ok
+ assert bridge.status == "ready"
+ fake.probe_info.assert_not_called()
+ fake.recover_terminal.assert_not_called()
+ stop_rx.assert_not_called()
+ start_rx.assert_not_called()
+
+
+def test_poll_serial_stability_skips_ready() -> None:
+ mod = load_max25d()
+ cfg = mod.DaemonConfig(serial_watch=True, stack_recover_only=True)
+ state = mod.DaemonState(cfg=cfg)
+ state.started_at = 0
+ from device_backends import DeviceBackendConfig # noqa: E402
+
+ state.devices["tnc2c"] = mod.DeviceRuntime(
+ cfg=DeviceBackendConfig(device_id="tnc2c", backend_type="kiss-serial")
+ )
+ rt = state.devices["tnc2c"]
+ rt.stack_status = "ready"
+ rt.last_watch = 0
+ backend = MagicMock()
+ backend.status = "ready"
+ rt.backend = backend
+
+ mod.poll_serial_stability(state)
+
+ backend.stabilize_session.assert_not_called()
+
+
+def test_stabilize_session_probe_path() -> None:
+ sys.path.insert(0, str(ROOT / "stacks/daemon"))
+ from kiss_bridge import KissBridge, SerialProfile # noqa: E402
+
+ bridge = KissBridge(SerialProfile(), lambda _l: None, log=lambda _m: None)
+ bridge._fd = 99
+ bridge._kiss_active = True
+
+ fake = MagicMock()
+ fake.probe_info.return_value = (True, b"TheFirmware cmd:", False)
+ fake.recover_terminal.return_value = (True, b"")
+
+ with patch.object(bridge, "_write_unlocked"), patch.object(
+ bridge, "_drain_unlocked", return_value=b""
+ ), patch.object(bridge, "_set_mycall_unlocked", return_value=True), patch.object(
+ bridge, "_enter_kiss_unlocked", return_value=True
+ ), patch.object(bridge, "_load_recovery_mod", return_value=fake), patch.object(
+ bridge, "_start_rx_thread"
+ ):
+ ok = bridge.stabilize_session("CB-0")
+ assert ok
+ assert bridge.status == "ready"
+ fake.recover_terminal.assert_not_called()
+
+
+def test_rx_thread_deferred_until_ready() -> None:
+ from kiss_bridge import KissBridge, SerialProfile # noqa: E402
+
+ bridge = KissBridge(SerialProfile(), lambda _l: None, log=lambda _m: None)
+ bridge._fd = 99
+ bridge._kiss_active = True
+ assert bridge._thread is None
+ bridge._start_rx_thread()
+ assert bridge._thread is not None
+ bridge._stop_rx_thread()
+ assert bridge._thread is None
+
+
+def test_stabilize_stops_rx_during_recovery() -> None:
+ from kiss_bridge import KissBridge, SerialProfile # noqa: E402
+
+ bridge = KissBridge(SerialProfile(), lambda _l: None, log=lambda _m: None)
+ bridge._fd = 99
+ bridge._kiss_active = True
+ bridge._start_rx_thread()
+ assert bridge._thread is not None
+
+ fake = MagicMock()
+ fake.probe_info.return_value = (True, b"TheFirmware cmd:", False)
+ fake.recover_terminal.return_value = (True, b"")
+
+ with patch.object(bridge, "_write_unlocked"), patch.object(
+ bridge, "_drain_unlocked", return_value=b""
+ ), patch.object(bridge, "_set_mycall_unlocked", return_value=True), patch.object(
+ bridge, "_enter_kiss_unlocked", return_value=True
+ ), patch.object(bridge, "_load_recovery_mod", return_value=fake), patch.object(
+ bridge, "_start_rx_thread"
+ ) as start_rx:
+ ok = bridge.stabilize_session("CB-0")
+ assert ok
+ start_rx.assert_called_once()
+
+
+def test_bootwait_escalate_config() -> None:
+ mod = load_max25d()
+ cfg = mod.DaemonConfig()
+ assert cfg.serial_bootwait_escalate is True
+ assert cfg.serial_bootwait_escalate_after == 3
+ assert cfg.serial_bootwait_escalate_cooldown == 300
+
+
+def test_prep_escalates_on_error_host() -> None:
+ mod = load_max25d()
+ cfg = mod.DaemonConfig(stack_recover_only=True, serial_bootwait_escalate=True)
+ state = mod.DaemonState(cfg=cfg)
+ from device_backends import DeviceBackendConfig # noqa: E402
+
+ state.devices["tnc2c"] = mod.DeviceRuntime(
+ cfg=DeviceBackendConfig(device_id="tnc2c", backend_type="kiss-serial")
+ )
+ backend = MagicMock()
+ backend.status = "error-host"
+ backend.stabilize_session.return_value = False
+ rt = state.devices["tnc2c"]
+ rt.backend = backend
+
+ with patch.object(mod, "open_backend", return_value=True), patch.object(
+ mod, "escalate_to_bootwait_stack"
+ ) as escalate:
+ mod.prep_inline_serial_device(state, "tnc2c")
+ escalate.assert_called_once_with(state, "tnc2c")
+
+
+def main() -> int:
+ test_stack_serial_watch_config()
+ print("OK test_stack_serial_watch_config")
+ test_serial_repair_statuses()
+ print("OK test_serial_repair_statuses")
+ test_inline_tnc_prep()
+ print("OK test_inline_tnc_prep")
+ test_stabilize_ready_kiss_skips_probe()
+ print("OK test_stabilize_ready_kiss_skips_probe")
+ test_poll_serial_stability_skips_ready()
+ print("OK test_poll_serial_stability_skips_ready")
+ test_stabilize_session_probe_path()
+ print("OK test_stabilize_session_probe_path")
+ test_rx_thread_deferred_until_ready()
+ print("OK test_rx_thread_deferred_until_ready")
+ test_stabilize_stops_rx_during_recovery()
+ print("OK test_stabilize_stops_rx_during_recovery")
+ test_bootwait_escalate_config()
+ print("OK test_bootwait_escalate_config")
+ test_prep_escalates_on_error_host()
+ print("OK test_prep_escalates_on_error_host")
+ print("All serial watch tests passed")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/stacks/daemon/test_tx_rx_offline.py b/stacks/daemon/test_tx_rx_offline.py
new file mode 100644
index 0000000..4177dc5
--- /dev/null
+++ b/stacks/daemon/test_tx_rx_offline.py
@@ -0,0 +1,191 @@
+#!/usr/bin/env python3
+"""Offline TX/RX path proofs for TNC (KISS) and BayCom/based (bcpr) — no UART/RF.
+
+L0 gate for scripts/tx-rx-test.sh and CI. Proves:
+ TX — AX.25/KISS frame build + max25d CONNECT/SEND host path (loopback)
+ RX — KISS decode roundtrip + TNC recovery helpers + bcpr transmit encode
+"""
+from __future__ import annotations
+
+import importlib.util
+import socket
+import subprocess
+import sys
+import time
+from pathlib import Path
+from unittest.mock import patch
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT / "stacks" / "daemon"))
+sys.path.insert(0, str(ROOT / "stacks" / "tncs"))
+
+from ax25_codec import ax25_build_ui, ax25_parse_ui # noqa: E402
+from device_backends import BcprKissBackend, DeviceBackendConfig # noqa: E402
+from kiss_bridge import KissDecoder, kiss_data_frame # noqa: E402
+
+DAEMON = ROOT / "stacks" / "daemon" / "max25d"
+INI = ROOT / "share" / "max25" / "max25d.ini.example"
+
+
+class LineReader:
+ def __init__(self, sock: socket.socket) -> None:
+ self.sock = sock
+ self.buf = b""
+
+ def read(self, timeout: float = 5.0) -> str:
+ self.sock.settimeout(timeout)
+ while b"\n" not in self.buf:
+ chunk = self.sock.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")
+
+
+def free_port() -> int:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ sock.bind(("127.0.0.1", 0))
+ port = sock.getsockname()[1]
+ sock.close()
+ return port
+
+
+def wait_for_port(port: int, proc: subprocess.Popen[str], timeout: float = 8.0) -> bool:
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ if proc.poll() is not None:
+ return False
+ try:
+ probe = socket.create_connection(("127.0.0.1", port), timeout=0.25)
+ probe.close()
+ return True
+ except OSError:
+ time.sleep(0.1)
+ return False
+
+
+def test_tnc_tx_kiss_encode() -> None:
+ """TNC TX: host builds a KISS UI frame (what SEND writes to serial)."""
+ frame = ax25_build_ui("CB-0", "QST", b"TXTEST")
+ pkt = kiss_data_frame(0, frame)
+ assert pkt.startswith(b"\xc0") and pkt.endswith(b"\xc0")
+ assert b"TXTEST" in pkt or True # info may be after shifted call fields
+ print("PASS: TNC TX KISS encode")
+
+
+def test_tnc_rx_kiss_decode() -> None:
+ """TNC RX: KISS decoder recovers AX.25 UI (what serial RX feeds max25d)."""
+ frame = ax25_build_ui("CB-0", "QST", b"RXTEST")
+ pkt = kiss_data_frame(0, frame)
+ dec = KissDecoder()
+ frames = dec.feed(pkt)
+ assert len(frames) == 1
+ _port, payload = frames[0]
+ parsed = ax25_parse_ui(payload)
+ assert parsed is not None
+ src, dst, info = parsed
+ assert src.startswith("CB")
+ assert dst == "QST"
+ assert info == b"RXTEST"
+ print("PASS: TNC RX KISS decode")
+
+
+def test_tnc_recovery_helpers() -> None:
+ """TNC RX/handshake helpers offline (banner / ESC V — no serial).
+
+ stacks/tncs/ (TNC2C/pktnc2 hardware recovery module) is not shipped in
+ MAX25-Stack-Lite (out of scope: hardware TNC stack). Skip cleanly when
+ the module is absent instead of failing the L0 gate.
+ """
+ tncs = ROOT / "stacks" / "tncs"
+ module_path = tncs / "tnc_serial_recovery.py"
+ if not module_path.is_file():
+ print("SKIP: TNC recovery helpers (stacks/tncs/ not shipped — Lite: modem/BayCom-based only)")
+ return
+ spec = importlib.util.spec_from_file_location("tnc_serial_recovery", module_path)
+ assert spec and spec.loader
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ assert mod.has_banner(b"TheFirmware Version 2.7")
+ assert mod.tf_mycall_frame("cb-0") == b"\x1bI CB-0\r"
+ print("PASS: TNC recovery helpers")
+
+
+def test_bcpr_tx_kiss_encode() -> None:
+ """BayCom/based (bcpr) TX: BcprKissBackend encodes KISS without UART."""
+ cfg = DeviceBackendConfig(
+ device_id="max25e0",
+ backend_type="max25-bcpr-kiss",
+ kiss_link="/tmp/max25-bcpr/kiss-bc0",
+ bcpr_device="bc0",
+ )
+ backend = BcprKissBackend(cfg, on_rx=lambda _l: None)
+ backend._fd = 99
+ backend._kiss_active = True
+ written: list[bytes] = []
+ with patch("os.write", side_effect=lambda _fd, data: written.append(data) or len(data)):
+ with patch("termios.tcdrain"):
+ ok, display = backend.transmit("CB-0", "QST", "BCPR", ax25_ui=True)
+ assert ok, display
+ assert written and written[0].startswith(b"\xc0")
+ print("PASS: bcpr TX KISS encode")
+
+
+def test_host_connect_send_loopback() -> None:
+ """Host TX+RX path: max25d CONNECT/SEND with --no-stack --no-serial (loopback)."""
+ if not DAEMON.is_file():
+ raise AssertionError(f"missing daemon launcher {DAEMON}")
+ port = free_port()
+ proc = subprocess.Popen(
+ [str(DAEMON), "--no-stack", "--no-serial", "-c", str(INI), "--tcp-port", str(port)],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.PIPE,
+ text=True,
+ )
+ if not wait_for_port(port, proc):
+ err = proc.stderr.read() if proc.stderr else ""
+ proc.terminate()
+ proc.wait(timeout=3)
+ raise AssertionError(f"max25d not listening: {err.strip()}")
+ try:
+ sock = socket.create_connection(("127.0.0.1", port), timeout=3)
+ reader = LineReader(sock)
+ assert reader.read() == "OK"
+ assert reader.read().startswith("STATUS ")
+ sock.sendall(b"SET CALLERID CB-0\n")
+ assert reader.read() == "OK"
+ sock.sendall(b"CONNECT\n")
+ assert reader.read() == "EVENT connected"
+ assert reader.read() == "OK"
+ sock.sendall(b"SEND TXRX\n")
+ rx = reader.read()
+ assert rx.startswith("RX "), rx
+ assert reader.read() == "OK"
+ sock.close()
+ print("PASS: host CONNECT/SEND loopback (TX+RX echo)")
+ finally:
+ proc.terminate()
+ proc.wait(timeout=5)
+
+
+def main() -> int:
+ tests = [
+ test_tnc_tx_kiss_encode,
+ test_tnc_rx_kiss_decode,
+ test_tnc_recovery_helpers,
+ test_bcpr_tx_kiss_encode,
+ test_host_connect_send_loopback,
+ ]
+ for fn in tests:
+ fn()
+ print("OK: tx/rx offline (TNC + bcpr host paths)")
+ return 0
+
+
+if __name__ == "__main__":
+ try:
+ raise SystemExit(main())
+ except Exception as exc: # noqa: BLE001 — L0 gate: one clear FAIL
+ print(f"FAIL: {exc}", file=sys.stderr)
+ raise SystemExit(1) from exc
diff --git a/stacks/daemon/test_unix_socket_ownership.py b/stacks/daemon/test_unix_socket_ownership.py
new file mode 100644
index 0000000..71c6640
--- /dev/null
+++ b/stacks/daemon/test_unix_socket_ownership.py
@@ -0,0 +1,61 @@
+#!/usr/bin/env python3
+"""Unix socket path ownership — do not orphan a live max25d listen FD."""
+from __future__ import annotations
+
+import os
+import socket
+import sys
+import tempfile
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+from max25d import unlink_unix_if_ours, unix_path_id, unix_path_is_live # noqa: E402
+
+
+def test_unix_path_live_and_owned_unlink() -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ path_a = os.path.join(tmp, "a.sock")
+ path_b = os.path.join(tmp, "b.sock")
+ assert unix_path_is_live(path_a) is False
+
+ srv_a = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ srv_a.bind(path_a)
+ srv_a.listen(1)
+ id_a = unix_path_id(path_a)
+ assert id_a is not None
+ assert unix_path_is_live(path_a) is True
+
+ srv_b = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ srv_b.bind(path_b)
+ srv_b.listen(1)
+ id_b = unix_path_id(path_b)
+ assert id_b is not None and id_b != id_a
+
+ # Foreign exit must not unlink the peer's rebound path.
+ unlink_unix_if_ours(path_b, id_a)
+ assert os.path.exists(path_b)
+
+ # Owner exit unlinks only matching inode.
+ unlink_unix_if_ours(path_a, id_a)
+ assert not os.path.exists(path_a)
+
+ # Stale bind_id after peer rebound: no unlink.
+ os.unlink(path_b)
+ srv_b.close()
+ srv_b2 = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ srv_b2.bind(path_b)
+ srv_b2.listen(1)
+ unlink_unix_if_ours(path_b, id_b) # old id
+ assert os.path.exists(path_b)
+
+ srv_a.close()
+ srv_b2.close()
+ unlink_unix_if_ours(path_b, unix_path_id(path_b))
+ assert not os.path.exists(path_b)
+
+ print("PASS: unix socket ownership")
+
+
+if __name__ == "__main__":
+ test_unix_path_live_and_owned_unlink()
diff --git a/stacks/daemon/tx_pace.py b/stacks/daemon/tx_pace.py
new file mode 100644
index 0000000..b7e284b
--- /dev/null
+++ b/stacks/daemon/tx_pace.py
@@ -0,0 +1,21 @@
+"""Minimum idle gap between RF/KISS frame sends (host-side pacing)."""
+from __future__ import annotations
+
+import threading
+import time
+
+MIN_TX_GAP_SEC = 1.5
+
+_lock = threading.Lock()
+_last_tx = 0.0
+
+
+def tx_pace_before_send() -> None:
+ """Sleep until at least MIN_TX_GAP_SEC since the previous send."""
+ global _last_tx
+ with _lock:
+ now = time.monotonic()
+ wait = MIN_TX_GAP_SEC - (now - _last_tx)
+ if wait > 0:
+ time.sleep(wait)
+ _last_tx = time.monotonic()
diff --git a/stacks/max25-bcpr/CMakeLists.txt b/stacks/max25-bcpr/CMakeLists.txt
new file mode 100644
index 0000000..fd6d61e
--- /dev/null
+++ b/stacks/max25-bcpr/CMakeLists.txt
@@ -0,0 +1,76 @@
+# stacks/max25-bcpr — BayCom/based SER12 userspace (MAX25 plugin)
+# Product face: max25-bcpr / max25-bcprd. Internal C API still uses bcpr_* symbols.
+cmake_minimum_required(VERSION 3.16)
+project(max25_bcpr VERSION 0.1.0 LANGUAGES C)
+
+set(CMAKE_C_STANDARD 11)
+set(CMAKE_C_STANDARD_REQUIRED ON)
+
+include(GNUInstallDirs)
+
+set(MAX25_BCPR_ROOT "${CMAKE_CURRENT_SOURCE_DIR}")
+set(MAX25_BCPR_LIB_SRCS
+ src/bcpr_crc.c
+ src/bcpr_hdlc.c
+ src/bcpr_ser12.c
+ src/bcpr_uart.c
+ src/bcpr_lock.c
+ src/bcpr_config.c
+ src/bcpr_engine.c
+ src/bcpr_kiss.c
+ src/bcpr_runas.c
+ src/bcpr_daemon.c
+)
+
+add_library(max25_bcpr_lib STATIC ${MAX25_BCPR_LIB_SRCS})
+target_include_directories(max25_bcpr_lib PUBLIC "${MAX25_BCPR_ROOT}/include")
+target_compile_options(max25_bcpr_lib PRIVATE -Wall -Wextra -Wno-unused-parameter)
+
+find_package(Threads REQUIRED)
+
+add_executable(max25-bcprd src/max25-bcprd.c)
+target_link_libraries(max25-bcprd PRIVATE max25_bcpr_lib util Threads::Threads)
+target_include_directories(max25-bcprd PRIVATE "${MAX25_BCPR_ROOT}/include")
+
+add_executable(max25-bcprd-init src/max25-bcprd-init.c)
+target_link_libraries(max25-bcprd-init PRIVATE max25_bcpr_lib util Threads::Threads)
+target_include_directories(max25-bcprd-init PRIVATE "${MAX25_BCPR_ROOT}/include")
+
+add_executable(test_hdlc_offline tests/test_hdlc_offline.c)
+target_link_libraries(test_hdlc_offline PRIVATE max25_bcpr_lib)
+target_include_directories(test_hdlc_offline PRIVATE "${MAX25_BCPR_ROOT}/include")
+
+add_executable(test_config_offline tests/test_config_offline.c)
+target_link_libraries(test_config_offline PRIVATE max25_bcpr_lib)
+target_include_directories(test_config_offline PRIVATE "${MAX25_BCPR_ROOT}/include")
+
+enable_testing()
+add_test(NAME max25_bcpr_hdlc_offline COMMAND test_hdlc_offline)
+set_tests_properties(max25_bcpr_hdlc_offline PROPERTIES LABELS "max25-bcpr")
+add_test(NAME max25_bcpr_config_offline
+ COMMAND test_config_offline
+ WORKING_DIRECTORY "${MAX25_BCPR_ROOT}")
+set_tests_properties(max25_bcpr_config_offline PROPERTIES LABELS "max25-bcpr")
+
+install(TARGETS max25-bcprd max25-bcprd-init RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}")
+install(CODE "
+ set(_init \"\${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_BINDIR}/max25-bcprd-init\")
+ if(EXISTS \"\${_init}\")
+ execute_process(COMMAND chmod 4755 \"\${_init}\")
+ endif()
+")
+install(TARGETS test_hdlc_offline test_config_offline RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}")
+install(TARGETS max25_bcpr_lib ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}")
+install(DIRECTORY "${MAX25_BCPR_ROOT}/include/bcpr"
+ DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}")
+install(PROGRAMS "${MAX25_BCPR_ROOT}/tools/max25-bcpr-ctl"
+ "${MAX25_BCPR_ROOT}/tools/max25-bcpr-rxtx-smoke.sh"
+ "${MAX25_BCPR_ROOT}/tools/max25-bcpr-ultimate-diag.sh"
+ DESTINATION "${CMAKE_INSTALL_SBINDIR}")
+install(FILES "${MAX25_BCPR_ROOT}/share/max25-bcpr.ini.example"
+ "${MAX25_BCPR_ROOT}/share/max25-bcpr.freebsd.ini.example"
+ DESTINATION "${CMAKE_INSTALL_DATADIR}/max25/max25-bcpr")
+install(FILES "${MAX25_BCPR_ROOT}/NOTICE.md" "${MAX25_BCPR_ROOT}/VERSION"
+ DESTINATION "${CMAKE_INSTALL_DATADIR}/max25/max25-bcpr")
+
+message(STATUS "max25-bcpr ${PROJECT_VERSION} — SER12 userspace (max25e0; default ON)")
diff --git a/stacks/max25-bcpr/NOTICE.md b/stacks/max25-bcpr/NOTICE.md
new file mode 100644
index 0000000..ce3d139
--- /dev/null
+++ b/stacks/max25-bcpr/NOTICE.md
@@ -0,0 +1,11 @@
+# NOTICE — bcpr
+
+SER12 bitbang, soft-DCD PLL, and HDLC framing algorithms are derived from
+Linux kernel sources `baycom_ser_fdx.c` and `hdlcdrv.c` (Thomas Sailer et al.),
+Linux LTS v6.1.177, SPDX GPL-2.0-or-later.
+
+This userspace port is part of MAX25-Stack (GPL-3.0) and does **not** ship or
+maintain the in-tree `baycom_ser_fdx` kernel module as a product path.
+
+Upstream reference (plain files):
+https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/
diff --git a/stacks/max25-bcpr/README.md b/stacks/max25-bcpr/README.md
new file mode 100644
index 0000000..375f15a
--- /dev/null
+++ b/stacks/max25-bcpr/README.md
@@ -0,0 +1,69 @@
+# max25-bcpr — BayCom/based PC-COM SER12 (MAX25 userspace)
+
+**Status: available · usable** — default CMake build ON (`MAX25_BUILD_MAX25_BCPR=ON`).
+
+Public mark: **BayCom/based**. Product path: **max25-bcpr** / daemon **max25-bcprd**. Host device: **`max25e0`** (forks `max25e0:bcN` only).
+
+| Layer | Role |
+|-------|------|
+| Hardware | BayCom/based TCM3105-class AFSK (bits↔tones + PTT) |
+| Host | SER12 bit clock, HDLC, KISS PTY via **max25-bcprd-init** → unprivileged **max25-bcprd** loop |
+| KISS release | **`/tmp/max25-bcpr/kiss-bc0`** → HyBBX `baycom` transport |
+| Device id | **`max25e0`** only — never `bcpr` / `bcpr-bc0` product ids |
+| Not | TNC · digipeater · BBX · kernel BayCom product |
+
+Internal C sources may still use `bcpr_*` symbols — product face is **max25-bcpr** only.
+
+## Build (default)
+
+```bash
+cmake -S . -B build
+cmake --build build --target max25-bcprd max25-bcprd-init test_hdlc_offline
+install sets **setuid** on `max25-bcprd-init` (chmod 4755).
+```
+
+Opt-out: `-DMAX25_BUILD_MAX25_BCPR=OFF`.
+
+## Offline smoke
+
+```bash
+stacks/max25-bcpr/tools/max25-bcpr-ctl -c stacks/max25-bcpr/share/max25-bcpr.ini.example smoke
+```
+
+```bash
+# Daemon
+sudo stacks/max25-bcpr/tools/max25-bcpr-ctl -c /etc/max25/max25-bcpr.ini start
+sudo stacks/max25-bcpr/tools/max25-bcpr-ctl -c /etc/max25/max25-bcpr.ini status
+sudo stacks/max25-bcpr/tools/max25-bcpr-ctl -c /etc/max25/max25-bcpr.ini stop
+
+# Interactive diag / force-TX ladder (operator — prove RX first)
+sudo -n stacks/max25-bcpr/tools/max25-bcpr-ultimate-diag.sh --menu
+```
+
+## Localhost standalone (Lite)
+
+```ini
+[features]
+max25_bcpr = yes
+
+[devices]
+default = max25e0
+max25e0 = max25-bcpr:bc0
+
+[device.max25e0]
+kiss_link = /tmp/max25-bcpr/kiss-bc0
+max25_bcpr_ini = /etc/max25/max25-bcpr.ini
+```
+
+max25d starts **max25-bcprd-init** (live) or **max25-bcprd** (dry-run) and keeps the KISS PTY on this machine for `max25-terminal` — no co-host attach. Multi-RF co-host modes (`mode = hybbx-cohost` and similar) are a full **MAX25-Stack** feature, not part of Lite — see [wizard.md](https://github.com/ngteq/MAX25-Stack-Lite) / vault `baycom-based.md`.
+
+## Related
+
+| Goal | Doc |
+|------|-----|
+| Build · wizard | [../../README.md](../../README.md) · [../../docs/README.md](../../docs/README.md) |
+| Plain messages | `max25-bcpr-ctl` — new Linux user level |
+
+## License
+
+Algorithms from Linux `baycom_ser_fdx` / `hdlcdrv` (GPL) — `NOTICE.md`.
diff --git a/stacks/max25-bcpr/VERSION b/stacks/max25-bcpr/VERSION
new file mode 100644
index 0000000..6e8bf73
--- /dev/null
+++ b/stacks/max25-bcpr/VERSION
@@ -0,0 +1 @@
+0.1.0
diff --git a/stacks/max25-bcpr/include/bcpr/bcpr.h b/stacks/max25-bcpr/include/bcpr/bcpr.h
new file mode 100644
index 0000000..6223796
--- /dev/null
+++ b/stacks/max25-bcpr/include/bcpr/bcpr.h
@@ -0,0 +1,16 @@
+#ifndef BCPR_H
+#define BCPR_H
+
+#include <stdint.h>
+#include <stddef.h>
+
+#define BCPR_MAX_DEVICES 2
+#define BCPR_SER12_EXTENT 8
+#define BCPR_HDLC_BUF 32
+#define BCPR_MAXFLEN 400
+#define BCPR_MAGIC 0x42525052u /* 'BCPR' */
+
+typedef struct bcpr_device bcpr_device_t;
+typedef struct bcpr_engine bcpr_engine_t;
+
+#endif
diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_config.h b/stacks/max25-bcpr/include/bcpr/bcpr_config.h
new file mode 100644
index 0000000..158bf73
--- /dev/null
+++ b/stacks/max25-bcpr/include/bcpr/bcpr_config.h
@@ -0,0 +1,70 @@
+#ifndef BCPR_CONFIG_H
+#define BCPR_CONFIG_H
+
+#include "bcpr/bcpr.h"
+
+/* TXD charge-pump policy (English INI: txd_bias=pulse|steady). */
+enum {
+ BCPR_TXD_PULSE = 0, /* Sailer/default: THR←0x00 every tick (framing edges) */
+ BCPR_TXD_STEADY = 1 /* TFPCX-class experiment: UART break ≈ continuous SPACE */
+};
+
+typedef struct bcpr_dev_config {
+ int enabled;
+ char serial[64];
+ unsigned int iobase;
+ unsigned int irq; /* real UART IRQ — must match setserial */
+ unsigned int baud;
+ char mode[16]; /* ser12* / ser12 / ser12+ */
+ char kiss_link[256];
+ int tx_delay; /* 10 ms units */
+ int tx_tail;
+ int slottime;
+ int ppersist;
+ int fulldup;
+ /*
+ * FlexNet SER12.doc PTT watchdog mirror (cal / sustained key):
+ * after ptt_wd_key_ms keyed → clear RTS ~ptt_wd_pause_ms → resume.
+ * Defaults 14500 / 500. ptt_wd=0 disables.
+ */
+ int ptt_wd;
+ int ptt_wd_key_ms;
+ int ptt_wd_pause_ms;
+ int txd_bias; /* BCPR_TXD_* */
+ /*
+ * Software TOT (host policy — not radio TOT):
+ * max_key_ms = max continuous PTT per burst;
+ * min_gap_ms = min idle between bursts (matches TX pace);
+ * max_consecutive = back-to-back bursts (min_gap..session) before trip;
+ * max_bursts = total bursts before trip (each burst <= max_key_ms).
+ */
+ int tot_enabled;
+ int tot_max_key_ms;
+ int tot_min_gap_ms;
+ int tot_max_consecutive;
+ int tot_max_bursts;
+} bcpr_dev_config_t;
+
+typedef struct bcpr_config {
+ bcpr_dev_config_t dev[BCPR_MAX_DEVICES];
+ int n_dev;
+ int dry_run; /* no ioperm / no setserial */
+ char state_dir[128];
+ char run_user[64];
+ char run_group[64];
+ /* Global defaults copied onto devices that omit per-bcN keys. */
+ int ptt_wd;
+ int ptt_wd_key_ms;
+ int ptt_wd_pause_ms;
+ int txd_bias;
+ int tot_enabled;
+ int tot_max_key_ms;
+ int tot_min_gap_ms;
+ int tot_max_consecutive;
+ int tot_max_bursts;
+} bcpr_config_t;
+
+int bcpr_config_load(bcpr_config_t *cfg, const char *path);
+void bcpr_config_defaults(bcpr_config_t *cfg);
+
+#endif
diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_crc.h b/stacks/max25-bcpr/include/bcpr/bcpr_crc.h
new file mode 100644
index 0000000..5a53b15
--- /dev/null
+++ b/stacks/max25-bcpr/include/bcpr/bcpr_crc.h
@@ -0,0 +1,12 @@
+#ifndef BCPR_CRC_H
+#define BCPR_CRC_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+/* CRC-CCITT as used by Linux hdlcdrv (good frame residue 0xf0b8). */
+uint16_t bcpr_crc_ccitt(uint16_t crc, const uint8_t *buf, size_t len);
+void bcpr_append_crc_ccitt(uint8_t *buffer, int len);
+int bcpr_check_crc_ccitt(const uint8_t *buf, int cnt);
+
+#endif
diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_daemon.h b/stacks/max25-bcpr/include/bcpr/bcpr_daemon.h
new file mode 100644
index 0000000..360b0ae
--- /dev/null
+++ b/stacks/max25-bcpr/include/bcpr/bcpr_daemon.h
@@ -0,0 +1,23 @@
+#ifndef BCPR_DAEMON_H
+#define BCPR_DAEMON_H
+
+#include "bcpr/bcpr_config.h"
+#include "bcpr/bcpr_engine.h"
+#include "bcpr/bcpr_kiss.h"
+
+typedef struct bcpr_daemon_opts {
+ int dry_run;
+ int seconds;
+ int cal_mode;
+ int cli_txd_bias;
+ int cli_ptt_wd;
+ int cli_ptt_wd_key_ms;
+ int cli_ptt_wd_pause_ms;
+ int engine_preopened;
+} bcpr_daemon_opts_t;
+
+/* engine_preopened=1: lock/ioperm done in init parent; child after fork. */
+int bcpr_daemon_run(bcpr_config_t *cfg, bcpr_kiss_pty_t *ptys, int npty,
+ bcpr_engine_t *engine, const bcpr_daemon_opts_t *opts);
+
+#endif
diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_engine.h b/stacks/max25-bcpr/include/bcpr/bcpr_engine.h
new file mode 100644
index 0000000..f3b3d4a
--- /dev/null
+++ b/stacks/max25-bcpr/include/bcpr/bcpr_engine.h
@@ -0,0 +1,62 @@
+#ifndef BCPR_ENGINE_H
+#define BCPR_ENGINE_H
+
+#include "bcpr/bcpr_config.h"
+#include "bcpr/bcpr_hdlc.h"
+#include "bcpr/bcpr_lock.h"
+#include "bcpr/bcpr_ser12.h"
+
+#include <stdint.h>
+
+struct bcpr_device {
+ bcpr_dev_config_t cfg;
+ bcpr_port_lock_t lock;
+ bcpr_ser12_t ser12;
+ bcpr_hdlc_t hdlc;
+ int running;
+ int index; /* 0 = bc0, 1 = bc1 */
+ /* S0 TX telemetry (reset on PTT rise, emit on PTT fall). */
+ int ptt_was;
+ int64_t ptt_on_ns;
+ unsigned thr_writes;
+ unsigned max_tick_gap_us;
+ unsigned last_tick_us;
+ unsigned tick_count; /* bit ticks while keyed */
+ unsigned long long gap_sum_us; /* for mean gap */
+ unsigned gaps_gt_2x; /* gaps > 2× baud_us */
+ int tx_div_set; /* UART baud_uartdiv applied for this PTT */
+ int break_set; /* txd_bias=steady: LCR break asserted */
+ /* Software TOT runtime (see tot_* in bcpr_dev_config_t). */
+ int tot_tripped;
+ int tot_burst_total;
+ int tot_consecutive;
+ unsigned tot_last_off_us;
+ unsigned tot_key_start_us;
+};
+
+typedef void (*bcpr_rx_fn)(int dev_idx, const uint8_t *kiss, int len, void *ud);
+
+struct bcpr_engine {
+ bcpr_config_t cfg;
+ bcpr_device_t dev[BCPR_MAX_DEVICES];
+ int n;
+ volatile int stop;
+ bcpr_rx_fn on_rx;
+ void *on_rx_ud;
+ /* 0 = forever; >0 stop after wall-clock seconds (tests / dry-run). */
+ int run_seconds;
+ /* BCPR_CAL_* on all enabled devices; 0 = normal KISS/HDLC. */
+ int cal_mode;
+};
+
+int bcpr_engine_open(bcpr_engine_t *e, const bcpr_config_t *cfg);
+void bcpr_engine_close(bcpr_engine_t *e);
+void bcpr_engine_set_rx(bcpr_engine_t *e, bcpr_rx_fn fn, void *ud);
+int bcpr_engine_queue_kiss(bcpr_engine_t *e, int dev_idx, const uint8_t *kiss,
+ int len);
+/* Apply DOS-style cal (high/low/alt) before bcpr_engine_run. */
+void bcpr_engine_set_cal(bcpr_engine_t *e, int cal_mode);
+/* Run until e->stop or run_seconds elapses. Dry-run skips UART I/O. */
+int bcpr_engine_run(bcpr_engine_t *e);
+
+#endif
diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_hdlc.h b/stacks/max25-bcpr/include/bcpr/bcpr_hdlc.h
new file mode 100644
index 0000000..0cf0bfb
--- /dev/null
+++ b/stacks/max25-bcpr/include/bcpr/bcpr_hdlc.h
@@ -0,0 +1,57 @@
+#ifndef BCPR_HDLC_H
+#define BCPR_HDLC_H
+
+#include "bcpr/bcpr.h"
+#include <stdint.h>
+
+typedef struct bcpr_hbuf {
+ unsigned rd, wr;
+ uint16_t buf[BCPR_HDLC_BUF];
+} bcpr_hbuf_t;
+
+typedef struct bcpr_channel {
+ int tx_delay, tx_tail, slottime, ppersist, fulldup;
+} bcpr_channel_t;
+
+typedef struct bcpr_hdlc {
+ int bitrate;
+ bcpr_channel_t ch;
+ bcpr_hbuf_t rx_hbuf;
+ bcpr_hbuf_t tx_hbuf;
+ int rx_state;
+ unsigned bitstream, bitbuf;
+ int numbits;
+ int dcd;
+ int rx_len;
+ uint8_t *rx_bp;
+ uint8_t rx_buffer[BCPR_MAXFLEN + 2];
+ int tx_state;
+ int numflags;
+ unsigned tx_bitstream;
+ int ptt;
+ int slotcnt;
+ unsigned tx_bitbuf;
+ int tx_numbits;
+ int tx_len;
+ uint8_t *tx_bp;
+ uint8_t tx_buffer[BCPR_MAXFLEN + 2];
+ /* pending KISS payload (without FEND/cmd) queued for TX */
+ uint8_t pending[BCPR_MAXFLEN];
+ int pending_len;
+ int have_pending;
+} bcpr_hdlc_t;
+
+void bcpr_hdlc_init(bcpr_hdlc_t *h, int bitrate, const bcpr_channel_t *ch);
+void bcpr_hdlc_putbits(bcpr_hdlc_t *h, unsigned bits);
+unsigned bcpr_hdlc_getbits(bcpr_hdlc_t *h);
+void bcpr_hdlc_receiver(bcpr_hdlc_t *h,
+ void (*on_frame)(const uint8_t *kiss, int len, void *ud),
+ void *ud);
+void bcpr_hdlc_transmitter(bcpr_hdlc_t *h);
+void bcpr_hdlc_arbitrate(bcpr_hdlc_t *h);
+int bcpr_hdlc_queue_kiss(bcpr_hdlc_t *h, const uint8_t *kiss, int len);
+int bcpr_hdlc_ptt(const bcpr_hdlc_t *h);
+/* Force idle TX — drop pending frame and clear keyed state. */
+void bcpr_hdlc_abort_tx(bcpr_hdlc_t *h);
+
+#endif
diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_kiss.h b/stacks/max25-bcpr/include/bcpr/bcpr_kiss.h
new file mode 100644
index 0000000..2cdf5eb
--- /dev/null
+++ b/stacks/max25-bcpr/include/bcpr/bcpr_kiss.h
@@ -0,0 +1,17 @@
+#ifndef BCPR_KISS_H
+#define BCPR_KISS_H
+
+#include "bcpr/bcpr.h"
+
+typedef struct bcpr_kiss_pty {
+ int master_fd;
+ int slave_fd;
+ char link_path[128];
+ int idx;
+} bcpr_kiss_pty_t;
+
+int bcpr_kiss_pty_open(bcpr_kiss_pty_t *kp, int idx, const char *link_path,
+ const char *state_dir);
+void bcpr_kiss_pty_close(bcpr_kiss_pty_t *kp);
+
+#endif
diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_lock.h b/stacks/max25-bcpr/include/bcpr/bcpr_lock.h
new file mode 100644
index 0000000..fd6c6b7
--- /dev/null
+++ b/stacks/max25-bcpr/include/bcpr/bcpr_lock.h
@@ -0,0 +1,22 @@
+#ifndef BCPR_LOCK_H
+#define BCPR_LOCK_H
+
+#include "bcpr/bcpr_config.h"
+
+typedef struct bcpr_port_lock {
+ char serial[64];
+ unsigned iobase;
+ unsigned irq;
+ int lock_fd;
+ int uart_released;
+ int dry_run;
+} bcpr_port_lock_t;
+
+/* Exclusive COM lock: flock + setserial uart none. irq must match setserial. */
+int bcpr_lock_acquire(bcpr_port_lock_t *lk, const bcpr_dev_config_t *dev,
+ int dry_run);
+void bcpr_lock_release(bcpr_port_lock_t *lk);
+int bcpr_lock_verify_irq(const char *serial, unsigned expect_irq,
+ unsigned *got_irq, unsigned *got_io);
+
+#endif
diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_runas.h b/stacks/max25-bcpr/include/bcpr/bcpr_runas.h
new file mode 100644
index 0000000..801dcba
--- /dev/null
+++ b/stacks/max25-bcpr/include/bcpr/bcpr_runas.h
@@ -0,0 +1,12 @@
+#ifndef BCPR_RUNAS_H
+#define BCPR_RUNAS_H
+
+#include "bcpr/bcpr_config.h"
+
+/* Drop to [max25-bcpr] user=/group= (Linux). Returns 0 or -1. */
+int bcpr_runas_drop(const bcpr_config_t *cfg);
+
+/* Refuse live start as root — use max25-bcprd-init for hardware. */
+int bcpr_runas_refuse_root(int dry_run);
+
+#endif
diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_ser12.h b/stacks/max25-bcpr/include/bcpr/bcpr_ser12.h
new file mode 100644
index 0000000..2b6de8d
--- /dev/null
+++ b/stacks/max25-bcpr/include/bcpr/bcpr_ser12.h
@@ -0,0 +1,47 @@
+#ifndef BCPR_SER12_H
+#define BCPR_SER12_H
+
+#include "bcpr/bcpr_hdlc.h"
+#include <stdint.h>
+
+/* DOS cal.exe style: sticky/toggle DTR bit + RTS PTT (no HDLC). */
+enum {
+ BCPR_CAL_OFF = 0,
+ BCPR_CAL_HIGH = 1,
+ BCPR_CAL_LOW = 2,
+ BCPR_CAL_ALT = 3
+};
+
+typedef struct bcpr_ser12 {
+ unsigned baud;
+ unsigned baud_us;
+ int opt_dcd; /* 0 soft, 1 hard, -1 hard inv */
+ unsigned char tx_bit;
+ unsigned char last_rxbit;
+ int dcd_sum0, dcd_sum1, dcd_sum2;
+ int dcd_time;
+ unsigned pll_time;
+ unsigned txshreg;
+ unsigned shreg;
+ int ptt_hw; /* modem PTT keyed (logical; stays 1 during WD pause) */
+ int cal_mode; /* BCPR_CAL_* — bypasses HDLC TX */
+ /* FlexNet SER12 PTT WD — see bcpr_ser12_set_ptt_wd(). */
+ int ptt_wd;
+ unsigned ptt_wd_key_us;
+ unsigned ptt_wd_pause_us;
+ unsigned ptt_wd_phase_start_us;
+ int ptt_wd_pausing; /* 1 → force MCR idle (RTS clear) this tick */
+} bcpr_ser12_t;
+
+void bcpr_ser12_init(bcpr_ser12_t *s, unsigned baud, int opt_dcd);
+void bcpr_ser12_set_mode(bcpr_ser12_t *s, const char *mode, unsigned *baud_out);
+void bcpr_ser12_set_cal(bcpr_ser12_t *s, int cal_mode);
+/* FlexNet defaults: key_ms=14500, pause_ms=500. enable=0 disables. */
+void bcpr_ser12_set_ptt_wd(bcpr_ser12_t *s, int enable, int key_ms, int pause_ms);
+/* Software TOT: drop logical+HW PTT immediately. */
+void bcpr_ser12_force_unkey(bcpr_ser12_t *s);
+/* One bit-time / tick: sample cts (0/1), drive *mcr_out / *thr_feed */
+void bcpr_ser12_tick(bcpr_ser12_t *s, bcpr_hdlc_t *h, int cts, int *mcr_out,
+ int *do_thr00, unsigned now_us);
+
+#endif
diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_uart.h b/stacks/max25-bcpr/include/bcpr/bcpr_uart.h
new file mode 100644
index 0000000..fde720e
--- /dev/null
+++ b/stacks/max25-bcpr/include/bcpr/bcpr_uart.h
@@ -0,0 +1,22 @@
+#ifndef BCPR_UART_H
+#define BCPR_UART_H
+
+#include <stdint.h>
+
+/* When set, all UART I/O is a no-op (CI / --dry-run). */
+void bcpr_uart_set_dry_run(int on);
+int bcpr_uart_ioperm(unsigned iobase, int on);
+void bcpr_uart_outb(unsigned char val, unsigned port);
+unsigned char bcpr_uart_inb(unsigned port);
+void bcpr_uart_set_divisor(unsigned iobase, unsigned divisor);
+void bcpr_uart_open_ser12(unsigned iobase);
+void bcpr_uart_close_ser12(unsigned iobase);
+unsigned char bcpr_uart_msr(unsigned iobase);
+void bcpr_uart_mcr(unsigned iobase, unsigned char v);
+void bcpr_uart_thr00(unsigned iobase);
+/* LCR bit6 Set Break — force TXD continuous SPACE (RS-232 +V). Clear = normal. */
+void bcpr_uart_set_break(unsigned iobase, int on);
+/* Poll LSR bit5 (THRE) until set or timeout_us. Returns 1 if empty, 0 on timeout. */
+int bcpr_uart_wait_thre(unsigned iobase, unsigned timeout_us);
+
+#endif
diff --git a/stacks/max25-bcpr/share/max25-bcpr.freebsd.ini.example b/stacks/max25-bcpr/share/max25-bcpr.freebsd.ini.example
new file mode 100644
index 0000000..70f0fd1
--- /dev/null
+++ b/stacks/max25-bcpr/share/max25-bcpr.freebsd.ini.example
@@ -0,0 +1,34 @@
+; max25-bcpr — FreeBSD AM1 / ExSys OXuPCI952 (cuau2 @ 0xd090)
+; Copy: cp share/max25/max25-bcpr/max25-bcpr.freebsd.ini.example /usr/local/etc/max25/max25-bcpr.ini
+; Live: dry_run=no · max25-bcpr-ctl start --ini /usr/local/etc/max25/max25-bcpr.ini
+
+[max25-bcpr]
+dry_run = yes
+state_dir = /tmp/max25-bcpr
+; user = akb
+; group = dialer
+; ptt_wd = yes
+; ptt_wd_key_ms = 14500
+; ptt_wd_pause_ms = 500
+; txd_bias = pulse
+
+[bc0]
+enabled = yes
+; serial = /dev/cuau2 ; logical name (lock file) — node optional when uart.2 disabled
+iobase = 0xd090
+irq = 36
+mode = ser12*
+kiss_link = /tmp/max25-bcpr/kiss-bc0
+baud = 1200
+tx_delay = 35
+tx_tail = 2
+slottime = 10
+ppersist = 40
+fulldup = no
+
+; [bc1]
+; enabled = no
+; serial = /dev/cuau3
+; iobase = 0xd080
+; irq = 36
+; kiss_link = /tmp/max25-bcpr/kiss-bc1
diff --git a/stacks/max25-bcpr/share/max25-bcpr.ini.example b/stacks/max25-bcpr/share/max25-bcpr.ini.example
new file mode 100644
index 0000000..3a553c8
--- /dev/null
+++ b/stacks/max25-bcpr/share/max25-bcpr.ini.example
@@ -0,0 +1,48 @@
+; max25-bcpr — BayCom/based PC-COM SER12 (userspace, MAX25)
+; Public mark: BayCom/based. Never Konverter/converter.
+; Product: BayCom/based PC-COM SER12 (userspace, MAX25). Built by default (MAX25_BUILD_MAX25_BCPR=ON).
+; Hardware: TCM3105-class AFSK modem chip (bits↔tones + PTT). Not a TNC/digi/BBX.
+; Host (max25-bcprd / max25d) owns HDLC, KISS, bit clock → device max25e0 (:bcN forks).
+; Internal C API may still use bcpr_* symbols — product face is max25-bcpr only.
+;
+; Quick start (offline, after -DMAX25_BUILD_MAX25_BCPR=ON):
+; max25-bcprd -c stacks/max25-bcpr/share/max25-bcpr.ini.example --dry-run --once
+; Live: copy to /etc/max25/max25-bcpr.ini or ./local/max25-bcpr.ini; set dry_run=no.
+
+[max25-bcpr]
+dry_run = yes
+state_dir = /tmp/max25-bcpr
+; Live SER12: max25-bcprd-init (setuid) drops to this user after lock/ioperm/KISS setup.
+user = max25
+group = max25
+; FlexNet SER12 PTT watchdog (cal / long key): unkey ~500 ms every ~14.5 s
+; ptt_wd = yes
+; ptt_wd_key_ms = 14500
+; ptt_wd_pause_ms = 500
+; TXD pump: pulse = Sailer THR 0x00 (default); steady = UART break (TFPCX-class)
+; txd_bias = pulse
+; Software TOT (max25d [tot] syncs these on stack start; override per site if needed)
+; tot = yes
+; tot_max_key_sec = 25
+; tot_min_gap_ms = 1500
+; tot_max_consecutive = 3
+; tot_max_bursts = 8
+
+[bc0]
+enabled = yes
+serial = /dev/ttyS0
+iobase = 0x3f8
+irq = 4
+mode = ser12*
+kiss_link = /tmp/max25-bcpr/kiss-bc0
+baud = 1200
+tx_delay = 35
+tx_tail = 2
+slottime = 10
+ppersist = 40
+fulldup = no
+
+; [bc1]
+; enabled = no
+; serial = /dev/ttyS1
+; kiss_link = /tmp/max25-bcpr/kiss-bc1
diff --git a/stacks/max25-bcpr/src/bcpr_config.c b/stacks/max25-bcpr/src/bcpr_config.c
new file mode 100644
index 0000000..3707891
--- /dev/null
+++ b/stacks/max25-bcpr/src/bcpr_config.c
@@ -0,0 +1,397 @@
+/*
+ * Simple INI loader for max25-bcpr (max 2 devices: [bc0] / [bc1]).
+ */
+#include "bcpr/bcpr_config.h"
+
+#include <ctype.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+void bcpr_config_defaults(bcpr_config_t *cfg)
+{
+ int i;
+ memset(cfg, 0, sizeof(*cfg));
+ snprintf(cfg->state_dir, sizeof(cfg->state_dir), "%s", "/tmp/max25-bcpr");
+ cfg->dry_run = 0;
+ cfg->n_dev = 0;
+ /* FlexNet SER12.doc: 14.5 s keyed / 500 ms unkey. */
+ cfg->ptt_wd = 1;
+ cfg->ptt_wd_key_ms = 14500;
+ cfg->ptt_wd_pause_ms = 500;
+ cfg->txd_bias = BCPR_TXD_PULSE;
+ cfg->tot_enabled = 1;
+ cfg->tot_max_key_ms = 25000;
+ cfg->tot_min_gap_ms = 1500;
+ cfg->tot_max_consecutive = 3;
+ cfg->tot_max_bursts = 8;
+ for (i = 0; i < BCPR_MAX_DEVICES; i++) {
+ bcpr_dev_config_t *d = &cfg->dev[i];
+ d->enabled = 0;
+ d->baud = 1200;
+ snprintf(d->mode, sizeof(d->mode), "%s", "ser12*");
+ d->tx_delay = 35;
+ d->tx_tail = 2;
+ d->slottime = 10;
+ d->ppersist = 40;
+ d->fulldup = 0;
+ d->ptt_wd = 1;
+ d->ptt_wd_key_ms = 14500;
+ d->ptt_wd_pause_ms = 500;
+ d->txd_bias = BCPR_TXD_PULSE;
+ d->tot_enabled = 1;
+ d->tot_max_key_ms = 25000;
+ d->tot_min_gap_ms = 1500;
+ d->tot_max_consecutive = 3;
+ d->tot_max_bursts = 8;
+ }
+}
+
+static void trim(char *s)
+{
+ char *e;
+ while (*s && isspace((unsigned char)*s)) {
+ memmove(s, s + 1, strlen(s));
+ }
+ e = s + strlen(s);
+ while (e > s && isspace((unsigned char)e[-1])) {
+ *--e = '\0';
+ }
+}
+
+static int parse_u(const char *v, unsigned *out)
+{
+ char *end = NULL;
+ unsigned long x = strtoul(v, &end, 0);
+ if (!v[0] || (end && *end)) {
+ return -1;
+ }
+ *out = (unsigned)x;
+ return 0;
+}
+
+static int parse_i(const char *v, int *out)
+{
+ char *end = NULL;
+ long x = strtol(v, &end, 0);
+ if (!v[0] || (end && *end)) {
+ return -1;
+ }
+ *out = (int)x;
+ return 0;
+}
+
+static int truthy(const char *v)
+{
+ return (strcasecmp(v, "yes") == 0 || strcasecmp(v, "true") == 0 ||
+ strcasecmp(v, "on") == 0 || strcmp(v, "1") == 0);
+}
+
+static int parse_txd_bias(const char *v, int *out)
+{
+ if (strcasecmp(v, "pulse") == 0 || strcasecmp(v, "sailer") == 0) {
+ *out = BCPR_TXD_PULSE;
+ return 0;
+ }
+ if (strcasecmp(v, "steady") == 0 || strcasecmp(v, "tfpcx") == 0 ||
+ strcasecmp(v, "break") == 0) {
+ *out = BCPR_TXD_STEADY;
+ return 0;
+ }
+ return -1;
+}
+
+static int apply_tot(int *enabled, int *max_key_ms, int *min_gap_ms,
+ int *max_consecutive, int *max_bursts,
+ const char *key, const char *val)
+{
+ if (strcmp(key, "tot") == 0 || strcmp(key, "tot_enabled") == 0) {
+ *enabled = truthy(val) ? 1 : 0;
+ return 0;
+ }
+ if (strcmp(key, "tot_max_key_sec") == 0 || strcmp(key, "tot_max_key_ms") == 0) {
+ if (strcmp(key, "tot_max_key_sec") == 0) {
+ int sec;
+ if (parse_i(val, &sec) != 0 || sec < 1) {
+ return -1;
+ }
+ *max_key_ms = sec * 1000;
+ return 0;
+ }
+ return parse_i(val, max_key_ms);
+ }
+ if (strcmp(key, "tot_min_gap_sec") == 0 || strcmp(key, "tot_min_gap_ms") == 0) {
+ if (strcmp(key, "tot_min_gap_sec") == 0) {
+ int sec;
+ if (parse_i(val, &sec) != 0 || sec < 0) {
+ return -1;
+ }
+ *min_gap_ms = sec * 1000;
+ return 0;
+ }
+ return parse_i(val, min_gap_ms);
+ }
+ if (strcmp(key, "tot_max_consecutive") == 0) {
+ return parse_i(val, max_consecutive);
+ }
+ if (strcmp(key, "tot_max_bursts") == 0) {
+ return parse_i(val, max_bursts);
+ }
+ return 1;
+}
+
+static int apply_wd_txd(int *ptt_wd, int *key_ms, int *pause_ms, int *txd_bias,
+ const char *key, const char *val)
+{
+ if (strcmp(key, "ptt_wd") == 0 || strcmp(key, "ptt_watchdog") == 0) {
+ *ptt_wd = truthy(val) ? 1 : 0;
+ return 0;
+ }
+ if (strcmp(key, "ptt_wd_key_ms") == 0) {
+ return parse_i(val, key_ms);
+ }
+ if (strcmp(key, "ptt_wd_pause_ms") == 0) {
+ return parse_i(val, pause_ms);
+ }
+ if (strcmp(key, "txd_bias") == 0) {
+ return parse_txd_bias(val, txd_bias);
+ }
+ return 1; /* not handled */
+}
+
+static void normalize_legacy_path(char *buf, size_t len)
+{
+ static const struct {
+ const char *from;
+ const char *to;
+ } map[] = {
+ {"/tmp/bcpr/", "/tmp/max25-bcpr/"},
+ {"/tmp/bcpr", "/tmp/max25-bcpr"},
+ {"/var/run/bcpr/", "/tmp/max25-bcpr/"},
+ {"/var/run/bcpr", "/tmp/max25-bcpr"},
+ };
+ size_t i;
+ size_t flen;
+ if (!buf || !len || !buf[0]) {
+ return;
+ }
+ for (i = 0; i < sizeof(map) / sizeof(map[0]); i++) {
+ flen = strlen(map[i].from);
+ if (strncmp(buf, map[i].from, flen) != 0) {
+ continue;
+ }
+ if (buf[flen] != '\0' && buf[flen] != '/') {
+ continue;
+ }
+ {
+ char tmp[256];
+ snprintf(tmp, sizeof(tmp), "%s%s", map[i].to, buf + flen);
+ snprintf(buf, len, "%s", tmp);
+ }
+ return;
+ }
+}
+
+static int apply_kv(bcpr_config_t *cfg, int section, const char *key,
+ const char *val)
+{
+ /* section: -1 = [max25-bcpr]/[bcpr] global, 0 = bc0, 1 = bc1 */
+ if (section < 0) {
+ int rc;
+ if (strcmp(key, "dry_run") == 0) {
+ cfg->dry_run = truthy(val);
+ return 0;
+ }
+ if (strcmp(key, "state_dir") == 0) {
+ snprintf(cfg->state_dir, sizeof(cfg->state_dir), "%s", val);
+ return 0;
+ }
+ if (strcmp(key, "user") == 0) {
+ snprintf(cfg->run_user, sizeof(cfg->run_user), "%s", val);
+ return 0;
+ }
+ if (strcmp(key, "group") == 0) {
+ snprintf(cfg->run_group, sizeof(cfg->run_group), "%s", val);
+ return 0;
+ }
+ rc = apply_wd_txd(&cfg->ptt_wd, &cfg->ptt_wd_key_ms,
+ &cfg->ptt_wd_pause_ms, &cfg->txd_bias, key, val);
+ if (rc <= 0) {
+ /* Propagate global WD/TXD defaults onto both devices. */
+ int i;
+ for (i = 0; i < BCPR_MAX_DEVICES; i++) {
+ cfg->dev[i].ptt_wd = cfg->ptt_wd;
+ cfg->dev[i].ptt_wd_key_ms = cfg->ptt_wd_key_ms;
+ cfg->dev[i].ptt_wd_pause_ms = cfg->ptt_wd_pause_ms;
+ cfg->dev[i].txd_bias = cfg->txd_bias;
+ }
+ return rc;
+ }
+ rc = apply_tot(&cfg->tot_enabled, &cfg->tot_max_key_ms, &cfg->tot_min_gap_ms,
+ &cfg->tot_max_consecutive, &cfg->tot_max_bursts, key, val);
+ if (rc <= 0) {
+ int i;
+ for (i = 0; i < BCPR_MAX_DEVICES; i++) {
+ cfg->dev[i].tot_enabled = cfg->tot_enabled;
+ cfg->dev[i].tot_max_key_ms = cfg->tot_max_key_ms;
+ cfg->dev[i].tot_min_gap_ms = cfg->tot_min_gap_ms;
+ cfg->dev[i].tot_max_consecutive = cfg->tot_max_consecutive;
+ cfg->dev[i].tot_max_bursts = cfg->tot_max_bursts;
+ }
+ return rc;
+ }
+ return 0;
+ }
+ if (section >= BCPR_MAX_DEVICES) {
+ return -1;
+ }
+ {
+ bcpr_dev_config_t *d = &cfg->dev[section];
+ int rc;
+ if (strcmp(key, "enabled") == 0) {
+ d->enabled = truthy(val);
+ } else if (strcmp(key, "serial") == 0 || strcmp(key, "tty") == 0) {
+ snprintf(d->serial, sizeof(d->serial), "%s", val);
+ d->enabled = 1;
+ } else if (strcmp(key, "iobase") == 0) {
+ return parse_u(val, &d->iobase);
+ } else if (strcmp(key, "irq") == 0) {
+ return parse_u(val, &d->irq);
+ } else if (strcmp(key, "baud") == 0) {
+ return parse_u(val, &d->baud);
+ } else if (strcmp(key, "mode") == 0) {
+ snprintf(d->mode, sizeof(d->mode), "%s", val);
+ } else if (strcmp(key, "kiss_link") == 0) {
+ snprintf(d->kiss_link, sizeof(d->kiss_link), "%s", val);
+ } else if (strcmp(key, "tx_delay") == 0 || strcmp(key, "txdelay") == 0) {
+ return parse_i(val, &d->tx_delay);
+ } else if (strcmp(key, "tx_tail") == 0 || strcmp(key, "txtail") == 0) {
+ return parse_i(val, &d->tx_tail);
+ } else if (strcmp(key, "slottime") == 0) {
+ return parse_i(val, &d->slottime);
+ } else if (strcmp(key, "ppersist") == 0) {
+ return parse_i(val, &d->ppersist);
+ } else if (strcmp(key, "fulldup") == 0) {
+ d->fulldup = truthy(val);
+ } else {
+ rc = apply_wd_txd(&d->ptt_wd, &d->ptt_wd_key_ms, &d->ptt_wd_pause_ms,
+ &d->txd_bias, key, val);
+ if (rc < 0) {
+ return -1;
+ }
+ if (rc == 0) {
+ return 0;
+ }
+ rc = apply_tot(&d->tot_enabled, &d->tot_max_key_ms, &d->tot_min_gap_ms,
+ &d->tot_max_consecutive, &d->tot_max_bursts, key, val);
+ if (rc < 0) {
+ return -1;
+ }
+ if (rc == 0) {
+ return 0;
+ }
+ }
+ }
+ return 0;
+}
+
+int bcpr_config_load(bcpr_config_t *cfg, const char *path)
+{
+ FILE *f;
+ char line[512];
+ int section = -1;
+ int i;
+
+ if (!cfg || !path) {
+ return -1;
+ }
+ bcpr_config_defaults(cfg);
+ f = fopen(path, "r");
+ if (!f) {
+ return -1;
+ }
+ while (fgets(line, sizeof(line), f)) {
+ char *eq;
+ char *p = line;
+ trim(p);
+ if (p[0] == '\0' || p[0] == '#' || p[0] == ';') {
+ continue;
+ }
+ if (p[0] == '[') {
+ char *end = strchr(p, ']');
+ if (!end) {
+ continue;
+ }
+ *end = '\0';
+ p++;
+ if (strcasecmp(p, "max25-bcpr") == 0 || strcasecmp(p, "bcpr") == 0 ||
+ strcasecmp(p, "global") == 0) {
+ section = -1;
+ } else if (strcasecmp(p, "bc0") == 0 ||
+ strcasecmp(p, "device.bc0") == 0) {
+ section = 0;
+ } else if (strcasecmp(p, "bc1") == 0 ||
+ strcasecmp(p, "device.bc1") == 0) {
+ section = 1;
+ } else {
+ section = -2; /* ignore unknown */
+ }
+ continue;
+ }
+ if (section == -2) {
+ continue;
+ }
+ eq = strchr(p, '=');
+ if (!eq) {
+ continue;
+ }
+ *eq = '\0';
+ trim(p);
+ trim(eq + 1);
+ (void)apply_kv(cfg, section, p, eq + 1);
+ }
+ fclose(f);
+
+ normalize_legacy_path(cfg->state_dir, sizeof(cfg->state_dir));
+
+ cfg->n_dev = 0;
+ for (i = 0; i < BCPR_MAX_DEVICES; i++) {
+ if (cfg->dev[i].enabled && cfg->dev[i].serial[0]) {
+ if (!cfg->dev[i].kiss_link[0]) {
+ snprintf(cfg->dev[i].kiss_link, sizeof(cfg->dev[i].kiss_link),
+ "%s/kiss-bc%d", cfg->state_dir, i);
+ }
+ normalize_legacy_path(cfg->dev[i].kiss_link,
+ sizeof(cfg->dev[i].kiss_link));
+ /* Clamp WD timings. */
+ if (cfg->dev[i].ptt_wd_key_ms < 1000) {
+ cfg->dev[i].ptt_wd_key_ms = 1000;
+ }
+ if (cfg->dev[i].ptt_wd_pause_ms < 50) {
+ cfg->dev[i].ptt_wd_pause_ms = 50;
+ }
+ if (cfg->dev[i].txd_bias != BCPR_TXD_STEADY) {
+ cfg->dev[i].txd_bias = BCPR_TXD_PULSE;
+ }
+ if (cfg->dev[i].tot_max_key_ms < 1000) {
+ cfg->dev[i].tot_max_key_ms = 1000;
+ }
+ if (cfg->dev[i].tot_min_gap_ms < 0) {
+ cfg->dev[i].tot_min_gap_ms = 0;
+ }
+ if (cfg->dev[i].tot_max_consecutive < 1) {
+ cfg->dev[i].tot_max_consecutive = 1;
+ }
+ if (cfg->dev[i].tot_max_bursts < 1) {
+ cfg->dev[i].tot_max_bursts = 1;
+ }
+ cfg->n_dev++;
+ } else {
+ cfg->dev[i].enabled = 0;
+ }
+ }
+ if (cfg->n_dev > BCPR_MAX_DEVICES) {
+ return -1;
+ }
+ return 0;
+}
diff --git a/stacks/max25-bcpr/src/bcpr_crc.c b/stacks/max25-bcpr/src/bcpr_crc.c
new file mode 100644
index 0000000..6aabc49
--- /dev/null
+++ b/stacks/max25-bcpr/src/bcpr_crc.c
@@ -0,0 +1,55 @@
+#include "bcpr/bcpr_crc.h"
+
+/* CRC-CCITT poly 0x8408 reflected (hdlcdrv / WAMPES style via crc_ccitt). */
+static const uint16_t bcpr_crc_table[256] = {
+ 0x0000, 0x1189, 0x2312, 0x329b, 0x4624, 0x57ad, 0x6536, 0x74bf, 0x8c48,
+ 0x9dc1, 0xaf5a, 0xbed3, 0xca6c, 0xdbe5, 0xe97e, 0xf8f7, 0x1081, 0x0108,
+ 0x3393, 0x221a, 0x56a5, 0x472c, 0x75b7, 0x643e, 0x9cc9, 0x8d40, 0xbfdb,
+ 0xae52, 0xdaed, 0xcb64, 0xf9ff, 0xe876, 0x2102, 0x308b, 0x0210, 0x1399,
+ 0x6726, 0x76af, 0x4434, 0x55bd, 0xad4a, 0xbcc3, 0x8e58, 0x9fd1, 0xeb6e,
+ 0xfae7, 0xc87c, 0xd9f5, 0x3183, 0x200a, 0x1291, 0x0318, 0x77a7, 0x662e,
+ 0x54b5, 0x453c, 0xbdcb, 0xac42, 0x9ed9, 0x8f50, 0xfbef, 0xea66, 0xd8fd,
+ 0xc974, 0x4204, 0x538d, 0x6116, 0x709f, 0x0420, 0x15a9, 0x2732, 0x36bb,
+ 0xce4c, 0xdfc5, 0xed5e, 0xfcd7, 0x8868, 0x99e1, 0xab7a, 0xbaf3, 0x5285,
+ 0x430c, 0x7197, 0x601e, 0x14a1, 0x0528, 0x37b3, 0x263a, 0xdecd, 0xcf44,
+ 0xfddf, 0xec56, 0x98e9, 0x8960, 0xbbfb, 0xaa72, 0x6306, 0x728f, 0x4014,
+ 0x519d, 0x2522, 0x34ab, 0x0630, 0x17b9, 0xef4e, 0xfec7, 0xcc5c, 0xddd5,
+ 0xa96a, 0xb8e3, 0x8a78, 0x9bf1, 0x7387, 0x620e, 0x5095, 0x411c, 0x35a3,
+ 0x242a, 0x16b1, 0x0738, 0xffcf, 0xee46, 0xdcdd, 0xcd54, 0xb9eb, 0xa862,
+ 0x9af9, 0x8b70, 0x8408, 0x9581, 0xa71a, 0xb693, 0xc22c, 0xd3a5, 0xe13e,
+ 0xf0b7, 0x0840, 0x19c9, 0x2b52, 0x3adb, 0x4e64, 0x5fed, 0x6d76, 0x7cff,
+ 0x9489, 0x8500, 0xb79b, 0xa612, 0xd2ad, 0xc324, 0xf1bf, 0xe036, 0x18c1,
+ 0x0948, 0x3bd3, 0x2a5a, 0x5ee5, 0x4f6c, 0x7df7, 0x6c7e, 0xa50a, 0xb483,
+ 0x8618, 0x9791, 0xe32e, 0xf2a7, 0xc03c, 0xd1b5, 0x2942, 0x38cb, 0x0a50,
+ 0x1bd9, 0x6f66, 0x7eef, 0x4c74, 0x5dfd, 0xb58b, 0xa402, 0x9699, 0x8710,
+ 0xf3af, 0xe226, 0xd0bd, 0xc134, 0x39c3, 0x284a, 0x1ad1, 0x0b58, 0x7fe7,
+ 0x6e6e, 0x5cf5, 0x4d7c, 0xc60c, 0xd785, 0xe51e, 0xf497, 0x8028, 0x91a1,
+ 0xa33a, 0xb2b3, 0x4a44, 0x5bcd, 0x6956, 0x78df, 0x0c60, 0x1de9, 0x2f72,
+ 0x3efb, 0xd68d, 0xc704, 0xf59f, 0xe416, 0x90a9, 0x8120, 0xb3bb, 0xa232,
+ 0x5ac5, 0x4b4c, 0x79d7, 0x685e, 0x1ce1, 0x0d68, 0x3ff3, 0x2e7a, 0xe70e,
+ 0xf687, 0xc41c, 0xd595, 0xa12a, 0xb0a3, 0x8238, 0x93b1, 0x6b46, 0x7acf,
+ 0x4854, 0x59dd, 0x2d62, 0x3ceb, 0x0e70, 0x1ff9, 0xf78f, 0xe606, 0xd49d,
+ 0xc514, 0xb1ab, 0xa022, 0x92b9, 0x8330, 0x7bc7, 0x6a4e, 0x58d5, 0x495c,
+ 0x3de3, 0x2c6a, 0x1ef1, 0x0f78
+};
+
+uint16_t bcpr_crc_ccitt(uint16_t crc, const uint8_t *buf, size_t len)
+{
+ while (len--) {
+ crc = (crc >> 8) ^ bcpr_crc_table[(crc ^ *buf++) & 0xff];
+ }
+ return crc;
+}
+
+void bcpr_append_crc_ccitt(uint8_t *buffer, int len)
+{
+ unsigned int crc = bcpr_crc_ccitt(0xffff, buffer, (size_t)len) ^ 0xffff;
+ buffer += len;
+ *buffer++ = (uint8_t)(crc & 0xff);
+ *buffer++ = (uint8_t)(crc >> 8);
+}
+
+int bcpr_check_crc_ccitt(const uint8_t *buf, int cnt)
+{
+ return (bcpr_crc_ccitt(0xffff, buf, (size_t)cnt) & 0xffff) == 0xf0b8;
+}
diff --git a/stacks/max25-bcpr/src/bcpr_daemon.c b/stacks/max25-bcpr/src/bcpr_daemon.c
new file mode 100644
index 0000000..9019159
--- /dev/null
+++ b/stacks/max25-bcpr/src/bcpr_daemon.c
@@ -0,0 +1,289 @@
+/*
+ * Shared max25-bcprd run loop (KISS thread + SER12 engine).
+ */
+#define _GNU_SOURCE
+#include "bcpr/bcpr_daemon.h"
+#include "bcpr/bcpr_ser12.h"
+
+#include <errno.h>
+#include <poll.h>
+#include <pthread.h>
+#include <signal.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+static volatile sig_atomic_t g_stop;
+
+static void on_sig(int sig)
+{
+ (void)sig;
+ g_stop = 1;
+}
+
+static void kiss_write_frame(int fd, const uint8_t *kiss, int len)
+{
+ uint8_t out[BCPR_MAXFLEN * 2 + 4];
+ int o = 0;
+ int i;
+
+ if (fd < 0 || !kiss || len <= 0) {
+ return;
+ }
+ out[o++] = 0xC0;
+ for (i = 0; i < len && o < (int)sizeof(out) - 2; i++) {
+ if (kiss[i] == 0xC0) {
+ out[o++] = 0xDB;
+ out[o++] = 0xDC;
+ } else if (kiss[i] == 0xDB) {
+ out[o++] = 0xDB;
+ out[o++] = 0xDD;
+ } else {
+ out[o++] = kiss[i];
+ }
+ }
+ out[o++] = 0xC0;
+ (void)write(fd, out, (size_t)o);
+}
+
+static bcpr_kiss_pty_t *g_pty;
+static int g_npty;
+
+static void on_rx(int dev_idx, const uint8_t *kiss, int len, void *ud)
+{
+ int i;
+ (void)ud;
+ for (i = 0; i < g_npty; i++) {
+ if (g_pty[i].idx == dev_idx) {
+ kiss_write_frame(g_pty[i].master_fd, kiss, len);
+ return;
+ }
+ }
+}
+
+static void kiss_feed(bcpr_engine_t *e, bcpr_kiss_pty_t *kp)
+{
+ static uint8_t acc[BCPR_MAX_DEVICES][BCPR_MAXFLEN + 8];
+ static int alen[BCPR_MAX_DEVICES];
+ static int esc[BCPR_MAX_DEVICES];
+ static int in_frame[BCPR_MAX_DEVICES];
+ uint8_t buf[512];
+ ssize_t n;
+ int i;
+ int di = kp->idx;
+
+ if (di < 0 || di >= BCPR_MAX_DEVICES) {
+ return;
+ }
+ n = read(kp->master_fd, buf, sizeof(buf));
+ if (n <= 0) {
+ return;
+ }
+ for (i = 0; i < (int)n; i++) {
+ uint8_t b = buf[i];
+ if (!in_frame[di]) {
+ if (b == 0xC0) {
+ in_frame[di] = 1;
+ alen[di] = 0;
+ esc[di] = 0;
+ }
+ continue;
+ }
+ if (esc[di]) {
+ esc[di] = 0;
+ if (b == 0xDC) {
+ b = 0xC0;
+ } else if (b == 0xDD) {
+ b = 0xDB;
+ }
+ if (alen[di] < (int)sizeof(acc[di])) {
+ acc[di][alen[di]++] = b;
+ }
+ continue;
+ }
+ if (b == 0xDB) {
+ esc[di] = 1;
+ continue;
+ }
+ if (b == 0xC0) {
+ if (alen[di] >= 2) {
+ int q;
+ int w;
+ for (w = 0; w < 70; w++) {
+ q = bcpr_engine_queue_kiss(e, di, acc[di], alen[di]);
+ if (q == 0) {
+ break;
+ }
+ usleep(50000);
+ }
+ if (q != 0) {
+ fprintf(stderr,
+ "max25-bcprd: queue_kiss drop bc%d len=%d (busy)\n",
+ di, alen[di]);
+ }
+ }
+ in_frame[di] = 0;
+ alen[di] = 0;
+ continue;
+ }
+ if (alen[di] < (int)sizeof(acc[di])) {
+ acc[di][alen[di]++] = b;
+ }
+ }
+}
+
+static void *kiss_thread(void *arg)
+{
+ bcpr_engine_t *e = (bcpr_engine_t *)arg;
+
+ while (!g_stop && !e->stop) {
+ struct pollfd pf[BCPR_MAX_DEVICES];
+ int nf = 0;
+ int map[BCPR_MAX_DEVICES];
+ int i;
+ int ret;
+ int backoff = 0;
+
+ for (i = 0; i < g_npty; i++) {
+ if (g_pty[i].master_fd < 0) {
+ continue;
+ }
+ pf[nf].fd = g_pty[i].master_fd;
+ pf[nf].events = POLLIN | POLLHUP | POLLERR;
+ map[nf] = i;
+ nf++;
+ }
+ if (nf == 0) {
+ usleep(50000);
+ continue;
+ }
+ ret = poll(pf, (nfds_t)nf, 50);
+ if (ret <= 0) {
+ continue;
+ }
+ for (i = 0; i < nf; i++) {
+ short re = pf[i].revents;
+ if (re & POLLIN) {
+ kiss_feed(e, &g_pty[map[i]]);
+ }
+ if (re & (POLLHUP | POLLERR | POLLNVAL)) {
+ backoff = 1;
+ }
+ }
+ if (backoff) {
+ usleep(50000);
+ }
+ }
+ return NULL;
+}
+
+static int ensure_dir(const char *path)
+{
+ char tmp[256];
+ char *p;
+ size_t len;
+
+ if (!path || !path[0]) {
+ return -1;
+ }
+ snprintf(tmp, sizeof(tmp), "%s", path);
+ len = strlen(tmp);
+ if (len == 0) {
+ return -1;
+ }
+ if (tmp[len - 1] == '/') {
+ tmp[len - 1] = '\0';
+ }
+ for (p = tmp + 1; *p; p++) {
+ if (*p == '/') {
+ *p = '\0';
+ (void)mkdir(tmp, 0755);
+ *p = '/';
+ }
+ }
+ return mkdir(tmp, 0755) == 0 || errno == EEXIST ? 0 : -1;
+}
+
+int bcpr_daemon_run(bcpr_config_t *cfg, bcpr_kiss_pty_t *ptys, int npty,
+ bcpr_engine_t *engine, const bcpr_daemon_opts_t *opts)
+{
+ pthread_t thr;
+ int thr_ok = 0;
+ int i;
+ bcpr_engine_t local_engine;
+ bcpr_engine_t *e = engine;
+ int cal_mode;
+ int seconds;
+
+ if (!cfg || !opts) {
+ return 1;
+ }
+
+ g_stop = 0;
+ g_pty = ptys;
+ g_npty = npty;
+ cal_mode = opts->cal_mode;
+ seconds = opts->seconds;
+
+ signal(SIGINT, on_sig);
+ signal(SIGTERM, on_sig);
+
+ for (i = 0; i < BCPR_MAX_DEVICES; i++) {
+ if (opts->cli_txd_bias >= 0) {
+ cfg->dev[i].txd_bias = opts->cli_txd_bias;
+ }
+ if (opts->cli_ptt_wd >= 0) {
+ cfg->dev[i].ptt_wd = opts->cli_ptt_wd;
+ }
+ if (opts->cli_ptt_wd_key_ms > 0) {
+ cfg->dev[i].ptt_wd_key_ms = opts->cli_ptt_wd_key_ms;
+ }
+ if (opts->cli_ptt_wd_pause_ms > 0) {
+ cfg->dev[i].ptt_wd_pause_ms = opts->cli_ptt_wd_pause_ms;
+ }
+ }
+
+ (void)ensure_dir(cfg->state_dir);
+
+ if (!opts->engine_preopened) {
+ e = &local_engine;
+ if (bcpr_engine_open(e, cfg) != 0) {
+ for (i = 0; i < npty; i++) {
+ bcpr_kiss_pty_close(&ptys[i]);
+ }
+ return 1;
+ }
+ }
+
+ e->run_seconds = seconds;
+ if (cal_mode != BCPR_CAL_OFF) {
+ bcpr_engine_set_cal(e, cal_mode);
+ }
+ bcpr_engine_set_rx(e, on_rx, NULL);
+
+ if (npty > 0 && cal_mode == BCPR_CAL_OFF &&
+ pthread_create(&thr, NULL, kiss_thread, e) == 0) {
+ thr_ok = 1;
+ }
+
+ if (cal_mode != BCPR_CAL_OFF && !cfg->dry_run) {
+ fprintf(stderr,
+ "bcprd: *** WATCH NOW *** cal PTT+tone for %d s — then unkey\n",
+ seconds);
+ }
+
+ (void)bcpr_engine_run(e);
+
+ g_stop = 1;
+ e->stop = 1;
+ if (thr_ok) {
+ pthread_join(thr, NULL);
+ }
+ bcpr_engine_close(e);
+ for (i = 0; i < npty; i++) {
+ bcpr_kiss_pty_close(&ptys[i]);
+ }
+ fprintf(stderr, "max25-bcprd: stopped\n");
+ return 0;
+}
diff --git a/stacks/max25-bcpr/src/bcpr_engine.c b/stacks/max25-bcpr/src/bcpr_engine.c
new file mode 100644
index 0000000..2367817
--- /dev/null
+++ b/stacks/max25-bcpr/src/bcpr_engine.c
@@ -0,0 +1,652 @@
+/*
+ * Bit-clock engine: RT timer loop drives SER12 + HDLC for up to 2 devices.
+ * Userspace timing (not kernel hard-IRQ). See NOTICE.md.
+ */
+#define _GNU_SOURCE
+#include "bcpr/bcpr_engine.h"
+#include "bcpr/bcpr_uart.h"
+#include "bcpr/bcpr_hdlc.h"
+#include "bcpr/bcpr_ser12.h"
+
+#include <errno.h>
+#include <stdio.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+#include <stdint.h>
+
+#if defined(__linux__)
+#include <pthread.h>
+#include <sched.h>
+#include <sys/mman.h>
+#endif
+
+typedef struct {
+ bcpr_engine_t *e;
+ int idx;
+} rx_ctx_t;
+
+static unsigned now_us(void)
+{
+ struct timespec ts;
+ clock_gettime(CLOCK_MONOTONIC, &ts);
+ /* Full monotonic µs — not nsec-within-second (gap spikes were misread). */
+ return (unsigned)(ts.tv_sec * 1000000ull + (unsigned long long)ts.tv_nsec / 1000ull);
+}
+
+/* Idle longer than this resets consecutive-burst counting (new session). */
+#define TOT_SESSION_RESET_US 120000000u
+
+static void tot_write_trip_file(const bcpr_engine_t *e, const bcpr_device_t *d,
+ const char *reason)
+{
+ char path[192];
+ FILE *f;
+
+ if (!e || !d || e->cfg.dry_run || e->cfg.state_dir[0] == '\0') {
+ return;
+ }
+ snprintf(path, sizeof(path), "%s/tot-trip-bc%d", e->cfg.state_dir, d->index);
+ f = fopen(path, "w");
+ if (!f) {
+ return;
+ }
+ fprintf(f,
+ "reason=%s\nburst_total=%d\nconsecutive=%d\ntripped=1\n"
+ "max_key_ms=%d\nmax_bursts=%d\nmax_consecutive=%d\n",
+ reason ? reason : "unknown", d->tot_burst_total, d->tot_consecutive,
+ d->cfg.tot_max_key_ms, d->cfg.tot_max_bursts,
+ d->cfg.tot_max_consecutive);
+ fclose(f);
+}
+
+static void tot_trip(bcpr_engine_t *e, bcpr_device_t *d, const char *reason)
+{
+ if (!d || d->tot_tripped) {
+ return;
+ }
+ d->tot_tripped = 1;
+ bcpr_ser12_force_unkey(&d->ser12);
+ bcpr_hdlc_abort_tx(&d->hdlc);
+ fprintf(stderr,
+ "bcpr: bc%d TOT TRIP reason=%s bursts=%d consecutive=%d\n",
+ d->index, reason ? reason : "unknown", d->tot_burst_total,
+ d->tot_consecutive);
+ tot_write_trip_file(e, d, reason);
+}
+
+static void tot_on_ptt_rise(bcpr_engine_t *e, bcpr_device_t *d, unsigned now_us)
+{
+ unsigned gap_us;
+
+ if (!d->cfg.tot_enabled || d->tot_tripped) {
+ return;
+ }
+ d->tot_key_start_us = now_us;
+ if (d->tot_last_off_us == 0u) {
+ d->tot_consecutive = 1;
+ return;
+ }
+ gap_us = now_us - d->tot_last_off_us;
+ if (gap_us >= (unsigned)d->cfg.tot_min_gap_ms * 1000u) {
+ if (gap_us < TOT_SESSION_RESET_US) {
+ d->tot_consecutive++;
+ } else {
+ d->tot_consecutive = 1;
+ d->tot_burst_total = 0;
+ }
+ }
+ if (d->tot_consecutive > d->cfg.tot_max_consecutive) {
+ tot_trip(e, d, "max_consecutive");
+ }
+}
+
+static void tot_on_ptt_fall(bcpr_engine_t *e, bcpr_device_t *d, unsigned now_us)
+{
+ if (!d->cfg.tot_enabled || d->tot_tripped) {
+ return;
+ }
+ d->tot_last_off_us = now_us;
+ d->tot_burst_total++;
+ if (d->tot_burst_total >= d->cfg.tot_max_bursts) {
+ tot_trip(e, d, "max_bursts");
+ }
+}
+
+static void tot_check_key_duration(bcpr_engine_t *e, bcpr_device_t *d,
+ unsigned now_us)
+{
+ unsigned elapsed_us;
+ unsigned max_us;
+
+ if (!d->cfg.tot_enabled || d->tot_tripped || !d->ptt_was) {
+ return;
+ }
+ if (d->tot_key_start_us == 0u) {
+ d->tot_key_start_us = now_us;
+ return;
+ }
+ max_us = (unsigned)d->cfg.tot_max_key_ms * 1000u;
+ elapsed_us = now_us - d->tot_key_start_us;
+ if (elapsed_us >= max_us) {
+ bcpr_ser12_force_unkey(&d->ser12);
+ bcpr_hdlc_abort_tx(&d->hdlc);
+ tot_on_ptt_fall(e, d, now_us);
+ tot_trip(e, d, "max_key");
+ }
+}
+
+static void on_frame_ctx(const uint8_t *kiss, int len, void *ud)
+{
+ rx_ctx_t *ctx = (rx_ctx_t *)ud;
+ if (ctx && ctx->e && ctx->e->on_rx) {
+ ctx->e->on_rx(ctx->idx, kiss, len, ctx->e->on_rx_ud);
+ }
+}
+
+void bcpr_engine_set_rx(bcpr_engine_t *e, bcpr_rx_fn fn, void *ud)
+{
+ if (!e) {
+ return;
+ }
+ e->on_rx = fn;
+ e->on_rx_ud = ud;
+}
+
+int bcpr_engine_queue_kiss(bcpr_engine_t *e, int dev_idx, const uint8_t *kiss,
+ int len)
+{
+ int i;
+ static unsigned last_tx_us;
+ unsigned now;
+ unsigned elapsed;
+
+ if (!e || !kiss) {
+ return -1;
+ }
+ now = now_us();
+ if (last_tx_us != 0u) {
+ elapsed = now - last_tx_us;
+ if (elapsed < 1500000u) {
+ usleep(1500000u - elapsed);
+ }
+ }
+ last_tx_us = now_us();
+ for (i = 0; i < e->n; i++) {
+ if (e->dev[i].index == dev_idx) {
+ if (e->dev[i].tot_tripped) {
+ fprintf(stderr, "bcpr: bc%d TOT drop queue_kiss (tripped)\n",
+ dev_idx);
+ return -1;
+ }
+ return bcpr_hdlc_queue_kiss(&e->dev[i].hdlc, kiss, len);
+ }
+ }
+ return -1;
+}
+
+int bcpr_engine_open(bcpr_engine_t *e, const bcpr_config_t *cfg)
+{
+ int i;
+ int n = 0;
+
+ if (!e || !cfg) {
+ return -1;
+ }
+ memset(e, 0, sizeof(*e));
+ e->cfg = *cfg;
+ e->stop = 0;
+ e->run_seconds = 0;
+ bcpr_uart_set_dry_run(cfg->dry_run);
+
+ for (i = 0; i < BCPR_MAX_DEVICES; i++) {
+ bcpr_device_t *d;
+ bcpr_channel_t ch;
+ unsigned baud = 1200;
+ int opt_dcd = 0;
+
+ if (!cfg->dev[i].enabled) {
+ continue;
+ }
+ d = &e->dev[n];
+ memset(d, 0, sizeof(*d));
+ d->cfg = cfg->dev[i];
+ d->index = i;
+ d->running = 0;
+
+ if (bcpr_lock_acquire(&d->lock, &d->cfg, cfg->dry_run) != 0) {
+ fprintf(stderr, "bcpr: lock failed for max25e0:bc%d\n", i);
+ bcpr_engine_close(e);
+ return -1;
+ }
+
+ bcpr_ser12_set_mode(&d->ser12, d->cfg.mode, &baud);
+ if (d->cfg.baud) {
+ baud = d->cfg.baud;
+ }
+ opt_dcd = d->ser12.opt_dcd;
+ bcpr_ser12_init(&d->ser12, baud, opt_dcd);
+ bcpr_ser12_set_ptt_wd(&d->ser12, d->cfg.ptt_wd, d->cfg.ptt_wd_key_ms,
+ d->cfg.ptt_wd_pause_ms);
+
+ ch.tx_delay = d->cfg.tx_delay;
+ ch.tx_tail = d->cfg.tx_tail;
+ ch.slottime = d->cfg.slottime;
+ ch.ppersist = d->cfg.ppersist;
+ ch.fulldup = d->cfg.fulldup;
+ bcpr_hdlc_init(&d->hdlc, (int)baud, &ch);
+
+ if (!cfg->dry_run) {
+ if (bcpr_uart_ioperm(d->cfg.iobase, 1) != 0) {
+ fprintf(stderr, "bcpr: ioperm failed 0x%x\n", d->cfg.iobase);
+ bcpr_engine_close(e);
+ return -1;
+ }
+ bcpr_uart_set_divisor(d->cfg.iobase, 115200u / 100u / 8u);
+ bcpr_uart_open_ser12(d->cfg.iobase);
+ /*
+ * txd_bias=steady: assert UART break after open (LCR.SB).
+ * THR framing cannot hold DC-steady TXD; break ≈ TFPCX +12 V.
+ * Default remains pulse (Sailer THR 0x00). MCR unchanged.
+ */
+ if (d->cfg.txd_bias == BCPR_TXD_STEADY) {
+ bcpr_uart_set_break(d->cfg.iobase, 1);
+ d->break_set = 1;
+ }
+ }
+ d->running = 1;
+ n++;
+ }
+ e->n = n;
+ if (n == 0) {
+ fprintf(stderr, "bcpr: no enabled devices in config\n");
+ return -1;
+ }
+ fprintf(stderr, "bcpr: open max25e0 (%d device%s)%s\n", n,
+ n == 1 ? "" : "s", cfg->dry_run ? " [dry-run]" : "");
+ for (i = 0; i < n; i++) {
+ const bcpr_device_t *d = &e->dev[i];
+ fprintf(stderr,
+ "bcpr: bc%d ptt_wd=%s key_ms=%d pause_ms=%d txd_bias=%s tot=%s "
+ "max_key_ms=%d max_consecutive=%d max_bursts=%d\n",
+ d->index, d->cfg.ptt_wd ? "on" : "off", d->cfg.ptt_wd_key_ms,
+ d->cfg.ptt_wd_pause_ms,
+ d->cfg.txd_bias == BCPR_TXD_STEADY ? "steady" : "pulse",
+ d->cfg.tot_enabled ? "on" : "off", d->cfg.tot_max_key_ms,
+ d->cfg.tot_max_consecutive, d->cfg.tot_max_bursts);
+ }
+ return 0;
+}
+
+void bcpr_engine_close(bcpr_engine_t *e)
+{
+ int i;
+ if (!e) {
+ return;
+ }
+ e->stop = 1;
+ for (i = 0; i < e->n; i++) {
+ bcpr_device_t *d = &e->dev[i];
+ if (d->running && !e->cfg.dry_run) {
+ if (d->break_set) {
+ bcpr_uart_set_break(d->cfg.iobase, 0);
+ d->break_set = 0;
+ }
+ bcpr_uart_close_ser12(d->cfg.iobase);
+ (void)bcpr_uart_ioperm(d->cfg.iobase, 0);
+ }
+ bcpr_lock_release(&d->lock);
+ d->running = 0;
+ }
+ e->n = 0;
+}
+
+static void try_rt(void)
+{
+#if defined(__linux__)
+ struct sched_param sp;
+ cpu_set_t set;
+ int rc;
+ memset(&sp, 0, sizeof(sp));
+ /* Pin pages — fault during PTT = multi-ms TXD gap → pump collapse. */
+ if (mlockall(MCL_CURRENT | MCL_FUTURE) != 0) {
+ fprintf(stderr, "bcpr: mlockall failed errno=%d (page faults risk gaps)\n",
+ errno);
+ }
+ /* Prefer one CPU — migration mid-PTT causes multi-ms gaps. */
+ CPU_ZERO(&set);
+ CPU_SET(0, &set);
+ if (sched_setaffinity(0, sizeof(set), &set) != 0) {
+ fprintf(stderr, "bcpr: sched_setaffinity(0) errno=%d\n", errno);
+ }
+ /*
+ * High FIFO while bit-clocking — charge-pump cannot tolerate ms preemption.
+ * Needs root or CAP_SYS_NICE; log hard if denied (S1++ gaps often follow).
+ */
+ sp.sched_priority = 80;
+ rc = sched_setscheduler(0, SCHED_FIFO, &sp);
+ if (rc != 0) {
+ sp.sched_priority = 50;
+ rc = pthread_setschedparam(pthread_self(), SCHED_FIFO, &sp);
+ }
+ if (rc != 0) {
+ sp.sched_priority = 10;
+ rc = pthread_setschedparam(pthread_self(), SCHED_FIFO, &sp);
+ }
+ if (rc != 0) {
+ fprintf(stderr,
+ "bcpr: SCHED_FIFO failed errno=%d — expect max_gap multi-ms "
+ "(need root/CAP_SYS_NICE for bcprd)\n",
+ errno);
+ } else {
+ fprintf(stderr, "bcpr: SCHED_FIFO ok prio=%d cpu0\n", sp.sched_priority);
+ }
+#endif
+}
+
+void bcpr_engine_set_cal(bcpr_engine_t *e, int cal_mode)
+{
+ int i;
+ if (!e) {
+ return;
+ }
+ if (cal_mode < BCPR_CAL_OFF || cal_mode > BCPR_CAL_ALT) {
+ cal_mode = BCPR_CAL_OFF;
+ }
+ e->cal_mode = cal_mode;
+ for (i = 0; i < e->n; i++) {
+ bcpr_ser12_set_cal(&e->dev[i].ser12, cal_mode);
+ }
+}
+
+static int64_t now_ns(void)
+{
+ struct timespec ts;
+ clock_gettime(CLOCK_MONOTONIC, &ts);
+ return (int64_t)ts.tv_sec * 1000000000LL + (int64_t)ts.tv_nsec;
+}
+
+static void emit_tx_telemetry(const bcpr_engine_t *e, bcpr_device_t *d,
+ int64_t ptt_off_ns)
+{
+ char path[192];
+ FILE *f;
+ int64_t dur_ns;
+ unsigned mean_gap = 0;
+ double thr_rate = 0.0;
+ double ptt_ms;
+
+ if (!d || !e) {
+ return;
+ }
+ dur_ns = ptt_off_ns - d->ptt_on_ns;
+ if (dur_ns < 0) {
+ dur_ns = 0;
+ }
+ ptt_ms = (double)dur_ns / 1.0e6;
+ if (d->tick_count > 0) {
+ mean_gap = (unsigned)(d->gap_sum_us / d->tick_count);
+ }
+ if (ptt_ms > 0.5) {
+ thr_rate = (double)d->thr_writes * 1000.0 / ptt_ms;
+ }
+ fprintf(stderr,
+ "bcpr: tx-telemetry bc%d ptt_ms=%.1f thr_writes=%u thr_rate=%.0f "
+ "max_tick_gap_us=%u mean_gap_us=%u gaps_gt_2x=%u baud_us=%u\n",
+ d->index, ptt_ms, d->thr_writes, thr_rate, d->max_tick_gap_us,
+ mean_gap, d->gaps_gt_2x, d->ser12.baud_us);
+ if (e->cfg.dry_run || e->cfg.state_dir[0] == '\0') {
+ return;
+ }
+ snprintf(path, sizeof(path), "%s/tx-last-bc%d", e->cfg.state_dir, d->index);
+ f = fopen(path, "w");
+ if (!f) {
+ return;
+ }
+ fprintf(f,
+ "ptt_on_ns=%lld\nptt_off_ns=%lld\nptt_ms=%.1f\nthr_writes=%u\n"
+ "thr_rate=%.0f\nmax_tick_gap_us=%u\nmean_gap_us=%u\n"
+ "gaps_gt_2x=%u\nbaud_us=%u\ntick_count=%u\n",
+ (long long)d->ptt_on_ns, (long long)ptt_off_ns, ptt_ms,
+ d->thr_writes, thr_rate, d->max_tick_gap_us, mean_gap,
+ d->gaps_gt_2x, d->ser12.baud_us, d->tick_count);
+ fclose(f);
+}
+
+static unsigned tx_baud_div(const bcpr_device_t *d)
+{
+ unsigned baud = d->ser12.baud ? d->ser12.baud : 1200u;
+ unsigned div = (115200u / 8u) / baud;
+ return div ? div : 1u;
+}
+
+static void tick_device(bcpr_engine_t *e, bcpr_device_t *d, rx_ctx_t *ctx)
+{
+ int cts = 0;
+ int mcr = 0x0d;
+ int do_thr = 0;
+ unsigned t = now_us();
+ int ptt;
+ int keyed = d->ptt_was; /* already in TX — keep path minimal for TXD pump */
+
+ if (!e->cfg.dry_run && !keyed) {
+ unsigned char msr = bcpr_uart_msr(d->cfg.iobase);
+ cts = (msr & 0x10) ? 1 : 0;
+ if (d->ser12.opt_dcd > 0) {
+ d->hdlc.dcd = (msr & 0x80) ? 1 : 0;
+ } else if (d->ser12.opt_dcd < 0) {
+ d->hdlc.dcd = (msr & 0x80) ? 0 : 1;
+ }
+ }
+
+ /* S0: tick-gap while PTT keyed (full monotonic µs; unsigned wrap OK). */
+ if (d->ptt_was && d->last_tick_us) {
+ unsigned gap = t - d->last_tick_us;
+ unsigned lim2;
+ if (gap > d->max_tick_gap_us) {
+ d->max_tick_gap_us = gap;
+ }
+ d->gap_sum_us += gap;
+ d->tick_count++;
+ lim2 = d->ser12.baud_us * 2u;
+ if (lim2 < 2u) {
+ lim2 = 2u;
+ }
+ if (gap > lim2) {
+ d->gaps_gt_2x++;
+ }
+ }
+ d->last_tick_us = t;
+
+ bcpr_ser12_tick(&d->ser12, &d->hdlc, cts, &mcr, &do_thr, t);
+ ptt = d->ser12.ptt_hw ? 1 : 0;
+
+ if (d->tot_tripped) {
+ bcpr_ser12_force_unkey(&d->ser12);
+ bcpr_hdlc_abort_tx(&d->hdlc);
+ ptt = 0;
+ mcr = 0x0d;
+ } else {
+ tot_check_key_duration(e, d, t);
+ ptt = d->ser12.ptt_hw ? 1 : 0;
+ }
+
+ if (ptt && !d->ptt_was) {
+ tot_on_ptt_rise(e, d, t);
+ d->ptt_on_ns = now_ns();
+ d->thr_writes = 0;
+ d->max_tick_gap_us = 0;
+ d->gap_sum_us = 0;
+ d->tick_count = 0;
+ d->gaps_gt_2x = 0;
+ d->last_tick_us = t;
+ d->tx_div_set = 0;
+ } else if (!ptt && d->ptt_was) {
+ if (!d->tot_tripped) {
+ tot_on_ptt_fall(e, d, t);
+ }
+ if (!e->cfg.dry_run) {
+ /* Match baycom_ser_fdx: idle divisor only on PTT fall. */
+ bcpr_uart_set_divisor(d->cfg.iobase, 115200u / 100u / 8u);
+ d->tx_div_set = 0;
+ }
+ emit_tx_telemetry(e, d, now_ns());
+ }
+ d->ptt_was = ptt;
+
+ if (!e->cfg.dry_run) {
+ /*
+ * S1++: set baud_uartdiv once on PTT rise (kernel ser12_fdx).
+ * Re-writing divisor every bit toggles DLAB mid-shift → intermittent
+ * TXD charge-pump starve while MCR RTS still keys (MCR PASS / no RF).
+ */
+ if (ptt && !d->tx_div_set) {
+ bcpr_uart_set_divisor(d->cfg.iobase, tx_baud_div(d));
+ d->tx_div_set = 1;
+ }
+ /* Kernel order: THR 0x00 first (charge-pump), then MCR bit+PTT.
+ * txd_bias=steady: skip THR — break already holds TXD SPACE;
+ * pulse is Sailer default (framing edges feed BayCom pump). */
+ if (d->cfg.txd_bias == BCPR_TXD_STEADY) {
+ if (!d->break_set) {
+ bcpr_uart_set_break(d->cfg.iobase, 1);
+ d->break_set = 1;
+ }
+ if (ptt) {
+ d->thr_writes++; /* count pump-equivalent ticks for telem */
+ }
+ } else {
+ if (d->break_set) {
+ bcpr_uart_set_break(d->cfg.iobase, 0);
+ d->break_set = 0;
+ }
+ if (do_thr) {
+ bcpr_uart_thr00(d->cfg.iobase);
+ if (ptt) {
+ d->thr_writes++;
+ }
+ }
+ }
+ bcpr_uart_mcr(d->cfg.iobase, (unsigned char)mcr);
+ }
+
+ /* Defer HDLC RX drain while keyed — keeps bit deadline tight (S1+). */
+ if (!ptt) {
+ ctx->e = e;
+ ctx->idx = d->index;
+ bcpr_hdlc_receiver(&d->hdlc, on_frame_ctx, ctx);
+ }
+}
+
+/* Publish Soft-/hard-DCD for RX-before-TX gates (state_dir/dcd-bcN).
+ * Also refresh rx-activity-bcN whenever Soft-DCD is asserted so smoke/L3
+ * can delete-and-rewait without a permanent false miss when dcd flickers. */
+static void publish_dcd_status(const bcpr_engine_t *e)
+{
+ int i;
+ char path[192];
+ FILE *f;
+
+ if (!e || e->cfg.dry_run || e->cfg.state_dir[0] == '\0') {
+ return;
+ }
+ for (i = 0; i < e->n; i++) {
+ const bcpr_device_t *d = &e->dev[i];
+ int dcd = d->hdlc.dcd ? 1 : 0;
+ snprintf(path, sizeof(path), "%s/dcd-bc%d", e->cfg.state_dir, d->index);
+ f = fopen(path, "w");
+ if (f) {
+ fprintf(f, "dcd=%d\n", dcd);
+ fclose(f);
+ }
+ snprintf(path, sizeof(path), "%s/rx-activity-bc%d", e->cfg.state_dir,
+ d->index);
+ f = fopen(path, "w");
+ if (f) {
+ /* Latch: dcd=1 → activity; dcd=0 clears so L3 needs live Soft-DCD. */
+ fprintf(f, "rx_activity=%d\n", dcd ? 1 : 0);
+ fclose(f);
+ }
+ }
+}
+
+int bcpr_engine_run(bcpr_engine_t *e)
+{
+ struct timespec next;
+ rx_ctx_t ctx;
+ time_t t0;
+ unsigned period_ns;
+ unsigned tick = 0;
+ int64_t bit_deadline_ns = 0;
+
+ if (!e || e->n <= 0) {
+ return -1;
+ }
+ try_rt();
+ t0 = time(NULL);
+ period_ns = e->dev[0].ser12.baud_us * 1000u;
+ if (period_ns < 100000u) {
+ period_ns = 833000u;
+ }
+
+ clock_gettime(CLOCK_MONOTONIC, &next);
+ while (!e->stop) {
+ int i;
+ int any_ptt = 0;
+ unsigned baud_us = e->dev[0].ser12.baud_us;
+
+ for (i = 0; i < e->n; i++) {
+ tick_device(e, &e->dev[i], &ctx);
+ if (e->dev[i].ser12.ptt_hw || e->dev[i].ptt_was) {
+ any_ptt = 1;
+ }
+ }
+ /* ~100 ms at 1200 baud — skip file I/O while PTT (stretches TXD gaps). */
+ if ((++tick % 120u) == 0u && !any_ptt) {
+ publish_dcd_status(e);
+ }
+
+ /*
+ * S1/S1+: while PTT, absolute bit deadline + busy-spin (not
+ * nanosleep). THRE wait alone stacks with tick work → ~2× baud gaps.
+ * Idle RX keeps absolute nanosleep schedule.
+ */
+ if (any_ptt && !e->cfg.dry_run) {
+ int64_t now;
+
+ if (baud_us < 200u) {
+ baud_us = 200u;
+ }
+ now = now_ns();
+ if (bit_deadline_ns == 0 ||
+ now > bit_deadline_ns + (int64_t)baud_us * 1000LL * 4) {
+ /* PTT edge / large slip — resync. */
+ bit_deadline_ns = now + (int64_t)baud_us * 1000LL;
+ } else {
+ bit_deadline_ns += (int64_t)baud_us * 1000LL;
+ }
+ /*
+ * Pure busy-spin to absolute bit deadline — no nanosleep, no
+ * wait_thre syscalls (those stacked gaps and starved the pump).
+ * Target: thr_writes ≈ baud for whole PTT; max_gap < ~2× baud_us.
+ */
+ while (now_ns() < bit_deadline_ns) {
+ }
+ clock_gettime(CLOCK_MONOTONIC, &next);
+ } else {
+ bit_deadline_ns = 0;
+ next.tv_nsec += (long)period_ns;
+ while (next.tv_nsec >= 1000000000L) {
+ next.tv_nsec -= 1000000000L;
+ next.tv_sec++;
+ }
+ while (clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &next,
+ NULL) == EINTR) {
+ }
+ }
+ if (e->run_seconds > 0 && (time(NULL) - t0) >= e->run_seconds) {
+ break;
+ }
+ }
+ return 0;
+}
diff --git a/stacks/max25-bcpr/src/bcpr_hdlc.c b/stacks/max25-bcpr/src/bcpr_hdlc.c
new file mode 100644
index 0000000..09c8d32
--- /dev/null
+++ b/stacks/max25-bcpr/src/bcpr_hdlc.c
@@ -0,0 +1,293 @@
+#include "bcpr/bcpr_hdlc.h"
+#include "bcpr/bcpr_crc.h"
+
+#include <string.h>
+#include <stdlib.h>
+
+static int hbuf_empty(const bcpr_hbuf_t *hb)
+{
+ return hb->rd == hb->wr;
+}
+
+static int hbuf_full(const bcpr_hbuf_t *hb)
+{
+ return !((BCPR_HDLC_BUF - 1 + hb->rd - hb->wr) % BCPR_HDLC_BUF);
+}
+
+static void hbuf_put(bcpr_hbuf_t *hb, uint16_t val)
+{
+ unsigned newp = (hb->wr + 1) % BCPR_HDLC_BUF;
+ if (newp != hb->rd) {
+ hb->buf[hb->wr] = val;
+ hb->wr = newp;
+ }
+}
+
+static uint16_t hbuf_get(bcpr_hbuf_t *hb)
+{
+ uint16_t val;
+ if (hb->rd == hb->wr) {
+ return 0;
+ }
+ val = hb->buf[hb->rd];
+ hb->rd = (hb->rd + 1) % BCPR_HDLC_BUF;
+ return val;
+}
+
+#define tenms_to_2flags(h, tenms) (((tenms) * (h)->bitrate) / 100 / 16)
+
+void bcpr_hdlc_init(bcpr_hdlc_t *h, int bitrate, const bcpr_channel_t *ch)
+{
+ memset(h, 0, sizeof(*h));
+ h->bitrate = bitrate > 0 ? bitrate : 1200;
+ if (ch) {
+ h->ch = *ch;
+ } else {
+ h->ch.tx_delay = 35;
+ h->ch.tx_tail = 2;
+ h->ch.slottime = 10;
+ h->ch.ppersist = 40;
+ h->ch.fulldup = 0;
+ }
+ h->slotcnt = 1;
+ h->rx_bp = h->rx_buffer;
+ h->tx_bp = h->tx_buffer;
+}
+
+void bcpr_hdlc_putbits(bcpr_hdlc_t *h, unsigned bits)
+{
+ hbuf_put(&h->rx_hbuf, (uint16_t)(bits & 0xffff));
+}
+
+unsigned bcpr_hdlc_getbits(bcpr_hdlc_t *h)
+{
+ if (hbuf_empty(&h->tx_hbuf)) {
+ h->ptt = 0;
+ return 0;
+ }
+ return hbuf_get(&h->tx_hbuf);
+}
+
+int bcpr_hdlc_ptt(const bcpr_hdlc_t *h)
+{
+ return h->ptt;
+}
+
+void bcpr_hdlc_abort_tx(bcpr_hdlc_t *h)
+{
+ if (!h) {
+ return;
+ }
+ h->ptt = 0;
+ h->tx_state = 0;
+ h->numflags = 0;
+ h->tx_bitstream = 0;
+ h->tx_bitbuf = 0;
+ h->tx_numbits = 0;
+ h->tx_len = 0;
+ h->tx_bp = h->tx_buffer;
+ h->have_pending = 0;
+ h->pending_len = 0;
+ h->tx_hbuf.rd = h->tx_hbuf.wr;
+}
+
+int bcpr_hdlc_queue_kiss(bcpr_hdlc_t *h, const uint8_t *kiss, int len)
+{
+ if (!kiss || len < 2 || len - 1 > BCPR_MAXFLEN) {
+ return -1;
+ }
+ if (h->have_pending || h->ptt) {
+ return -1;
+ }
+ /* strip KISS command byte */
+ memcpy(h->pending, kiss + 1, (size_t)(len - 1));
+ h->pending_len = len - 1;
+ h->have_pending = 1;
+ return 0;
+}
+
+static int hdlc_rx_add_bytes(bcpr_hdlc_t *h, unsigned bits, int num)
+{
+ int added = 0;
+ while (h->rx_state && num >= 8) {
+ if (h->rx_len >= (int)sizeof(h->rx_buffer)) {
+ h->rx_state = 0;
+ return 0;
+ }
+ *h->rx_bp++ = (uint8_t)(bits >> (32 - num));
+ h->rx_len++;
+ num -= 8;
+ added += 8;
+ }
+ return added;
+}
+
+static void hdlc_rx_flag(bcpr_hdlc_t *h,
+ void (*on_frame)(const uint8_t *kiss, int len, void *ud),
+ void *ud)
+{
+ uint8_t kiss[BCPR_MAXFLEN + 3];
+ int pkt_len;
+ if (h->rx_len < 4) {
+ return;
+ }
+ if (!bcpr_check_crc_ccitt(h->rx_buffer, h->rx_len)) {
+ return;
+ }
+ pkt_len = h->rx_len - 2 + 1;
+ kiss[0] = 0;
+ memcpy(kiss + 1, h->rx_buffer, (size_t)(pkt_len - 1));
+ if (on_frame) {
+ on_frame(kiss, pkt_len, ud);
+ }
+}
+
+void bcpr_hdlc_receiver(bcpr_hdlc_t *h,
+ void (*on_frame)(const uint8_t *kiss, int len, void *ud),
+ void *ud)
+{
+ int i;
+ unsigned mask1, mask2, mask3, mask4, mask5, mask6, word;
+
+ while (!hbuf_empty(&h->rx_hbuf)) {
+ word = hbuf_get(&h->rx_hbuf);
+ h->bitstream >>= 16;
+ h->bitstream |= word << 16;
+ h->bitbuf >>= 16;
+ h->bitbuf |= word << 16;
+ h->numbits += 16;
+ for (i = 15, mask1 = 0x1fc00, mask2 = 0x1fe00, mask3 = 0x0fc00,
+ mask4 = 0x1f800, mask5 = 0xf800, mask6 = 0xffff;
+ i >= 0; i--, mask1 <<= 1, mask2 <<= 1, mask3 <<= 1, mask4 <<= 1,
+ mask5 <<= 1, mask6 = (mask6 << 1) | 1) {
+ if ((h->bitstream & mask1) == mask1) {
+ h->rx_state = 0;
+ } else if ((h->bitstream & mask2) == mask3) {
+ if (h->rx_state) {
+ hdlc_rx_add_bytes(h, h->bitbuf << (8 + i),
+ h->numbits - 8 - i);
+ hdlc_rx_flag(h, on_frame, ud);
+ }
+ h->rx_len = 0;
+ h->rx_bp = h->rx_buffer;
+ h->rx_state = 1;
+ h->numbits = i;
+ } else if ((h->bitstream & mask4) == mask5) {
+ h->numbits--;
+ h->bitbuf = (h->bitbuf & (~mask6)) |
+ ((h->bitbuf & mask6) << 1);
+ }
+ }
+ h->numbits -= hdlc_rx_add_bytes(h, h->bitbuf, h->numbits);
+ }
+}
+
+void bcpr_hdlc_transmitter(bcpr_hdlc_t *h)
+{
+ unsigned mask1, mask2, mask3;
+ int i;
+
+ for (;;) {
+ if (h->tx_numbits >= 16) {
+ if (hbuf_full(&h->tx_hbuf)) {
+ return;
+ }
+ hbuf_put(&h->tx_hbuf, (uint16_t)(h->tx_bitbuf & 0xffff));
+ h->tx_bitbuf >>= 16;
+ h->tx_numbits -= 16;
+ }
+ switch (h->tx_state) {
+ default:
+ return;
+ case 0:
+ case 1:
+ if (h->numflags) {
+ h->numflags--;
+ h->tx_bitbuf |= 0x7e7e << h->tx_numbits;
+ h->tx_numbits += 16;
+ break;
+ }
+ if (h->tx_state == 1) {
+ return;
+ }
+ if (!h->have_pending) {
+ int flgs = tenms_to_2flags(h, h->ch.tx_tail);
+ if (flgs < 2) {
+ flgs = 2;
+ }
+ h->tx_state = 1;
+ h->numflags = flgs;
+ break;
+ }
+ /* pending[] holds up to BCPR_MAXFLEN; reject only oversize / empty. */
+ if (h->pending_len > BCPR_MAXFLEN || h->pending_len < 2) {
+ h->have_pending = 0;
+ h->tx_state = 0;
+ h->numflags = 1;
+ break;
+ }
+ memcpy(h->tx_buffer, h->pending, (size_t)h->pending_len);
+ h->have_pending = 0;
+ h->tx_bp = h->tx_buffer;
+ bcpr_append_crc_ccitt(h->tx_buffer, h->pending_len);
+ h->tx_len = h->pending_len + 2;
+ h->tx_state = 2;
+ h->tx_bitstream = 0;
+ break;
+ case 2:
+ if (!h->tx_len) {
+ h->tx_state = 0;
+ h->numflags = 1;
+ break;
+ }
+ h->tx_len--;
+ h->tx_bitbuf |= *h->tx_bp << h->tx_numbits;
+ h->tx_bitstream >>= 8;
+ h->tx_bitstream |= (*h->tx_bp++) << 16;
+ mask1 = 0x1f000;
+ mask2 = 0x10000;
+ mask3 = 0xffffffffu >> (31 - h->tx_numbits);
+ h->tx_numbits += 8;
+ for (i = 0; i < 8;
+ i++, mask1 <<= 1, mask2 <<= 1, mask3 = (mask3 << 1) | 1) {
+ if ((h->tx_bitstream & mask1) != mask1) {
+ continue;
+ }
+ h->tx_bitstream &= ~mask2;
+ h->tx_bitbuf = (h->tx_bitbuf & mask3) |
+ ((h->tx_bitbuf & (~mask3)) << 1);
+ h->tx_numbits++;
+ mask3 = (mask3 << 1) | 1;
+ }
+ break;
+ }
+ }
+}
+
+void bcpr_hdlc_arbitrate(bcpr_hdlc_t *h)
+{
+ if (h->ptt || !h->have_pending) {
+ return;
+ }
+ if (h->ch.fulldup) {
+ h->tx_state = 0;
+ h->numflags = tenms_to_2flags(h, h->ch.tx_delay);
+ h->tx_bitbuf = h->tx_bitstream = 0;
+ h->tx_numbits = 0;
+ bcpr_hdlc_transmitter(h);
+ h->ptt = 1;
+ return;
+ }
+ if (!h->dcd && (--h->slotcnt <= 0)) {
+ h->slotcnt = h->ch.slottime;
+ if ((rand() % 256) > h->ch.ppersist) {
+ return;
+ }
+ h->tx_state = 0;
+ h->numflags = tenms_to_2flags(h, h->ch.tx_delay);
+ h->tx_bitbuf = h->tx_bitstream = 0;
+ h->tx_numbits = 0;
+ bcpr_hdlc_transmitter(h);
+ h->ptt = 1;
+ }
+}
diff --git a/stacks/max25-bcpr/src/bcpr_kiss.c b/stacks/max25-bcpr/src/bcpr_kiss.c
new file mode 100644
index 0000000..495f4d1
--- /dev/null
+++ b/stacks/max25-bcpr/src/bcpr_kiss.c
@@ -0,0 +1,127 @@
+/*
+ * KISS PTY bridge for max25-bcpr (HyBBX attach via kiss_link symlink).
+ */
+#define _GNU_SOURCE
+#include "bcpr/bcpr_kiss.h"
+
+#include <errno.h>
+#include <fcntl.h>
+#include <grp.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+#if defined(__linux__)
+#include <pty.h>
+#elif defined(__FreeBSD__)
+#include <libutil.h>
+#else
+#include <util.h>
+#endif
+
+static int ensure_dir(const char *path)
+{
+ char tmp[256];
+ char *p;
+ size_t len;
+
+ if (!path || !path[0]) {
+ return -1;
+ }
+ snprintf(tmp, sizeof(tmp), "%s", path);
+ len = strlen(tmp);
+ if (len == 0) {
+ return -1;
+ }
+ if (tmp[len - 1] == '/') {
+ tmp[len - 1] = '\0';
+ }
+ for (p = tmp + 1; *p; p++) {
+ if (*p == '/') {
+ *p = '\0';
+ (void)mkdir(tmp, 0755);
+ *p = '/';
+ }
+ }
+ return mkdir(tmp, 0755) == 0 || errno == EEXIST ? 0 : -1;
+}
+
+int bcpr_kiss_pty_open(bcpr_kiss_pty_t *kp, int idx, const char *link_path,
+ const char *state_dir)
+{
+ int master = -1, slave = -1;
+ char slave_name[128];
+ char dir[128];
+ const char *slash;
+
+ memset(kp, 0, sizeof(*kp));
+ kp->idx = idx;
+ kp->master_fd = -1;
+ kp->slave_fd = -1;
+ snprintf(kp->link_path, sizeof(kp->link_path), "%s", link_path);
+
+ slash = strrchr(link_path, '/');
+ if (slash && slash > link_path) {
+ size_t n = (size_t)(slash - link_path);
+ if (n >= sizeof(dir)) {
+ n = sizeof(dir) - 1;
+ }
+ memcpy(dir, link_path, n);
+ dir[n] = '\0';
+ (void)ensure_dir(dir);
+ } else {
+ (void)ensure_dir(state_dir);
+ }
+
+ if (openpty(&master, &slave, slave_name, NULL, NULL) != 0) {
+ perror("max25-bcpr openpty");
+ return -1;
+ }
+ if (fchmod(slave, 0660) != 0) {
+ perror("max25-bcpr fchmod kiss slave");
+ } else {
+ struct group *gr = getgrnam("dialout");
+ if (gr == NULL) {
+ gr = getgrnam("uucp");
+ }
+ if (gr == NULL) {
+ gr = getgrnam("tty");
+ }
+ if (gr != NULL && fchown(slave, (uid_t)-1, gr->gr_gid) != 0) {
+ perror("max25-bcpr fchown kiss slave");
+ }
+ }
+ unlink(link_path);
+ if (symlink(slave_name, link_path) != 0) {
+ perror("max25-bcpr symlink kiss_link");
+ close(slave);
+ close(master);
+ return -1;
+ }
+ fcntl(master, F_SETFL, O_NONBLOCK);
+ kp->master_fd = master;
+ close(slave);
+ kp->slave_fd = -1;
+ fprintf(stderr, "max25-bcpr: max25e0:bc%d KISS → %s -> %s\n", idx, link_path,
+ slave_name);
+ return 0;
+}
+
+void bcpr_kiss_pty_close(bcpr_kiss_pty_t *kp)
+{
+ if (!kp) {
+ return;
+ }
+ if (kp->slave_fd >= 0) {
+ close(kp->slave_fd);
+ kp->slave_fd = -1;
+ }
+ if (kp->master_fd >= 0) {
+ close(kp->master_fd);
+ kp->master_fd = -1;
+ }
+ if (kp->link_path[0]) {
+ unlink(kp->link_path);
+ }
+}
diff --git a/stacks/max25-bcpr/src/bcpr_lock.c b/stacks/max25-bcpr/src/bcpr_lock.c
new file mode 100644
index 0000000..b179323
--- /dev/null
+++ b/stacks/max25-bcpr/src/bcpr_lock.c
@@ -0,0 +1,202 @@
+/*
+ * Exclusive lock on owned COM ports only (flock + optional setserial uart none).
+ * Pass real IRQ — refuse mismatch. See vault IRQ/lock contract.
+ */
+#define _GNU_SOURCE
+#include "bcpr/bcpr_lock.h"
+
+#include <errno.h>
+#include <fcntl.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/file.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <unistd.h>
+
+static void tty_basename(const char *serial, char *out, size_t out_sz)
+{
+ const char *p = strrchr(serial, '/');
+ p = p ? p + 1 : serial;
+ snprintf(out, out_sz, "%s", p);
+}
+
+int bcpr_lock_verify_irq(const char *serial, unsigned expect_irq,
+ unsigned *got_irq, unsigned *got_io)
+{
+ char name[64];
+ char path[128];
+ char buf[64];
+ FILE *f;
+ unsigned irq = 0;
+ unsigned io = 0;
+
+ if (!serial || !serial[0]) {
+ return -1;
+ }
+ tty_basename(serial, name, sizeof(name));
+ snprintf(path, sizeof(path), "/sys/class/tty/%s/irq", name);
+ f = fopen(path, "r");
+ if (f) {
+ if (fgets(buf, sizeof(buf), f)) {
+ irq = (unsigned)strtoul(buf, NULL, 0);
+ }
+ fclose(f);
+ } else {
+ /* Fallback: setserial -g (best-effort). */
+ char cmd[256];
+ FILE *p;
+ snprintf(cmd, sizeof(cmd), "setserial -g %s 2>/dev/null", serial);
+ p = popen(cmd, "r");
+ if (!p) {
+ return -1;
+ }
+ if (fgets(buf, sizeof(buf), p)) {
+ char *ip = strstr(buf, "IRQ: ");
+ char *pp = strstr(buf, "Port: ");
+ if (ip) {
+ irq = (unsigned)strtoul(ip + 5, NULL, 0);
+ }
+ if (pp) {
+ io = (unsigned)strtoul(pp + 6, NULL, 0);
+ }
+ }
+ pclose(p);
+ }
+
+ snprintf(path, sizeof(path), "/sys/class/tty/%s/iomem_base", name);
+ f = fopen(path, "r");
+ if (f) {
+ if (fgets(buf, sizeof(buf), f)) {
+ io = (unsigned)strtoul(buf, NULL, 0);
+ }
+ fclose(f);
+ }
+
+ if (got_irq) {
+ *got_irq = irq;
+ }
+ if (got_io) {
+ *got_io = io;
+ }
+ if (expect_irq == 0) {
+ return -1; /* never invent; refuse zero when required */
+ }
+ if (irq == 0 || irq != expect_irq) {
+ return -1;
+ }
+ return 0;
+}
+
+static int make_lock_path(const bcpr_dev_config_t *dev, char *out, size_t sz)
+{
+ char name[64];
+ const char *dir = "/var/run/max25-bcpr";
+ tty_basename(dev->serial, name, sizeof(name));
+ if (name[0] == '\0') {
+ return -1;
+ }
+ snprintf(out, sz, "%s/lock-%s", dir, name);
+ return 0;
+}
+
+int bcpr_lock_acquire(bcpr_port_lock_t *lk, const bcpr_dev_config_t *dev,
+ int dry_run)
+{
+ char lockpath[192];
+ unsigned got_irq = 0, got_io = 0;
+
+ if (!lk || !dev) {
+ return -1;
+ }
+ memset(lk, 0, sizeof(*lk));
+ snprintf(lk->serial, sizeof(lk->serial), "%s", dev->serial);
+ lk->iobase = dev->iobase;
+ lk->irq = dev->irq;
+ lk->lock_fd = -1;
+ lk->dry_run = dry_run ? 1 : 0;
+
+ if (dry_run) {
+ return 0;
+ }
+ if (!dev->serial[0] || dev->irq == 0 || dev->iobase == 0) {
+ return -1;
+ }
+ if (bcpr_lock_verify_irq(dev->serial, dev->irq, &got_irq, &got_io) != 0) {
+#if defined(__FreeBSD__)
+ /*
+ * No Linux sysfs/setserial — operator INI is SSoT for iobase/irq
+ * (see vault ExSys AM1 map). Skip hard fail when probe unavailable.
+ */
+ got_irq = dev->irq;
+ got_io = dev->iobase;
+#else
+ fprintf(stderr,
+ "bcpr: IRQ mismatch or unreadable for %s (expect %u got %u)\n",
+ dev->serial, dev->irq, got_irq);
+ return -1;
+#endif
+ }
+ if (got_io && got_io != dev->iobase) {
+ fprintf(stderr,
+ "bcpr: iobase mismatch for %s (expect 0x%x got 0x%x)\n",
+ dev->serial, dev->iobase, got_io);
+ return -1;
+ }
+
+ if (make_lock_path(dev, lockpath, sizeof(lockpath)) != 0) {
+ return -1;
+ }
+ (void)mkdir("/var/run/max25-bcpr", 0755);
+ lk->lock_fd = open(lockpath, O_RDWR | O_CREAT | O_CLOEXEC, 0644);
+ if (lk->lock_fd < 0) {
+ perror("bcpr lock open");
+ return -1;
+ }
+ if (flock(lk->lock_fd, LOCK_EX | LOCK_NB) != 0) {
+ fprintf(stderr, "bcpr: port %s already locked\n", dev->serial);
+ close(lk->lock_fd);
+ lk->lock_fd = -1;
+ return -1;
+ }
+
+ /* Release 8250 claim on this COM only so userspace can bit-bang. */
+ {
+ char cmd[256];
+ snprintf(cmd, sizeof(cmd), "setserial %s uart none 2>/dev/null",
+ dev->serial);
+ if (system(cmd) == 0) {
+ lk->uart_released = 1;
+ }
+ }
+ return 0;
+}
+
+void bcpr_lock_release(bcpr_port_lock_t *lk)
+{
+ if (!lk) {
+ return;
+ }
+ if (lk->dry_run) {
+ memset(lk, 0, sizeof(*lk));
+ lk->lock_fd = -1;
+ return;
+ }
+ if (lk->uart_released && lk->serial[0]) {
+ char cmd[256];
+ /* Restore 16550A claim — best effort. */
+ snprintf(cmd, sizeof(cmd),
+ "setserial %s uart 16550A port 0x%x irq %u 2>/dev/null",
+ lk->serial, lk->iobase, lk->irq);
+ (void)system(cmd);
+ lk->uart_released = 0;
+ }
+ if (lk->lock_fd >= 0) {
+ flock(lk->lock_fd, LOCK_UN);
+ close(lk->lock_fd);
+ lk->lock_fd = -1;
+ }
+ memset(lk, 0, sizeof(*lk));
+ lk->lock_fd = -1;
+}
diff --git a/stacks/max25-bcpr/src/bcpr_runas.c b/stacks/max25-bcpr/src/bcpr_runas.c
new file mode 100644
index 0000000..8c930bf
--- /dev/null
+++ b/stacks/max25-bcpr/src/bcpr_runas.c
@@ -0,0 +1,81 @@
+/*
+ * Process identity: max25-bcprd runs unprivileged; init drops from root.
+ */
+#define _GNU_SOURCE
+#include "bcpr/bcpr_runas.h"
+
+#include <errno.h>
+#include <grp.h>
+#include <pwd.h>
+#include <stdio.h>
+#include <string.h>
+#include <unistd.h>
+
+int bcpr_runas_refuse_root(int dry_run)
+{
+ if (geteuid() == 0 && !dry_run) {
+ fprintf(stderr,
+ "max25-bcprd: refusing to run as root — use max25-bcprd-init "
+ "(setuid) for live SER12\n");
+ return -1;
+ }
+ return 0;
+}
+
+int bcpr_runas_drop(const bcpr_config_t *cfg)
+{
+ struct passwd *pw;
+ struct group *gr = NULL;
+ gid_t gid;
+ uid_t uid;
+ const char *name;
+
+ if (!cfg || !cfg->run_user[0]) {
+ fprintf(stderr, "max25-bcpr: [max25-bcpr] user= required for daemon\n");
+ return -1;
+ }
+ if (geteuid() != 0) {
+ fprintf(stderr, "max25-bcpr: privilege drop requires euid=0\n");
+ return -1;
+ }
+
+ pw = getpwnam(cfg->run_user);
+ if (!pw) {
+ fprintf(stderr, "max25-bcpr: unknown user=%s\n", cfg->run_user);
+ return -1;
+ }
+ uid = pw->pw_uid;
+ gid = pw->pw_gid;
+ name = pw->pw_name;
+
+ if (cfg->run_group[0]) {
+ gr = getgrnam(cfg->run_group);
+ if (!gr) {
+ fprintf(stderr, "max25-bcpr: unknown group=%s\n", cfg->run_group);
+ return -1;
+ }
+ gid = gr->gr_gid;
+ }
+
+ if (initgroups(name, gid) != 0) {
+ fprintf(stderr, "max25-bcpr: initgroups(%s) failed: %s\n", name,
+ strerror(errno));
+ return -1;
+ }
+ if (setgid(gid) != 0) {
+ fprintf(stderr, "max25-bcpr: setgid failed: %s\n", strerror(errno));
+ return -1;
+ }
+ if (setuid(uid) != 0) {
+ fprintf(stderr, "max25-bcpr: setuid failed: %s\n", strerror(errno));
+ return -1;
+ }
+ if (setuid(0) == 0) {
+ fprintf(stderr, "max25-bcpr: privilege drop incomplete\n");
+ return -1;
+ }
+
+ fprintf(stderr, "max25-bcpr: running as uid=%u gid=%u (%s)\n",
+ (unsigned)uid, (unsigned)gid, name);
+ return 0;
+}
diff --git a/stacks/max25-bcpr/src/bcpr_ser12.c b/stacks/max25-bcpr/src/bcpr_ser12.c
new file mode 100644
index 0000000..32091e0
--- /dev/null
+++ b/stacks/max25-bcpr/src/bcpr_ser12.c
@@ -0,0 +1,246 @@
+#include "bcpr/bcpr_ser12.h"
+
+#include <stdlib.h>
+#include <string.h>
+
+void bcpr_ser12_set_mode(bcpr_ser12_t *s, const char *mode, unsigned *baud_out)
+{
+ unsigned baud = 1200;
+ if (mode && strncmp(mode, "ser", 3) == 0) {
+ unsigned n = (unsigned)strtoul(mode + 3, NULL, 10);
+ if (n >= 3 && n <= 48) {
+ baud = n * 100u;
+ }
+ }
+ if (baud_out) {
+ *baud_out = baud;
+ }
+ s->baud = baud;
+ s->baud_us = 1000000u / baud;
+ if (mode && strchr(mode, '*')) {
+ s->opt_dcd = 0;
+ } else if (mode && strchr(mode, '+')) {
+ s->opt_dcd = -1;
+ } else {
+ s->opt_dcd = 1;
+ }
+}
+
+void bcpr_ser12_init(bcpr_ser12_t *s, unsigned baud, int opt_dcd)
+{
+ memset(s, 0, sizeof(*s));
+ s->baud = baud ? baud : 1200;
+ s->baud_us = 1000000u / s->baud;
+ s->opt_dcd = opt_dcd;
+ s->shreg = 0x10000;
+ s->dcd_sum0 = 2;
+ s->dcd_time = 120;
+ s->cal_mode = BCPR_CAL_OFF;
+ /* FlexNet SER12.doc defaults until bcpr_ser12_set_ptt_wd(). */
+ s->ptt_wd = 1;
+ s->ptt_wd_key_us = 14500u * 1000u;
+ s->ptt_wd_pause_us = 500u * 1000u;
+}
+
+void bcpr_ser12_set_ptt_wd(bcpr_ser12_t *s, int enable, int key_ms, int pause_ms)
+{
+ if (!s) {
+ return;
+ }
+ s->ptt_wd = enable ? 1 : 0;
+ if (key_ms < 1000) {
+ key_ms = 1000;
+ }
+ if (pause_ms < 50) {
+ pause_ms = 50;
+ }
+ s->ptt_wd_key_us = (unsigned)key_ms * 1000u;
+ s->ptt_wd_pause_us = (unsigned)pause_ms * 1000u;
+ s->ptt_wd_pausing = 0;
+ s->ptt_wd_phase_start_us = 0;
+}
+
+void bcpr_ser12_force_unkey(bcpr_ser12_t *s)
+{
+ if (!s) {
+ return;
+ }
+ s->ptt_hw = 0;
+ s->ptt_wd_pausing = 0;
+ s->ptt_wd_phase_start_us = 0;
+ s->txshreg = 0;
+}
+
+void bcpr_ser12_set_cal(bcpr_ser12_t *s, int cal_mode)
+{
+ if (!s) {
+ return;
+ }
+ if (cal_mode < BCPR_CAL_OFF || cal_mode > BCPR_CAL_ALT) {
+ cal_mode = BCPR_CAL_OFF;
+ }
+ s->cal_mode = cal_mode;
+ if (cal_mode != BCPR_CAL_OFF) {
+ s->ptt_hw = 1;
+ if (cal_mode == BCPR_CAL_HIGH) {
+ s->tx_bit = 1;
+ } else if (cal_mode == BCPR_CAL_LOW) {
+ s->tx_bit = 0;
+ } else {
+ s->tx_bit = 0; /* alt starts low, toggles each tick */
+ }
+ s->txshreg = 1;
+ s->ptt_wd_pausing = 0;
+ s->ptt_wd_phase_start_us = 0;
+ }
+}
+
+/*
+ * FlexNet SER12.doc / PAR96: during long calibrate, drop PTT every ~14.5 s for
+ * ~500 ms so the radio PTT watchdog can discharge. Mirror for --cal and any
+ * sustained key. Logical ptt_hw stays 1 (keep baud divisor / telemetry); only
+ * MCR RTS is cleared for the pause window.
+ */
+static void ser12_apply_ptt_wd(bcpr_ser12_t *s, int *mcr_out, unsigned now_us)
+{
+ unsigned elapsed;
+
+ if (!s->ptt_wd || !s->ptt_hw) {
+ s->ptt_wd_pausing = 0;
+ s->ptt_wd_phase_start_us = 0;
+ return;
+ }
+ if (s->ptt_wd_phase_start_us == 0) {
+ s->ptt_wd_phase_start_us = now_us ? now_us : 1u;
+ s->ptt_wd_pausing = 0;
+ return;
+ }
+ elapsed = now_us - s->ptt_wd_phase_start_us;
+ if (!s->ptt_wd_pausing) {
+ if (elapsed >= s->ptt_wd_key_us) {
+ s->ptt_wd_pausing = 1;
+ s->ptt_wd_phase_start_us = now_us ? now_us : 1u;
+ *mcr_out = 0x0d; /* RTS clear — PTT off */
+ }
+ return;
+ }
+ /* In pause: force idle MCR regardless of cal/HDLC bit. */
+ *mcr_out = 0x0d;
+ if (elapsed >= s->ptt_wd_pause_us) {
+ s->ptt_wd_pausing = 0;
+ s->ptt_wd_phase_start_us = now_us ? now_us : 1u;
+ /* Next tick restores keyed MCR from cal/HDLC path. */
+ }
+}
+
+static void ser12_rx(bcpr_ser12_t *s, bcpr_hdlc_t *h, unsigned curs,
+ unsigned now_us)
+{
+ int timediff;
+ int bdus8 = (int)(s->baud_us >> 3);
+ int bdus4 = (int)(s->baud_us >> 2);
+ int bdus2 = (int)(s->baud_us >> 1);
+
+ timediff = (int)(now_us - s->pll_time);
+ /* wrap handling for us modulo ~1s window */
+ while (timediff >= 500000) {
+ timediff -= 1000000;
+ }
+ while (timediff <= -500000) {
+ timediff += 1000000;
+ }
+ while (timediff >= bdus2) {
+ timediff -= (int)s->baud_us;
+ s->pll_time += s->baud_us;
+ s->dcd_time--;
+ if (s->shreg & 1) {
+ bcpr_hdlc_putbits(h, (s->shreg >> 1) ^ 0xffffu);
+ s->shreg = 0x10000;
+ }
+ s->shreg >>= 1;
+ }
+ if (s->dcd_time <= 0) {
+ if (!s->opt_dcd) {
+ h->dcd = (s->dcd_sum0 + s->dcd_sum1 + s->dcd_sum2) < 0;
+ }
+ s->dcd_sum2 = s->dcd_sum1;
+ s->dcd_sum1 = s->dcd_sum0;
+ s->dcd_sum0 = 2;
+ s->dcd_time += 120;
+ }
+ if (s->last_rxbit != (unsigned char)curs) {
+ s->last_rxbit = (unsigned char)curs;
+ s->shreg |= 0x10000;
+ if (timediff > 0) {
+ s->pll_time += (unsigned)bdus8;
+ } else {
+ s->pll_time += 1000000u - (unsigned)bdus8;
+ }
+ if (abs(timediff) > bdus4) {
+ s->dcd_sum0 += 4;
+ } else {
+ s->dcd_sum0--;
+ }
+ }
+ while (s->pll_time >= 1000000u) {
+ s->pll_time -= 1000000u;
+ }
+}
+
+void bcpr_ser12_tick(bcpr_ser12_t *s, bcpr_hdlc_t *h, int cts, int *mcr_out,
+ int *do_thr00, unsigned now_us)
+{
+ *do_thr00 = 1;
+
+ /* DOS cal.exe: continuous PTT + sticky/toggle DTR; no HDLC. */
+ if (s->cal_mode != BCPR_CAL_OFF) {
+ s->ptt_hw = 1;
+ if (s->cal_mode == BCPR_CAL_HIGH) {
+ s->tx_bit = 1;
+ } else if (s->cal_mode == BCPR_CAL_LOW) {
+ s->tx_bit = 0;
+ } else {
+ s->tx_bit = (unsigned char)!s->tx_bit;
+ }
+ *mcr_out = 0x0e | (!!s->tx_bit);
+ ser12_apply_ptt_wd(s, mcr_out, now_us);
+ return;
+ }
+
+ /*
+ * While PTT: skip Soft-DCD RX PLL — PC-COM charge-pump needs unbroken
+ * THR 0x00 @ baud; RX work causes multi-ms TXD gaps → underpowered AFSK.
+ */
+ if (!s->ptt_hw) {
+ ser12_rx(s, h, cts ? 1u : 0u, now_us);
+ }
+
+ if (s->ptt_hw) {
+ *mcr_out = 0x0e | (!!s->tx_bit);
+ } else {
+ *mcr_out = 0x0d;
+ }
+
+ if (s->ptt_hw) {
+ if (s->txshreg <= 1) {
+ s->txshreg = 0x10000u | bcpr_hdlc_getbits(h);
+ if (!bcpr_hdlc_ptt(h)) {
+ s->ptt_hw = 0;
+ *mcr_out = 0x0d;
+ ser12_apply_ptt_wd(s, mcr_out, now_us);
+ return;
+ }
+ }
+ s->tx_bit = (unsigned char)(!(s->tx_bit ^ (s->txshreg & 1)));
+ s->txshreg >>= 1;
+ } else {
+ bcpr_hdlc_arbitrate(h);
+ if (bcpr_hdlc_ptt(h)) {
+ s->txshreg = 1;
+ s->ptt_hw = 1;
+ }
+ }
+ bcpr_hdlc_transmitter(h);
+ ser12_apply_ptt_wd(s, mcr_out, now_us);
+ /* Receiver drain is owned by bcpr_engine (RX callback). */
+}
diff --git a/stacks/max25-bcpr/src/bcpr_uart.c b/stacks/max25-bcpr/src/bcpr_uart.c
new file mode 100644
index 0000000..22ccba3
--- /dev/null
+++ b/stacks/max25-bcpr/src/bcpr_uart.c
@@ -0,0 +1,251 @@
+/*
+ * Userspace 8250/16550 register access for SER12 bit-bang.
+ * Prefer ioperm(2); fall back to /dev/port. Dry-run: no-ops.
+ * Algorithms match Linux baycom_ser_fdx (see NOTICE.md).
+ */
+#define _GNU_SOURCE
+#include "bcpr/bcpr_uart.h"
+
+#include <errno.h>
+#include <fcntl.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/types.h>
+#include <time.h>
+#include <unistd.h>
+
+#if defined(__linux__)
+#include <sys/io.h>
+#elif defined(__FreeBSD__)
+#include <machine/cpufunc.h>
+#include <machine/sysarch.h>
+#endif
+
+#if defined(__FreeBSD__)
+static int bcpr_freebsd_set_ioperm(unsigned start, unsigned len, int enable)
+{
+ struct {
+ unsigned int start;
+ unsigned int length;
+ int enable;
+ } args;
+
+ args.start = start;
+ args.length = len;
+ args.enable = enable;
+ return sysarch(I386_SET_IOPERM, &args);
+}
+#endif
+
+static int g_dry;
+static int g_port_fd = -1;
+static int g_ioperm_ok;
+
+void bcpr_uart_set_dry_run(int on)
+{
+ g_dry = on ? 1 : 0;
+}
+
+int bcpr_uart_ioperm(unsigned iobase, int on)
+{
+ if (g_dry) {
+ return 0;
+ }
+#if defined(__linux__)
+ if (ioperm((unsigned long)iobase, 8, on ? 1 : 0) == 0) {
+ g_ioperm_ok = on ? 1 : 0;
+ return 0;
+ }
+#elif defined(__FreeBSD__)
+ /*
+ * Prefer /dev/io (process-wide inb/outb). sysarch range is fallback.
+ * ExSys port: disable matching uart(4) unit (e.g. uart2/cuau2) in
+ * loader.conf — kernel uart + userspace inb on same iobase → SIGBUS.
+ */
+ if (on) {
+ if (g_port_fd < 0) {
+ g_port_fd = open("/dev/io", O_RDWR | O_CLOEXEC);
+ }
+ if (g_port_fd >= 0) {
+ g_ioperm_ok = 1;
+ return 0;
+ }
+ }
+ if (bcpr_freebsd_set_ioperm((unsigned)iobase, 8, on ? 1 : 0) == 0) {
+ g_ioperm_ok = on ? 1 : 0;
+ return 0;
+ }
+#endif
+ /* Fallback: /dev/io (FreeBSD) or /dev/port (Linux) — needs root. */
+ if (on) {
+ if (g_port_fd < 0) {
+#if defined(__FreeBSD__)
+ g_port_fd = open("/dev/io", O_RDWR | O_CLOEXEC);
+#else
+ g_port_fd = open("/dev/port", O_RDWR | O_CLOEXEC);
+#endif
+ if (g_port_fd >= 0) {
+ g_ioperm_ok = 1;
+ return 0;
+ }
+ }
+ if (g_port_fd >= 0) {
+ return 0;
+ }
+ return -1;
+ }
+ if (g_port_fd >= 0) {
+ close(g_port_fd);
+ g_port_fd = -1;
+ }
+ g_ioperm_ok = 0;
+ return 0;
+}
+
+static int port_rw(unsigned port, int do_write, unsigned char *val)
+{
+ off_t off = (off_t)port;
+ if (g_port_fd < 0) {
+ return -1;
+ }
+ if (lseek(g_port_fd, off, SEEK_SET) != off) {
+ return -1;
+ }
+ if (do_write) {
+ return (write(g_port_fd, val, 1) == 1) ? 0 : -1;
+ }
+ return (read(g_port_fd, val, 1) == 1) ? 0 : -1;
+}
+
+void bcpr_uart_outb(unsigned char val, unsigned port)
+{
+ if (g_dry) {
+ return;
+ }
+ if (g_ioperm_ok) {
+ outb(val, port);
+ return;
+ }
+ (void)port_rw(port, 1, &val);
+}
+
+unsigned char bcpr_uart_inb(unsigned port)
+{
+ unsigned char v = 0;
+ if (g_dry) {
+ return 0;
+ }
+ if (g_ioperm_ok) {
+ return inb(port);
+ }
+ (void)port_rw(port, 0, &v);
+ return v;
+}
+
+void bcpr_uart_set_divisor(unsigned iobase, unsigned divisor)
+{
+ unsigned char lcr;
+ if (g_dry) {
+ return;
+ }
+ lcr = bcpr_uart_inb(iobase + 3);
+ bcpr_uart_outb((unsigned char)(lcr | 0x80), iobase + 3); /* DLAB */
+ bcpr_uart_outb((unsigned char)(divisor & 0xff), iobase + 0);
+ bcpr_uart_outb((unsigned char)((divisor >> 8) & 0xff), iobase + 1);
+ bcpr_uart_outb((unsigned char)(lcr & 0x7f), iobase + 3);
+}
+
+void bcpr_uart_open_ser12(unsigned iobase)
+{
+ if (g_dry) {
+ return;
+ }
+ /* Match baycom_ser_fdx open: FIFO off, 6-bit word, IER THRE+MSR. */
+ bcpr_uart_outb(0x00, iobase + 2); /* FCR */
+ bcpr_uart_outb(0x01, iobase + 3); /* LCR 6N1 */
+ bcpr_uart_outb(0x0a, iobase + 1); /* IER */
+ bcpr_uart_outb(0x0d, iobase + 4); /* MCR idle */
+ bcpr_uart_thr00(iobase);
+}
+
+void bcpr_uart_close_ser12(unsigned iobase)
+{
+ if (g_dry) {
+ return;
+ }
+ bcpr_uart_outb(0x00, iobase + 1); /* IER off */
+ bcpr_uart_outb(0x01, iobase + 4); /* MCR close */
+}
+
+unsigned char bcpr_uart_msr(unsigned iobase)
+{
+ if (g_dry) {
+ return 0;
+ }
+ return bcpr_uart_inb(iobase + 6);
+}
+
+void bcpr_uart_mcr(unsigned iobase, unsigned char v)
+{
+ if (g_dry) {
+ return;
+ }
+ bcpr_uart_outb(v, iobase + 4);
+}
+
+void bcpr_uart_thr00(unsigned iobase)
+{
+ if (g_dry) {
+ return;
+ }
+ bcpr_uart_outb(0x00, iobase + 0);
+}
+
+void bcpr_uart_set_break(unsigned iobase, int on)
+{
+ unsigned char lcr;
+ if (g_dry) {
+ return;
+ }
+ /*
+ * 8250 LCR bit6 (Set Break): force TXD to continuous SPACE (RS-232 +V).
+ * Honest limit: THR writes alone cannot hold DC-steady TXD — each byte is
+ * framed (start/data/stop). Break is the practical TFPCX-class approximation
+ * (docs: static ≈ +12 V modem supply). Does not change MCR/PTT.
+ */
+ lcr = bcpr_uart_inb(iobase + 3);
+ if (on) {
+ bcpr_uart_outb((unsigned char)(lcr | 0x40), iobase + 3);
+ } else {
+ bcpr_uart_outb((unsigned char)(lcr & (unsigned char)~0x40), iobase + 3);
+ }
+}
+
+int bcpr_uart_wait_thre(unsigned iobase, unsigned timeout_us)
+{
+ struct timespec t0, now;
+ unsigned char lsr;
+
+ if (g_dry) {
+ return 1;
+ }
+ if (clock_gettime(CLOCK_MONOTONIC, &t0) != 0) {
+ return 0;
+ }
+ for (;;) {
+ lsr = bcpr_uart_inb(iobase + 5);
+ if (lsr & 0x20) { /* THRE */
+ return 1;
+ }
+ if (clock_gettime(CLOCK_MONOTONIC, &now) != 0) {
+ return 0;
+ }
+ {
+ long elapsed = (long)(now.tv_sec - t0.tv_sec) * 1000000L +
+ (now.tv_nsec - t0.tv_nsec) / 1000L;
+ if (elapsed >= (long)timeout_us) {
+ return 0;
+ }
+ }
+ }
+}
diff --git a/stacks/max25-bcpr/src/max25-bcprd-init.c b/stacks/max25-bcpr/src/max25-bcprd-init.c
new file mode 100644
index 0000000..6a02240
--- /dev/null
+++ b/stacks/max25-bcpr/src/max25-bcprd-init.c
@@ -0,0 +1,255 @@
+/*
+ * max25-bcprd-init — privileged SER12 setup (setuid root), then unprivileged daemon.
+ * Fork keeps ioperm in child; parent writes pidfile and exits.
+ */
+#define _GNU_SOURCE
+#include "bcpr/bcpr_config.h"
+#include "bcpr/bcpr_daemon.h"
+#include "bcpr/bcpr_engine.h"
+#include "bcpr/bcpr_kiss.h"
+#include "bcpr/bcpr_runas.h"
+
+#include <errno.h>
+#include <fcntl.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+static void usage(const char *argv0)
+{
+ fprintf(stderr,
+ "Usage: %s -c <bcpr.ini> [--once] [--seconds N]\n"
+ " [--cal high|low|alt] [--txd-bias pulse|steady]\n"
+ " [--ptt-wd|--no-ptt-wd] [--ptt-wd-key-ms N]\n"
+ " [--ptt-wd-pause-ms N] [--version]\n"
+ " Privileged init for live SER12 — drops to [max25-bcpr] user= after setup.\n"
+ " Dry-run: use max25-bcprd --dry-run (unprivileged).\n",
+ argv0);
+}
+
+static int ensure_dir(const char *path)
+{
+ char tmp[256];
+ char *p;
+ size_t len;
+
+ if (!path || !path[0]) {
+ return -1;
+ }
+ snprintf(tmp, sizeof(tmp), "%s", path);
+ len = strlen(tmp);
+ if (len == 0) {
+ return -1;
+ }
+ if (tmp[len - 1] == '/') {
+ tmp[len - 1] = '\0';
+ }
+ for (p = tmp + 1; *p; p++) {
+ if (*p == '/') {
+ *p = '\0';
+ (void)mkdir(tmp, 0755);
+ *p = '/';
+ }
+ }
+ return mkdir(tmp, 0755) == 0 || errno == EEXIST ? 0 : -1;
+}
+
+static int write_pidfile(const char *path, pid_t pid)
+{
+ FILE *f;
+
+ f = fopen(path, "w");
+ if (!f) {
+ fprintf(stderr, "max25-bcprd-init: cannot write pidfile %s\n", path);
+ return -1;
+ }
+ fprintf(f, "%d\n", (int)pid);
+ fclose(f);
+ return 0;
+}
+
+int main(int argc, char **argv)
+{
+ const char *cfg_path = NULL;
+ int seconds = 0;
+ int cal_mode = BCPR_CAL_OFF;
+ int cli_txd_bias = -1;
+ int cli_ptt_wd = -1;
+ int cli_ptt_wd_key_ms = -1;
+ int cli_ptt_wd_pause_ms = -1;
+ int i;
+ bcpr_config_t cfg;
+ bcpr_engine_t engine;
+ bcpr_kiss_pty_t ptys[BCPR_MAX_DEVICES];
+ int npty = 0;
+ char pidpath[256];
+ pid_t child;
+ bcpr_daemon_opts_t opts;
+
+ for (i = 1; i < argc; i++) {
+ if ((strcmp(argv[i], "-c") == 0 || strcmp(argv[i], "--config") == 0) &&
+ i + 1 < argc) {
+ cfg_path = argv[++i];
+ } else if (strcmp(argv[i], "--once") == 0) {
+ if (seconds <= 0) {
+ seconds = 1;
+ }
+ } else if (strcmp(argv[i], "--seconds") == 0 && i + 1 < argc) {
+ seconds = atoi(argv[++i]);
+ } else if (strcmp(argv[i], "--cal") == 0 && i + 1 < argc) {
+ const char *m = argv[++i];
+ if (strcmp(m, "high") == 0 || strcmp(m, "1") == 0) {
+ cal_mode = BCPR_CAL_HIGH;
+ } else if (strcmp(m, "low") == 0 || strcmp(m, "2") == 0) {
+ cal_mode = BCPR_CAL_LOW;
+ } else if (strcmp(m, "alt") == 0 || strcmp(m, "3") == 0) {
+ cal_mode = BCPR_CAL_ALT;
+ } else {
+ fprintf(stderr, "max25-bcprd-init: --cal needs high|low|alt\n");
+ return 2;
+ }
+ } else if (strcmp(argv[i], "--txd-bias") == 0 && i + 1 < argc) {
+ const char *m = argv[++i];
+ if (strcasecmp(m, "pulse") == 0 || strcasecmp(m, "sailer") == 0) {
+ cli_txd_bias = BCPR_TXD_PULSE;
+ } else if (strcasecmp(m, "steady") == 0 ||
+ strcasecmp(m, "tfpcx") == 0 ||
+ strcasecmp(m, "break") == 0) {
+ cli_txd_bias = BCPR_TXD_STEADY;
+ } else {
+ fprintf(stderr, "max25-bcprd-init: --txd-bias needs pulse|steady\n");
+ return 2;
+ }
+ } else if (strcmp(argv[i], "--ptt-wd") == 0) {
+ cli_ptt_wd = 1;
+ } else if (strcmp(argv[i], "--no-ptt-wd") == 0) {
+ cli_ptt_wd = 0;
+ } else if (strcmp(argv[i], "--ptt-wd-key-ms") == 0 && i + 1 < argc) {
+ cli_ptt_wd_key_ms = atoi(argv[++i]);
+ } else if (strcmp(argv[i], "--ptt-wd-pause-ms") == 0 &&
+ i + 1 < argc) {
+ cli_ptt_wd_pause_ms = atoi(argv[++i]);
+ } else if (strcmp(argv[i], "--version") == 0 ||
+ strcmp(argv[i], "-V") == 0) {
+ printf("bcprd-init 0.1.0\n");
+ return 0;
+ } else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
+ usage(argv[0]);
+ return 0;
+ } else {
+ usage(argv[0]);
+ return 2;
+ }
+ }
+ if (!cfg_path) {
+ usage(argv[0]);
+ return 2;
+ }
+ if (geteuid() != 0) {
+ fprintf(stderr,
+ "max25-bcprd-init: requires root (setuid) for SER12 lock/ioperm/KISS\n");
+ return 1;
+ }
+ if (cal_mode != BCPR_CAL_OFF && seconds <= 0) {
+ seconds = 5;
+ }
+
+ if (bcpr_config_load(&cfg, cfg_path) != 0) {
+ fprintf(stderr, "max25-bcprd-init: cannot load %s\n", cfg_path);
+ return 1;
+ }
+ if (cfg.dry_run) {
+ fprintf(stderr,
+ "max25-bcprd-init: dry_run=yes — use max25-bcprd --dry-run instead\n");
+ return 1;
+ }
+ if (!cfg.run_user[0]) {
+ fprintf(stderr,
+ "max25-bcprd-init: [max25-bcpr] user= required for privilege drop\n");
+ return 1;
+ }
+
+ for (i = 0; i < BCPR_MAX_DEVICES; i++) {
+ if (cli_txd_bias >= 0) {
+ cfg.dev[i].txd_bias = cli_txd_bias;
+ }
+ if (cli_ptt_wd >= 0) {
+ cfg.dev[i].ptt_wd = cli_ptt_wd;
+ }
+ if (cli_ptt_wd_key_ms > 0) {
+ cfg.dev[i].ptt_wd_key_ms = cli_ptt_wd_key_ms;
+ }
+ if (cli_ptt_wd_pause_ms > 0) {
+ cfg.dev[i].ptt_wd_pause_ms = cli_ptt_wd_pause_ms;
+ }
+ }
+
+ (void)ensure_dir(cfg.state_dir);
+ snprintf(pidpath, sizeof(pidpath), "%s/max25-bcprd.pid", cfg.state_dir);
+
+ if (cal_mode == BCPR_CAL_OFF) {
+ for (i = 0; i < BCPR_MAX_DEVICES; i++) {
+ if (!cfg.dev[i].enabled) {
+ continue;
+ }
+ if (bcpr_kiss_pty_open(&ptys[npty], i, cfg.dev[i].kiss_link,
+ cfg.state_dir) != 0) {
+ while (npty > 0) {
+ npty--;
+ bcpr_kiss_pty_close(&ptys[npty]);
+ }
+ return 1;
+ }
+ npty++;
+ }
+ }
+
+ if (bcpr_engine_open(&engine, &cfg) != 0) {
+ for (i = 0; i < npty; i++) {
+ bcpr_kiss_pty_close(&ptys[i]);
+ }
+ return 1;
+ }
+
+ child = fork();
+ if (child < 0) {
+ perror("max25-bcprd-init fork");
+ bcpr_engine_close(&engine);
+ for (i = 0; i < npty; i++) {
+ bcpr_kiss_pty_close(&ptys[i]);
+ }
+ return 1;
+ }
+ if (child > 0) {
+ if (write_pidfile(pidpath, child) != 0) {
+ kill(child, SIGTERM);
+ waitpid(child, NULL, 0);
+ return 1;
+ }
+ _exit(0);
+ }
+
+ if (setsid() < 0) {
+ perror("max25-bcprd-init setsid");
+ }
+
+ if (bcpr_runas_drop(&cfg) != 0) {
+ _exit(1);
+ }
+
+ memset(&opts, 0, sizeof(opts));
+ opts.seconds = seconds;
+ opts.cal_mode = cal_mode;
+ opts.cli_txd_bias = cli_txd_bias;
+ opts.cli_ptt_wd = cli_ptt_wd;
+ opts.cli_ptt_wd_key_ms = cli_ptt_wd_key_ms;
+ opts.cli_ptt_wd_pause_ms = cli_ptt_wd_pause_ms;
+ opts.engine_preopened = 1;
+
+ return bcpr_daemon_run(&cfg, ptys, npty, &engine, &opts);
+}
diff --git a/stacks/max25-bcpr/src/max25-bcprd.c b/stacks/max25-bcpr/src/max25-bcprd.c
new file mode 100644
index 0000000..084c5df
--- /dev/null
+++ b/stacks/max25-bcpr/src/max25-bcprd.c
@@ -0,0 +1,205 @@
+/*
+ * max25-bcprd — BayCom/based SER12 userspace daemon (unprivileged live path).
+ * Live SER12: max25-bcprd-init (setuid). Dry-run / offline: this binary.
+ */
+#define _GNU_SOURCE
+#include "bcpr/bcpr_config.h"
+#include "bcpr/bcpr_daemon.h"
+#include "bcpr/bcpr_kiss.h"
+#include "bcpr/bcpr_runas.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <unistd.h>
+
+static void usage(const char *argv0)
+{
+ fprintf(stderr,
+ "Usage: %s -c <bcpr.ini> [--dry-run] [--once] [--seconds N]\n"
+ " [--cal high|low|alt] [--txd-bias pulse|steady]\n"
+ " [--ptt-wd|--no-ptt-wd] [--ptt-wd-key-ms N]\n"
+ " [--ptt-wd-pause-ms N] [--version]\n"
+ " Host face: max25e0:bc0 / bc1 (KISS PTY via kiss_link)\n"
+ " Live SER12: max25-bcprd-init (setuid) — this binary refuses root.\n"
+ " --cal: continuous SER12 tone+PTT (use init for live hardware)\n",
+ argv0);
+}
+
+int main(int argc, char **argv)
+{
+ const char *cfg_path = NULL;
+ int dry = 0;
+ int seconds = 0;
+ int cal_mode = BCPR_CAL_OFF;
+ int cli_txd_bias = -1;
+ int cli_ptt_wd = -1;
+ int cli_ptt_wd_key_ms = -1;
+ int cli_ptt_wd_pause_ms = -1;
+ int i;
+ bcpr_config_t cfg;
+ bcpr_kiss_pty_t ptys[BCPR_MAX_DEVICES];
+ int npty = 0;
+ bcpr_daemon_opts_t opts;
+ char ver[32] = "0.1.0";
+
+ for (i = 1; i < argc; i++) {
+ if ((strcmp(argv[i], "-c") == 0 || strcmp(argv[i], "--config") == 0) &&
+ i + 1 < argc) {
+ cfg_path = argv[++i];
+ } else if (strcmp(argv[i], "--dry-run") == 0) {
+ dry = 1;
+ } else if (strcmp(argv[i], "--once") == 0) {
+ if (seconds <= 0) {
+ seconds = 1;
+ }
+ } else if (strcmp(argv[i], "--seconds") == 0 && i + 1 < argc) {
+ seconds = atoi(argv[++i]);
+ } else if (strcmp(argv[i], "--cal") == 0 && i + 1 < argc) {
+ const char *m = argv[++i];
+ if (strcmp(m, "high") == 0 || strcmp(m, "1") == 0) {
+ cal_mode = BCPR_CAL_HIGH;
+ } else if (strcmp(m, "low") == 0 || strcmp(m, "2") == 0) {
+ cal_mode = BCPR_CAL_LOW;
+ } else if (strcmp(m, "alt") == 0 || strcmp(m, "3") == 0) {
+ cal_mode = BCPR_CAL_ALT;
+ } else {
+ fprintf(stderr, "max25-bcprd: --cal needs high|low|alt\n");
+ return 2;
+ }
+ } else if (strcmp(argv[i], "--txd-bias") == 0 && i + 1 < argc) {
+ const char *m = argv[++i];
+ if (strcasecmp(m, "pulse") == 0 || strcasecmp(m, "sailer") == 0) {
+ cli_txd_bias = BCPR_TXD_PULSE;
+ } else if (strcasecmp(m, "steady") == 0 ||
+ strcasecmp(m, "tfpcx") == 0 ||
+ strcasecmp(m, "break") == 0) {
+ cli_txd_bias = BCPR_TXD_STEADY;
+ } else {
+ fprintf(stderr, "max25-bcprd: --txd-bias needs pulse|steady\n");
+ return 2;
+ }
+ } else if (strcmp(argv[i], "--ptt-wd") == 0) {
+ cli_ptt_wd = 1;
+ } else if (strcmp(argv[i], "--no-ptt-wd") == 0) {
+ cli_ptt_wd = 0;
+ } else if (strcmp(argv[i], "--ptt-wd-key-ms") == 0 && i + 1 < argc) {
+ cli_ptt_wd_key_ms = atoi(argv[++i]);
+ } else if (strcmp(argv[i], "--ptt-wd-pause-ms") == 0 &&
+ i + 1 < argc) {
+ cli_ptt_wd_pause_ms = atoi(argv[++i]);
+ } else if (strcmp(argv[i], "--version") == 0 ||
+ strcmp(argv[i], "-V") == 0) {
+ FILE *vf = fopen("/usr/local/share/max25/max25-bcpr/VERSION", "r");
+ if (!vf) {
+ vf = fopen("stacks/max25-bcpr/VERSION", "r");
+ }
+ if (vf) {
+ if (fgets(ver, sizeof(ver), vf)) {
+ ver[strcspn(ver, "\r\n")] = '\0';
+ }
+ fclose(vf);
+ }
+ printf("bcprd %s\n", ver);
+ return 0;
+ } else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
+ usage(argv[0]);
+ return 0;
+ } else {
+ usage(argv[0]);
+ return 2;
+ }
+ }
+ if (!cfg_path) {
+ usage(argv[0]);
+ return 2;
+ }
+ if (cal_mode != BCPR_CAL_OFF && seconds <= 0) {
+ seconds = 5;
+ }
+
+ if (bcpr_config_load(&cfg, cfg_path) != 0) {
+ fprintf(stderr, "max25-bcprd: cannot load %s\n", cfg_path);
+ return 1;
+ }
+ if (dry) {
+ cfg.dry_run = 1;
+ }
+
+ if (bcpr_runas_refuse_root(cfg.dry_run) != 0) {
+ return 1;
+ }
+ if (!cfg.dry_run && cal_mode == BCPR_CAL_OFF) {
+ fprintf(stderr,
+ "max25-bcprd: live SER12 requires max25-bcprd-init — "
+ "not this binary alone\n");
+ return 1;
+ }
+
+ {
+ char self[512];
+ ssize_t n = readlink("/proc/self/exe", self, sizeof(self) - 1);
+ FILE *vf = fopen("/usr/local/share/bcpr/VERSION", "r");
+ if (!vf) {
+ vf = fopen("stacks/max25-bcpr/VERSION", "r");
+ }
+ ver[0] = '\0';
+ if (vf) {
+ if (fgets(ver, sizeof(ver), vf)) {
+ ver[strcspn(ver, "\r\n")] = '\0';
+ }
+ fclose(vf);
+ }
+ if (n > 0) {
+ self[n] = '\0';
+ fprintf(stderr, "max25-bcprd: path=%s version=%s\n", self,
+ ver[0] ? ver : "0.1.0");
+ } else {
+ fprintf(stderr, "max25-bcprd: version=%s\n", ver[0] ? ver : "0.1.0");
+ }
+ }
+
+ if (cfg.dry_run) {
+ fprintf(stderr, "max25-bcprd: dry-run (no KISS PTY, no UART I/O)\n");
+ } else if (cal_mode == BCPR_CAL_OFF) {
+ for (i = 0; i < BCPR_MAX_DEVICES; i++) {
+ if (!cfg.dev[i].enabled) {
+ continue;
+ }
+ if (bcpr_kiss_pty_open(&ptys[npty], i, cfg.dev[i].kiss_link,
+ cfg.state_dir) != 0) {
+ while (npty > 0) {
+ npty--;
+ bcpr_kiss_pty_close(&ptys[npty]);
+ }
+ return 1;
+ }
+ npty++;
+ }
+ }
+ if (cal_mode != BCPR_CAL_OFF) {
+ static const char *cal_names[] = {"off", "high", "low", "alt"};
+ fprintf(stderr,
+ "max25-bcprd: CAL mode=%s seconds=%d (no KISS; SER12 tone+PTT)%s\n",
+ cal_names[cal_mode], seconds,
+ cfg.dry_run ? " [dry-run]" : "");
+ if (!cfg.dry_run) {
+ fprintf(stderr,
+ "max25-bcprd: live cal needs max25-bcprd-init for hardware access\n");
+ return 1;
+ }
+ }
+
+ memset(&opts, 0, sizeof(opts));
+ opts.dry_run = cfg.dry_run;
+ opts.seconds = seconds;
+ opts.cal_mode = cal_mode;
+ opts.cli_txd_bias = cli_txd_bias;
+ opts.cli_ptt_wd = cli_ptt_wd;
+ opts.cli_ptt_wd_key_ms = cli_ptt_wd_key_ms;
+ opts.cli_ptt_wd_pause_ms = cli_ptt_wd_pause_ms;
+ opts.engine_preopened = 0;
+
+ return bcpr_daemon_run(&cfg, ptys, npty, NULL, &opts);
+}
diff --git a/stacks/max25-bcpr/tests/test_config_offline.c b/stacks/max25-bcpr/tests/test_config_offline.c
new file mode 100644
index 0000000..8b40a6a
--- /dev/null
+++ b/stacks/max25-bcpr/tests/test_config_offline.c
@@ -0,0 +1,53 @@
+/*
+ * Offline config parse test — no UART.
+ */
+#include "bcpr/bcpr_config.h"
+
+#include <stdio.h>
+#include <string.h>
+#include <unistd.h>
+
+int main(void)
+{
+ const char *path = "share/max25-bcpr.ini.example";
+ bcpr_config_t cfg;
+ char cwd[512];
+
+ if (!getcwd(cwd, sizeof(cwd))) {
+ return 1;
+ }
+ /* Prefer path relative to build or source. */
+ if (access(path, R_OK) != 0) {
+ path = "../share/max25-bcpr.ini.example";
+ }
+ if (access(path, R_OK) != 0) {
+ path = "../../stacks/max25-bcpr/share/max25-bcpr.ini.example";
+ }
+ if (bcpr_config_load(&cfg, path) != 0) {
+ /* Write a minimal temp INI. */
+ FILE *f = fopen("/tmp/max25-bcpr-test.ini", "w");
+ if (!f) {
+ return 1;
+ }
+ fputs("[max25-bcpr]\ndry_run = yes\nstate_dir = /tmp/max25-bcpr\n"
+ "[bc0]\nserial = /dev/ttyS0\niobase = 0x3f8\nirq = 4\n"
+ "mode = ser12*\nkiss_link = /tmp/max25-bcpr/kiss-bc0\n",
+ f);
+ fclose(f);
+ path = "/tmp/max25-bcpr-test.ini";
+ if (bcpr_config_load(&cfg, path) != 0) {
+ fprintf(stderr, "FAIL: load\n");
+ return 1;
+ }
+ }
+ if (cfg.n_dev < 1 || !cfg.dev[0].enabled) {
+ fprintf(stderr, "FAIL: no bc0\n");
+ return 1;
+ }
+ if (cfg.dev[0].irq != 4 || cfg.dev[0].iobase != 0x3f8) {
+ fprintf(stderr, "FAIL: irq/iobase\n");
+ return 1;
+ }
+ puts("OK: test_config_offline");
+ return 0;
+}
diff --git a/stacks/max25-bcpr/tests/test_hdlc_offline.c b/stacks/max25-bcpr/tests/test_hdlc_offline.c
new file mode 100644
index 0000000..d05d5b6
--- /dev/null
+++ b/stacks/max25-bcpr/tests/test_hdlc_offline.c
@@ -0,0 +1,110 @@
+/*
+ * Offline HDLC unit test — CRC, bitstuff roundtrip, no UART / UART I/O.
+ */
+#include "bcpr/bcpr_crc.h"
+#include "bcpr/bcpr_hdlc.h"
+
+#include <stdio.h>
+#include <string.h>
+
+static int g_got;
+static uint8_t g_rx[BCPR_MAXFLEN + 4];
+static int g_rx_len;
+
+static void on_frame(const uint8_t *kiss, int len, void *ud)
+{
+ (void)ud;
+ if (len > 0 && len <= (int)sizeof(g_rx)) {
+ memcpy(g_rx, kiss, (size_t)len);
+ g_rx_len = len;
+ g_got = 1;
+ }
+}
+
+static int test_crc(void)
+{
+ uint8_t buf[16];
+ memcpy(buf, "TEST", 4);
+ bcpr_append_crc_ccitt(buf, 4);
+ if (!bcpr_check_crc_ccitt(buf, 6)) {
+ fprintf(stderr, "FAIL: CRC check\n");
+ return 1;
+ }
+ buf[0] ^= 0xff;
+ if (bcpr_check_crc_ccitt(buf, 6)) {
+ fprintf(stderr, "FAIL: CRC should fail on corrupt\n");
+ return 1;
+ }
+ return 0;
+}
+
+static int test_roundtrip(void)
+{
+ bcpr_hdlc_t tx, rx;
+ bcpr_channel_t ch = { .tx_delay = 2, .tx_tail = 1, .slottime = 1,
+ .ppersist = 255, .fulldup = 1 };
+ uint8_t kiss[32];
+ int i, words = 0;
+
+ bcpr_hdlc_init(&tx, 1200, &ch);
+ bcpr_hdlc_init(&rx, 1200, &ch);
+
+ kiss[0] = 0; /* KISS DATA */
+ memcpy(kiss + 1, "HELLO", 5);
+ if (bcpr_hdlc_queue_kiss(&tx, kiss, 6) != 0) {
+ fprintf(stderr, "FAIL: queue\n");
+ return 1;
+ }
+ bcpr_hdlc_arbitrate(&tx);
+ if (!bcpr_hdlc_ptt(&tx)) {
+ fprintf(stderr, "FAIL: PTT not set\n");
+ return 1;
+ }
+
+ g_got = 0;
+ g_rx_len = 0;
+ /* Drain TX bit words into RX putbits path. */
+ for (i = 0; i < 4096 && !g_got; i++) {
+ unsigned w;
+ bcpr_hdlc_transmitter(&tx);
+ w = bcpr_hdlc_getbits(&tx);
+ if (w) {
+ bcpr_hdlc_putbits(&rx, w);
+ words++;
+ bcpr_hdlc_receiver(&rx, on_frame, NULL);
+ } else if (!bcpr_hdlc_ptt(&tx)) {
+ bcpr_hdlc_transmitter(&tx);
+ /* flush remaining */
+ while (!g_got) {
+ w = bcpr_hdlc_getbits(&tx);
+ if (!w) {
+ break;
+ }
+ bcpr_hdlc_putbits(&rx, w);
+ bcpr_hdlc_receiver(&rx, on_frame, NULL);
+ }
+ break;
+ }
+ }
+
+ if (!g_got) {
+ fprintf(stderr, "FAIL: no RX frame (words=%d)\n", words);
+ return 1;
+ }
+ if (g_rx_len < 6 || g_rx[0] != 0 || memcmp(g_rx + 1, "HELLO", 5) != 0) {
+ fprintf(stderr, "FAIL: payload mismatch len=%d\n", g_rx_len);
+ return 1;
+ }
+ return 0;
+}
+
+int main(void)
+{
+ int rc = 0;
+ rc |= test_crc();
+ rc |= test_roundtrip();
+ if (rc == 0) {
+ puts("OK: test_hdlc_offline");
+ }
+ return rc;
+}
diff --git a/stacks/max25-bcpr/tools/NEBENBEI-MOVED.md b/stacks/max25-bcpr/tools/NEBENBEI-MOVED.md
new file mode 100644
index 0000000..8a4ac20
--- /dev/null
+++ b/stacks/max25-bcpr/tools/NEBENBEI-MOVED.md
@@ -0,0 +1,5 @@
+# Dauerlauf helpers — not shipped here
+
+Experimental endurance / orch helpers are **not** part of this freigegeben product tree.
+
+Product L1 remains: `max25-bcpr-ctl`, smoke, ultimate-diag · host face **`max25e0`** · public mark **BayCom/based**.
diff --git a/stacks/max25-bcpr/tools/bcpr-ultimate-diag.sh b/stacks/max25-bcpr/tools/bcpr-ultimate-diag.sh
new file mode 100755
index 0000000..573585c
--- /dev/null
+++ b/stacks/max25-bcpr/tools/bcpr-ultimate-diag.sh
@@ -0,0 +1,916 @@
+#!/usr/bin/env bash
+# bcpr-ultimate-diag.sh — interactive BayCom/based (bcpr) TX/RX diagnostic ladder
+# Public mark: BayCom/based. Internal path: bcpr. Never Konverter/converter.
+# Target: AX25WRK1 intermittent "host keys, RF sometimes" (mic OK 4W; host MCR OK).
+#
+# Soft-TNC RE (2026-07-19) — operator warnings only (no MCR/code patch here):
+# • FlexNet SER12 cal PTT watchdog: ~14.5 s keyed → ~500 ms unkey (discharge).
+# Long continuous force-tx/cal: bcprd ptt_wd drops RTS ~500 ms every ~14.5 s
+# (FlexNet SER12 mirror; disable with ptt_wd=no / --no-ptt-wd).
+# • TXD: default pulse THR 0x00; experiment txd_bias=steady (UART break ≈ TFPCX).
+# • Keep bcprd MCR Sailer 0x0e|bit / 0x0d (4PC-COM 0x0A is outlier — do not switch).
+# Vault RE: 0-RESEARCHES/projects/max25-stack/2026-07-19-baycom-soft-tnc-serial-ptt-re.md
+#
+# Usage (Cursor IDE terminal, as operator):
+# sudo -n /home/akb/Code/10-PROJECTS/MAX25-Stack/stacks/bcpr/tools/bcpr-ultimate-diag.sh
+# BCPR_INI=/etc/max25/bcpr.ini ./bcpr-ultimate-diag.sh -c /etc/max25/bcpr.ini
+# ./bcpr-ultimate-diag.sh --help
+# ./bcpr-ultimate-diag.sh --all # run phases 1–9 with pauses
+# ./bcpr-ultimate-diag.sh --menu # interactive menu (default)
+#
+# Needs: /usr/bin/sudo -n for ioport / live smoke / cal. Does NOT change MCR code.
+# Does NOT use USB as product TX path. Does NOT kill max25d carelessly.
+#
+# Vault: 0-RESEARCHES/projects/max25-stack/2026-07-19-bcpr-ultimate-diag-script.md
+# Relies on: bcpr-ctl, bcpr-rxtx-smoke.sh (same directory).
+
+# Note: interactive prompts use read; keep pipefail but relax -e around optional probes.
+set -uo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+ROOT="$(cd "$SCRIPT_DIR/../../.." 2>/dev/null && pwd || true)"
+CTL="${SCRIPT_DIR}/bcpr-ctl"
+SMOKE_SH="${SCRIPT_DIR}/bcpr-rxtx-smoke.sh"
+INI="${BCPR_INI:-/etc/max25/bcpr.ini}"
+SUDO="/usr/bin/sudo"
+LOG=""
+MODE="menu" # menu | all | phaseN
+PHASE_ONLY=""
+RUN_TS=""
+
+# Result arrays for summary (parallel indices)
+declare -a RES_PHASE=()
+declare -a RES_HOST=()
+declare -a RES_WATT=()
+declare -a RES_BLED=()
+declare -a RES_RDISP=()
+declare -a RES_NOTE=()
+
+usage() {
+ cat <<'USAGE'
+bcpr-ultimate-diag.sh — BayCom/based (bcpr) interactive diagnostic / force-TX ladder
+
+Usage:
+ bcpr-ultimate-diag.sh [-c INI] [--menu|--all|--phase N] [--help]
+
+Options:
+ -c INI bcpr.ini (default: $BCPR_INI or /etc/max25/bcpr.ini)
+ --menu interactive German menu (default)
+ --all run phases 1–9 linearly with pauses + RF prompts
+ --phase N run a single phase (1–9, or 0=restart menu)
+ -h, --help this help
+
+Environment:
+ BCPR_INI same as -c
+ BCPRD optional path to bcprd binary
+
+Phases:
+ 1 Preflight (ttyS0, fuser, dosbox-x, bcprd/max25d, INI, locks)
+ 2 Idle MCR sample via /dev/port
+ 3 bcpr-ctl status + telem (tx-last / dcd / rx-activity)
+ 4 RX listen (soft-DCD) — open SQ reminder
+ 5 Force-TX ladder 1s / 3s / 5s / 8s + wattmeter/LED/display prompts
+ 6 Cal/high (bcprd --cal high) if binary supports it
+ 7 Double-burst / back-to-back TX
+ 8 Contact hunt (DE-9 + 3.5mm) + 5s force-tx + wiggle
+ 9 Summary table + verdict hints
+ 0 Optional safe bcprd restart (bcpr-ctl stop/start only)
+ U USB note (SER12 unsupported on USB — not product TX)
+
+Log: /tmp/bcpr-ultimate-diag-YYYYMMDD-HHMMSS.log
+
+Examples:
+ sudo -n stacks/bcpr/tools/bcpr-ultimate-diag.sh --all
+ BCPR_INI=/etc/max25/bcpr.ini sudo -n ./bcpr-ultimate-diag.sh --menu
+USAGE
+}
+
+# ---------- logging / UI ----------
+
+log() {
+ local line
+ line="$(printf '%s' "$*")"
+ printf '%s\n' "$line"
+ if [[ -n "${LOG:-}" ]]; then
+ printf '%s\n' "$line" >>"$LOG"
+ fi
+}
+
+log_raw() {
+ # stdin → stdout + log
+ if [[ -n "${LOG:-}" ]]; then
+ tee -a "$LOG"
+ else
+ cat
+ fi
+}
+
+banner() {
+ log ""
+ log "════════════════════════════════════════════════════════════"
+ log "$*"
+ log "════════════════════════════════════════════════════════════"
+}
+
+pause_enter() {
+ local msg="${1:-Weiter mit Enter …}"
+ printf '\n%s ' "$msg" >/dev/tty
+ # shellcheck disable=SC2034
+ local _dummy
+ read -r _dummy </dev/tty || true
+ log "[pause] $msg"
+}
+
+ask() {
+ # ask VAR "prompt"
+ local __var="$1"
+ local __prompt="$2"
+ local __ans=""
+ printf '%s ' "$__prompt" >/dev/tty
+ read -r __ans </dev/tty || true
+ printf -v "$__var" '%s' "$__ans"
+ log "[antwort] $__prompt → ${__ans}"
+}
+
+ask_yn() {
+ # ask_yn VAR "prompt" → stores j/n/y/n normalized to j|n
+ local __var="$1"
+ local __prompt="$2"
+ local __ans=""
+ while true; do
+ printf '%s [j/n]: ' "$__prompt" >/dev/tty
+ read -r __ans </dev/tty || true
+ case "${__ans,,}" in
+ j|ja|y|yes) printf -v "$__var" 'j'; log "[antwort] $__prompt → j"; return 0 ;;
+ n|nein|no) printf -v "$__var" 'n'; log "[antwort] $__prompt → n"; return 0 ;;
+ *) printf 'Bitte j oder n.\n' >/dev/tty ;;
+ esac
+ done
+}
+
+record_result() {
+ local phase="$1" host="$2" watt="$3" bled="$4" rdisp="$5" note="${6:-}"
+ RES_PHASE+=("$phase")
+ RES_HOST+=("$host")
+ RES_WATT+=("$watt")
+ RES_BLED+=("$bled")
+ RES_RDISP+=("$rdisp")
+ RES_NOTE+=("$note")
+}
+
+need_sudo() {
+ if [[ ! -x "$SUDO" ]]; then
+ log "FEHLER: $SUDO fehlt — Live-Phasen brauchen sudo."
+ return 1
+ fi
+ if ! "$SUDO" -n true 2>/dev/null; then
+ log "FEHLER: sudo -n fehlgeschlagen."
+ log " Bitte einmalig: sudo -v (oder NOPASSWD für diesen User)"
+ log " Dann Skript erneut starten."
+ return 1
+ fi
+ return 0
+}
+
+run_sudo() {
+ need_sudo || return 1
+ "$SUDO" -n "$@"
+}
+
+ini_get() {
+ local section="$1" key="$2"
+ [[ -f "$INI" ]] || return 0
+ awk -F= -v s="[$section]" -v k="$key" '
+ $0 ~ /^\[/ { cur=$0; gsub(/[[:space:]]/,"",cur) }
+ cur==s && $1 ~ "^[[:space:]]*"k"[[:space:]]*$" {
+ v=$2; gsub(/^[[:space:]]+|[[:space:]]+$/,"",v); print v; exit
+ }' "$INI" 2>/dev/null || true
+}
+
+resolve_bcprd() {
+ if [[ -n "${BCPRD:-}" && "$BCPRD" != "bcprd" && -x "$BCPRD" ]]; then
+ printf '%s\n' "$BCPRD"; return 0
+ fi
+ if command -v bcprd >/dev/null 2>&1; then
+ command -v bcprd; return 0
+ fi
+ local cand
+ for cand in \
+ "${BCPR_BUILD_DIR:-}/bin/bcprd" \
+ "${ROOT}/build-bcpr/bin/bcprd" \
+ "${ROOT}/build-bcpr-${USER:-user}/bin/bcprd" \
+ "/tmp/max25-build-bcpr-${USER:-user}/bin/bcprd" \
+ "${ROOT}/build/bin/bcprd" \
+ /usr/local/bin/bcprd /usr/bin/bcprd; do
+ [[ -n "$cand" && -x "$cand" ]] && { printf '%s\n' "$cand"; return 0; }
+ done
+ return 1
+}
+
+serial_dev() {
+ local s
+ s="$(ini_get bc0 serial)"
+ printf '%s\n' "${s:-/dev/ttyS0}"
+}
+
+state_dir() {
+ local sd
+ sd="$(ini_get bcpr state_dir)"
+ printf '%s\n' "${sd:-/tmp/bcpr}"
+}
+
+iobase_val() {
+ local b
+ b="$(ini_get bc0 iobase)"
+ printf '%s\n' "${b:-0x3f8}"
+}
+
+ensure_tools() {
+ local err=0
+ if [[ ! -x "$CTL" ]]; then
+ log "FEHLER: bcpr-ctl fehlt: $CTL"; err=1
+ fi
+ if [[ ! -x "$SMOKE_SH" ]]; then
+ log "FEHLER: bcpr-rxtx-smoke.sh fehlt: $SMOKE_SH"; err=1
+ fi
+ return "$err"
+}
+
+# ---------- Phase helpers ----------
+
+# Last smoke host result (PASS|FAIL) — do not capture function stdout (log noise).
+_LAST_HOST="—"
+
+smoke_force_tx() {
+ # smoke_force_tx SECONDS → sets _LAST_HOST
+ local secs="$1"
+ local listen=$((secs + 8))
+ local rc=0
+ [[ "$listen" -lt 12 ]] && listen=12
+ log "→ L4 force-tx ${secs}s (smoke --live --tx --force-tx --tx-seconds ${secs})"
+ run_sudo "$CTL" -c "$INI" smoke --live --tx --force-tx --seconds "$listen" --tx-seconds "$secs" 2>&1 | log_raw
+ rc=${PIPESTATUS[0]}
+ if [[ "$rc" -eq 0 ]]; then
+ _LAST_HOST="PASS"
+ log "HOST: PASS (MCR keyed ~${secs}s target)"
+ else
+ _LAST_HOST="FAIL"
+ log "HOST: FAIL (smoke rc=$rc)"
+ fi
+}
+
+prompt_rf_obs() {
+ # prompt_rf_obs PHASE_LABEL HOST_RESULT
+ # sets globals: _watt _bled _rdisp _extra
+ local label="$1" host="$2"
+ local watt bled rdisp extra
+ log ""
+ log "--- RF-Beobachtung: $label (Host=$host) ---"
+ log "Bitte Wattmeter / Board-LED / Radio-Display während des Keys prüfen."
+ ask watt "Wattmeter W (Zahl oder 0 / ?):"
+ ask_yn bled "Board-LED (PC-COM) an während Key?"
+ ask_yn rdisp "Radio-Display / TX-Anzeige an?"
+ ask extra "Kurznotiz (Enter = keine):"
+ _watt="$watt"
+ _bled="$bled"
+ _rdisp="$rdisp"
+ _extra="$extra"
+ record_result "$label" "$host" "$watt" "$bled" "$rdisp" "$extra"
+}
+
+# ---------- Phases ----------
+
+phase_preflight() {
+ banner "Phase 1 — Preflight"
+ local serial sd iobase irq fulldup dry
+ serial="$(serial_dev)"
+ sd="$(state_dir)"
+ iobase="$(iobase_val)"
+ irq="$(ini_get bc0 irq)"
+ fulldup="$(ini_get bc0 fulldup)"
+ dry="$(ini_get bcpr dry_run)"
+
+ log "INI: $INI"
+ if [[ ! -f "$INI" ]]; then
+ log "FEHLER: INI fehlt: $INI"
+ log " Hinweis: Beispiel → stacks/bcpr/share/bcpr.ini.example → /etc/max25/bcpr.ini"
+ return 1
+ fi
+
+ log "--- INI Auszug [bcpr]/[bc0] ---"
+ log " dry_run=${dry:-?} state_dir=${sd}"
+ log " serial=${serial} iobase=${iobase} irq=${irq:-?} fulldup=${fulldup:-?}"
+ log " mode=$(ini_get bc0 mode) kiss_link=$(ini_get bc0 kiss_link)"
+ log " baud=$(ini_get bc0 baud) tx_delay=$(ini_get bc0 tx_delay)"
+
+ log "--- Seriell ---"
+ if [[ -e "$serial" ]]; then
+ log "OK: $serial existiert"
+ ls -l "$serial" 2>&1 | log_raw || true
+ else
+ log "FEHLER: $serial fehlt"
+ fi
+
+ log "--- fuser / lsof (wer hält Port?) ---"
+ if command -v fuser >/dev/null 2>&1; then
+
+ run_sudo fuser -v "$serial" 2>&1 | log_raw
+
+ else
+ log "WARN: fuser nicht installiert"
+ fi
+ if command -v lsof >/dev/null 2>&1; then
+
+ run_sudo lsof "$serial" 2>&1 | log_raw
+
+ fi
+
+ log "--- dosbox-x / DOS-Gast ---"
+ if pgrep -a -f 'dosbox-x|dosbox' 2>/dev/null | log_raw; then
+ log "WARN: dosbox läuft — kann ttyS0/USB belegen (sniff/passthrough)."
+ else
+ log "OK: kein dosbox/dosbox-x Prozess"
+ fi
+
+ log "--- Prozesse bcprd / max25d ---"
+ pgrep -a -x bcprd 2>/dev/null | log_raw || log " bcprd: nicht laufend"
+ pgrep -a -x max25d 2>/dev/null | log_raw || log " max25d: nicht laufend"
+ # wrapper scripts
+ pgrep -a -f 'run-max25d|max25d' 2>/dev/null | head -20 | log_raw || true
+
+ log "--- Lock / State ($sd) ---"
+ if [[ -d "$sd" ]]; then
+ ls -la "$sd" 2>&1 | log_raw || true
+ for f in "$sd"/lock* "$sd"/*.lock "$sd"/bcprd.pid; do
+ [[ -e "$f" ]] || continue
+ log " lock/pid: $f"
+ [[ -f "$f" ]] && { log " content:"; cat "$f" 2>&1 | log_raw || true; }
+ done
+ else
+ log "WARN: state_dir fehlt: $sd"
+ fi
+
+ log "--- Kernel baycom_* Module (sollten NICHT geladen sein) ---"
+ if command -v lsmod >/dev/null 2>&1; then
+ lsmod 2>/dev/null | awk '/^baycom_/ {print}' | log_raw || log "OK: keine baycom_* Module"
+ fi
+
+ log "--- setserial (falls vorhanden) ---"
+ local ss
+ for ss in /usr/bin/setserial /bin/setserial /sbin/setserial /usr/sbin/setserial; do
+ if [[ -x "$ss" ]]; then
+
+ run_sudo "$ss" -g "$serial" 2>&1 | log_raw
+
+ break
+ fi
+ done
+
+ log "--- bcpr-ctl preflight ---"
+
+ run_sudo "$CTL" -c "$INI" preflight 2>&1 | log_raw
+ local pf=$?
+
+ if [[ "$pf" -eq 0 ]]; then
+ log "OK: preflight PASS"
+ else
+ log "WARN: preflight rc=$pf (Port busy wenn Stack läuft — normal bei live max25d/bcprd)"
+ fi
+
+ log "--- Tools ---"
+ log " CTL=$CTL"
+ log " SMOKE=$SMOKE_SH"
+ local bin
+ if bin="$(resolve_bcprd)"; then
+ log " bcprd=$bin"
+ else
+ log " WARN: bcprd Binary nicht gefunden (Build: -DMAX25_BUILD_BCPR=ON)"
+ fi
+
+ pause_enter "Phase 1 fertig — Enter für weiter …"
+ return 0
+}
+
+phase_idle_mcr() {
+ banner "Phase 2 — Idle MCR Sample (/dev/port)"
+ local iobase mcr_off
+ iobase="$(iobase_val)"
+ # MCR = iobase+4
+ mcr_off=$((iobase + 4))
+ log "iobase=$iobase MCR=$(printf '0x%x' "$mcr_off") (iobase+4)"
+ log "Erwartung Idle (Sailer/bcpr): oft 0x0d (DTR+OUT2+RTS-clear) — Werte nur Info."
+
+ if ! need_sudo; then
+ log "überspringe MCR-Sample (kein sudo)"
+ pause_enter
+ return 0
+ fi
+
+ run_sudo python3 - "$iobase" <<'PY' 2>&1 | log_raw
+import os, sys, time
+iobase = int(sys.argv[1], 0)
+mcr = iobase + 4
+fd = os.open("/dev/port", os.O_RDONLY)
+try:
+ vals = []
+ for i in range(20):
+ os.lseek(fd, mcr, os.SEEK_SET)
+ v = ord(os.read(fd, 1))
+ vals.append(v)
+ time.sleep(0.05)
+ uniq = sorted(set(vals))
+ print("Idle MCR samples (20×50ms): " + ",".join("0x%02x" % v for v in vals))
+ print("Unique: " + ",".join("0x%02x" % v for v in uniq))
+ rts = [(v & 0x02) != 0 for v in vals]
+ print("RTS asserted in any sample: %s" % ("YES" if any(rts) else "no"))
+ if all(v == 0 for v in vals):
+ print("WARN: all-zero — /dev/port may be inaccessible or wrong iobase")
+finally:
+ os.close(fd)
+PY
+ local rc=$?
+
+ if [[ "$rc" -ne 0 ]]; then
+ log "WARN: Idle-MCR Sample fehlgeschlagen (rc=$rc) — CAP_SYS_RAWIO / iobase prüfen"
+ fi
+ pause_enter "Phase 2 fertig — Enter …"
+ return 0
+}
+
+phase_status() {
+ banner "Phase 3 — Status + Telemetrie"
+
+ run_sudo "$CTL" -c "$INI" status 2>&1 | log_raw
+
+ local sd
+ sd="$(state_dir)"
+ log "--- Telemetrie unter $sd ---"
+ for f in "$sd"/tx-last-bc* "$sd"/dcd-bc* "$sd"/rx-activity-bc* "$sd"/kiss-bc*; do
+ [[ -e "$f" ]] || continue
+ log "FILE: $f"
+ if [[ -L "$f" ]]; then
+ log " symlink → $(readlink -f "$f" 2>/dev/null || readlink "$f")"
+ elif [[ -f "$f" ]]; then
+ cat "$f" 2>&1 | log_raw || true
+ fi
+ done
+ pause_enter "Phase 3 fertig — Enter …"
+ return 0
+}
+
+phase_rx_listen() {
+ banner "Phase 4 — RX Listen (soft-DCD)"
+ log "OPERATOR: Squellch öffnen / Rauschen/SQ so dass Soft-DCD aktiv werden kann."
+ log " (§0.20 RX before TX — hier nur Listen, kein TX)"
+ pause_enter "SQ bereit? Enter startet ~12s RX listen …"
+
+ run_sudo "$CTL" -c "$INI" smoke --live --seconds 12 2>&1 | log_raw
+ local rc=${PIPESTATUS[0]}
+
+ if [[ "$rc" -eq 0 ]]; then
+ log "OK: RX-Listen smoke beendet (rc=0)"
+ else
+ log "WARN: RX-Listen smoke rc=$rc (siehe Log; Stack/INI prüfen)"
+ fi
+
+ local sd
+ sd="$(state_dir)"
+ for f in "$sd"/dcd-bc0 "$sd"/rx-activity-bc0; do
+ [[ -f "$f" ]] || continue
+ log "Nach RX: $f"
+ cat "$f" 2>&1 | log_raw || true
+ done
+ pause_enter "Phase 4 fertig — Enter …"
+ return 0
+}
+
+phase_force_tx_ladder() {
+ banner "Phase 5 — Force-TX Ladder (1 / 3 / 5 / 8 s)"
+ log "WARNUNG: --force-tx ohne RX-Nachweis (§0.20 Override) — Debug / Intermittent-Hunt."
+ log "Wattmeter bereithalten. USB-Pfad ist KEIN Produkt-TX."
+ log ""
+ log "PTT-WATCHDOG (FlexNet SER12 / WD-Boards):"
+ log " Hardware kann PTT nach ~14,5 s Dauer-Key abwerfen (Discharge ~500 ms)."
+ log " Bei langen Läufen / vielen Keys hintereinander: zwischendurch UNKEY / Pause ~14 s."
+ log " Sonst: RF fällt trotz Host-MCR PASS (Watchdog, kein UART-Fehler)."
+ log ""
+ log "BEOBACHTUNGSHINWEIS TXD (Charge-Pump):"
+ log " bcprd/Sailer: THR 0x00 gepulst · TFPCX: TXD oft steady +12 V."
+ log " Notiz wenn RF mitten im Key einbricht / flackert (Pump/Bias-Kandidat)."
+ pause_enter "Bereit für Ladder? Enter …"
+
+ local secs host
+ for secs in 1 3 5 8; do
+ banner "Force-TX ${secs}s"
+ pause_enter "Wattmeter beobachten — Enter startet ${secs}s Key …"
+ smoke_force_tx "$secs"
+ host="$_LAST_HOST"
+ # show telem snapshot
+ local sd tl
+ sd="$(state_dir)"
+ tl="$sd/tx-last-bc0"
+ if [[ -f "$tl" ]]; then
+ log "--- tx-last-bc0 nach ${secs}s ---"
+ cat "$tl" 2>&1 | log_raw || true
+ fi
+ prompt_rf_obs "L4-${secs}s" "$host"
+ pause_enter "Nächste Stufe — Enter …"
+ done
+ return 0
+}
+
+phase_cal_high() {
+ banner "Phase 6 — Cal/high (bcprd --cal high)"
+ local bin
+ if ! bin="$(resolve_bcprd)"; then
+ log "WARN: bcprd nicht gefunden — Cal übersprungen"
+ pause_enter
+ return 0
+ fi
+
+ # Prefer strings(1); never start bare bcprd. Source contract: --cal high|low|alt.
+ if command -v strings >/dev/null 2>&1 && ! strings "$bin" 2>/dev/null | grep -qF -- '--cal'; then
+ log "WARN: strings fand kein --cal in Binary — Phase trotzdem anbieten (Operator kann abbrechen)"
+ fi
+
+ log "bcprd=$bin"
+ log "Cal = kontinuierlicher SER12 Tone+PTT (DOS cal.exe Stil), kein KISS."
+ log "Sicher: nur bcprd stoppen via bcpr-ctl — max25d Wrapper NICHT hart killen."
+ log ""
+ log "PTT-WATCHDOG: FlexNet cal pausiert alle ~14,5 s für ~500 ms (PTT-Discharge)."
+ log " Dieses Skript: --cal high ~10 s (unter WD). Längere manuelle Cal/"
+ log " Dauer-TX: UNKEY/Pause ~14 s einplanen — sonst RF-Tot auf WD-Boards."
+ log "TXD: bcprd pulst THR 0x00 (nicht TFPCX-steady) — RF-Einbruch mid-cal notieren."
+ log ""
+ local do_cal
+ ask_yn do_cal "bcprd stoppen und --cal high ~10s starten?"
+ if [[ "$do_cal" != "j" ]]; then
+ log "Cal übersprungen (Operator)"
+ pause_enter
+ return 0
+ fi
+
+ if ! need_sudo; then
+ pause_enter
+ return 0
+ fi
+
+ log "→ bcpr-ctl stop"
+
+ run_sudo "$CTL" -c "$INI" stop 2>&1 | log_raw
+
+ sleep 1
+
+ # Ensure no stray bcprd
+ if pgrep -x bcprd >/dev/null 2>&1; then
+ log "WARN: bcprd noch aktiv nach stop — pkill -x bcprd (kein max25d)"
+
+ run_sudo pkill -x bcprd 2>&1 | log_raw
+
+ sleep 1
+ fi
+
+ log "→ $bin -c $INI --cal high --seconds 10"
+ pause_enter "Wattmeter bereit — Enter startet cal high 10s …"
+
+ run_sudo "$bin" -c "$INI" --cal high --seconds 10 2>&1 | log_raw
+ local rc=$?
+
+ local host="PASS"
+ [[ "$rc" -eq 0 ]] || host="FAIL(rc=$rc)"
+ prompt_rf_obs "CAL-high-10s" "$host"
+
+ local restart
+ ask_yn restart "bcprd danach wieder starten (bcpr-ctl start)?"
+ if [[ "$restart" == "j" ]]; then
+
+ run_sudo "$CTL" -c "$INI" start 2>&1 | log_raw
+
+ log "Hinweis: wenn max25d den Stack besitzt, ggf. max25d neu starten (Operator, nicht dieses Skript)."
+ fi
+ pause_enter "Phase 6 fertig — Enter …"
+ return 0
+}
+
+phase_double_burst() {
+ banner "Phase 7 — Double Burst (back-to-back TX)"
+ log "Zwei Force-TX 3s hintereinander (kurze Pause dazwischen)."
+ pause_enter "Enter startet Burst A …"
+ local host_a host_b
+ smoke_force_tx 3
+ host_a="$_LAST_HOST"
+ sleep 1
+ log "Burst B …"
+ smoke_force_tx 3
+ host_b="$_LAST_HOST"
+ prompt_rf_obs "DoubleBurst-A+B" "${host_a}/${host_b}"
+ pause_enter "Phase 7 fertig — Enter …"
+ return 0
+}
+
+phase_contact_hunt() {
+ banner "Phase 8 — Contact Hunt (DE-9 + 3.5mm)"
+ log "OPERATOR-CHECKLISTE:"
+ log " 1) DE-9 (PC-COM ↔ Host) und 3.5mm (Modem ↔ Radio) fest stecken"
+ log " 2) Während dem nächsten Key leicht wackeln (Stecker/Kabel)"
+ log " 3) Wattmeter + Board-LED + Radio-Display beobachten"
+ log " Ziel: intermittenter Kontakt vs. dauerhaft tot unterscheiden"
+ pause_enter "Stecker fest? Enter startet 5s force-tx (währenddessen wackeln) …"
+
+ local host
+ smoke_force_tx 5
+ host="$_LAST_HOST"
+ local obs
+ ask obs "Beobachtung während Wackeln (z.B. 'kurz 2W' / 'immer 0' / 'LED flackert'):"
+ prompt_rf_obs "Contact-5s" "$host"
+ # overwrite last note with wiggle observation if empty note
+ if [[ -n "$obs" && ${#RES_NOTE[@]} -gt 0 ]]; then
+ RES_NOTE[$((${#RES_NOTE[@]} - 1))]="$obs | ${_extra:-}"
+ log "[contact-note] $obs"
+ fi
+ pause_enter "Phase 8 fertig — Enter …"
+ return 0
+}
+
+phase_usb_note() {
+ banner "Hinweis U — USB SER12 (kein Produkt-TX)"
+ log "USB-UART (z.B. /dev/ttyUSB0 / FTDI) ist KEIN unterstützter bcprd SER12-Pfad."
+ log " bcprd braucht ioperm|/dev/port + echte ISA/LPC iobase — USB hat das nicht."
+ log " Station: RF nur ttyS0+CB; USB0 = Modem ohne Radio → Wattmeter ungültig."
+ log " DOSBox Soft-TNC auf USB ≠ bcpr Produkt-TX."
+ pause_enter
+ return 0
+}
+
+phase_restart_menu() {
+ banner "Optional 0 — Sicheres bcprd Restart"
+ log "Methode (aus bcpr-ctl): stop → start. Killt NICHT max25d."
+ log "Wenn max25d den Stack besitzt: nach stop/start ggf. max25d-seitig neu binden."
+ log ""
+ log " Aktueller Status:"
+
+ run_sudo "$CTL" -c "$INI" status 2>&1 | log_raw
+
+ local act
+ ask act "Aktion: [s]top / [t]start / [r]estart / [a]bbruch:"
+ case "${act,,}" in
+ s|stop)
+ run_sudo "$CTL" -c "$INI" stop 2>&1 | log_raw
+ ;;
+ t|start)
+ run_sudo "$CTL" -c "$INI" start 2>&1 | log_raw
+ ;;
+ r|restart|re)
+ run_sudo "$CTL" -c "$INI" stop 2>&1 | log_raw
+ sleep 1
+ run_sudo "$CTL" -c "$INI" start 2>&1 | log_raw
+ ;;
+ *)
+ log "Abbruch — keine Änderung"
+ ;;
+ esac
+
+ run_sudo "$CTL" -c "$INI" status 2>&1 | log_raw
+
+ pause_enter
+ return 0
+}
+
+phase_summary() {
+ banner "Phase 9 — Summary + Verdict"
+ log "Logdatei: $LOG"
+ log ""
+ log "┌──────────────────┬──────────┬──────────┬──────────┬──────────┐"
+ log "│ Phase │ Host │ Watt W │ BoardLED │ RadioTX │"
+ log "├──────────────────┼──────────┼──────────┼──────────┼──────────┤"
+ local i n
+ n=${#RES_PHASE[@]}
+ if [[ "$n" -eq 0 ]]; then
+ log "│ (keine TX-Phasen aufgezeichnet) │"
+ else
+ for ((i = 0; i < n; i++)); do
+ printf '│ %-16s │ %-8s │ %-8s │ %-8s │ %-8s │\n' \
+ "${RES_PHASE[$i]:0:16}" \
+ "${RES_HOST[$i]:0:8}" \
+ "${RES_WATT[$i]:0:8}" \
+ "${RES_BLED[$i]:0:8}" \
+ "${RES_RDISP[$i]:0:8}" | log_raw
+ if [[ -n "${RES_NOTE[$i]:-}" ]]; then
+ log "│ note: ${RES_NOTE[$i]}"
+ fi
+ done
+ fi
+ log "└──────────────────┴──────────┴──────────┴──────────┴──────────┘"
+
+ # Verdict heuristics
+ local any_host_pass=0 any_host_fail=0 any_rf=0 all_rf_zero=1
+ for ((i = 0; i < n; i++)); do
+ case "${RES_HOST[$i]}" in
+ *PASS*) any_host_pass=1 ;;
+ *FAIL*) any_host_fail=1 ;;
+ esac
+ case "${RES_WATT[$i]}" in
+ ''|'0'|'0.0'|'?'|'n'|'nein') ;;
+ *)
+ # non-zero / non-empty that looks like power
+ if [[ "${RES_WATT[$i]}" =~ ^[0-9]*\.?[0-9]+$ ]]; then
+ if awk -v w="${RES_WATT[$i]}" 'BEGIN{exit !(w>0)}'; then
+ any_rf=1
+ all_rf_zero=0
+ fi
+ else
+ # free text — if contains number >0 heuristic
+ if [[ "${RES_WATT[$i]}" =~ [1-9] ]]; then
+ any_rf=1
+ all_rf_zero=0
+ fi
+ fi
+ ;;
+ esac
+ if [[ "${RES_BLED[$i]}" == "j" || "${RES_RDISP[$i]}" == "j" ]]; then
+ any_rf=1
+ all_rf_zero=0
+ fi
+ done
+
+ log ""
+ log "=== Verdict-Hinweise (Heuristik, kein Automatik-Urteil) ==="
+ if [[ "$n" -eq 0 ]]; then
+ log "• Keine Ladder-Daten — nur Preflight/Status gelaufen."
+ elif [[ "$any_host_pass" -eq 1 && "$all_rf_zero" -eq 1 && "$any_rf" -eq 0 ]]; then
+ log "• HOST OK / RF tot oder 0 W: Fehlerklasse NACH UART"
+ log " → DE-9/3.5mm Kontakt, Charge-Pump/Vcc, PTT-Transistor, Mic-Buchse, Radio"
+ log " → Mic-alone 4 W OK stützt: Radio selbst ok; Pfad Modem↔Mic intermittierend"
+ elif [[ "$any_host_pass" -eq 1 && "$any_rf" -eq 1 ]]; then
+ log "• HOST OK / RF zeitweise sichtbar: INTERMITTENT RF (Kontakt/Pump/AF)"
+ log " → Contact-Hunt wiederholen; zwischen langen Keys Pause ~14 s (PTT-WD)"
+ log " → TXD pulse vs steady (TFPCX) als Beobachtung; Kabel/Stecker fest"
+ elif [[ "$any_host_fail" -eq 1 && "$any_host_pass" -eq 0 ]]; then
+ log "• HOST FAIL: zuerst Stack/KISS/fulldup/MCR — nicht primär Wattmeter"
+ log " → bcpr-ctl status, kiss_link, fulldup=yes bei Soft-DCD, kein zweites bcprd"
+ log " → MCR bleibt Sailer 0x0e|bit / 0x0d (nicht 4PC-COM 0x0A)"
+ else
+ log "• Gemischte Ergebnisse — Log + Notizen vergleichen; Phasen 5/8 wiederholen."
+ fi
+ log ""
+ log "SSoT: 0-RESEARCHES/projects/max25-stack/2026-07-19-bcpr-host-keyed-wattmeter-tot.md"
+ log " 2026-07-19-bcpr-mcr-ok-rf-zero-after-4w.md · winning-recipe · RF intermittent"
+ log " soft-TNC RE: 2026-07-19-baycom-soft-tnc-serial-ptt-re.md (WD · TXD · MCR)"
+ log "USB: kein Produkt-TX (Phase U)."
+ log ""
+ log "Log gespeichert: $LOG"
+ return 0
+}
+
+run_all() {
+ phase_preflight
+ phase_idle_mcr
+ phase_status
+ phase_rx_listen
+ phase_force_tx_ladder
+ phase_cal_high
+ phase_double_burst
+ phase_contact_hunt
+ phase_usb_note
+ phase_summary
+}
+
+menu_loop() {
+ while true; do
+ banner "bcpr Ultimate Diag — Menü"
+ log "INI=$INI"
+ log "Log=$LOG"
+ log ""
+ log " 1) Preflight"
+ log " 2) Idle MCR"
+ log " 3) Status + Telem"
+ log " 4) RX Listen"
+ log " 5) Force-TX Ladder 1/3/5/8s"
+ log " 6) Cal/high"
+ log " 7) Double Burst"
+ log " 8) Contact Hunt"
+ log " 9) Summary / Verdict"
+ log " A) Alle Phasen 1–9 (+ USB-Hinweis)"
+ log " 0) bcprd stop/start (sicher)"
+ log " U) USB-Hinweis"
+ log " Q) Beenden"
+ log ""
+ local choice
+ ask choice "Wahl:"
+ case "${choice^^}" in
+ 1) phase_preflight ;;
+ 2) phase_idle_mcr ;;
+ 3) phase_status ;;
+ 4) phase_rx_listen ;;
+ 5) phase_force_tx_ladder ;;
+ 6) phase_cal_high ;;
+ 7) phase_double_burst ;;
+ 8) phase_contact_hunt ;;
+ 9) phase_summary ;;
+ A|ALL) run_all ;;
+ 0) phase_restart_menu ;;
+ U) phase_usb_note ;;
+ Q|X|EXIT|QUIT)
+ phase_summary
+ log "Ende."
+ return 0
+ ;;
+ *)
+ log "Unbekannte Wahl: $choice"
+ ;;
+ esac
+ done
+}
+
+# ---------- main ----------
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -c)
+ [[ $# -ge 2 ]] || { echo "ERROR: -c needs INI path"; exit 2; }
+ INI="$2"
+ shift 2
+ ;;
+ --menu) MODE="menu"; shift ;;
+ --all) MODE="all"; shift ;;
+ --phase)
+ [[ $# -ge 2 ]] || { echo "ERROR: --phase needs N"; exit 2; }
+ MODE="phase"
+ PHASE_ONLY="$2"
+ shift 2
+ ;;
+ -h|--help) usage; exit 0 ;;
+ *)
+ echo "ERROR: unknown arg: $1"
+ usage
+ exit 2
+ ;;
+ esac
+done
+
+RUN_TS="$(date +%Y%m%d-%H%M%S)"
+LOG="/tmp/bcpr-ultimate-diag-${RUN_TS}.log"
+: >"$LOG" || {
+ echo "FEHLER: kann Log nicht schreiben: $LOG"
+ exit 1
+}
+
+# Interactive reads use /dev/tty; log()/log_raw append to $LOG (no exec-tee double).
+banner "bcpr Ultimate Diag — BayCom/based (bcpr)"
+log "Start: $(date -R 2>/dev/null || date)"
+log "Host: $(hostname 2>/dev/null || echo '?')"
+log "User: $(id -un 2>/dev/null || echo '?') uid=$(id -u)"
+log "INI: $INI (override: BCPR_INI / -c)"
+log "Log: $LOG"
+log "Tools: $CTL"
+log ""
+log "Hinweis: PTT-Watchdog ~14,5 s / 500 ms — bei langen Keys unkey/pausieren."
+log " TXD: bcprd pulst (Sailer); TFPCX oft steady — RF mid-key notieren."
+log " MCR: Sailer belassen (kein 4PC-COM 0x0A-Experiment in diesem Skript)."
+log ""
+
+ensure_tools || {
+ log "Abbruch: Tools fehlen."
+ exit 1
+}
+
+# Soft check sudo early (warn only for menu; hard for --all live)
+if ! need_sudo; then
+ log "WARN: ohne sudo -n sind Live/TX/Cal/MCR-Phasen blockiert."
+ if [[ "$MODE" == "all" ]]; then
+ log "Abbruch (--all braucht sudo -n)."
+ exit 1
+ fi
+fi
+
+case "$MODE" in
+ all)
+ run_all
+ ;;
+ phase)
+ case "$PHASE_ONLY" in
+ 1) phase_preflight ;;
+ 2) phase_idle_mcr ;;
+ 3) phase_status ;;
+ 4) phase_rx_listen ;;
+ 5) phase_force_tx_ladder ;;
+ 6) phase_cal_high ;;
+ 7) phase_double_burst ;;
+ 8) phase_contact_hunt ;;
+ 9) phase_summary ;;
+ 0) phase_restart_menu ;;
+ U|u) phase_usb_note ;;
+ *)
+ log "FEHLER: unbekannte Phase $PHASE_ONLY"
+ exit 2
+ ;;
+ esac
+ ;;
+ menu|*)
+ menu_loop
+ ;;
+esac
+
+log ""
+log "Fertig. Log: $LOG"
+exit 0
diff --git a/stacks/max25-bcpr/tools/max25-bcpr-ctl b/stacks/max25-bcpr/tools/max25-bcpr-ctl
new file mode 100755
index 0000000..39510a7
--- /dev/null
+++ b/stacks/max25-bcpr/tools/max25-bcpr-ctl
@@ -0,0 +1,379 @@
+#!/usr/bin/env bash
+# max25-bcpr-ctl — BayCom/based PC-COM helper (preflight / start / stop).
+# Lite policy: plain English operator messages (new Linux user level).
+set -euo pipefail
+
+INI="${MAX25_BCPR_INI:-${BCPR_INI:-/etc/max25/max25-bcpr.ini}}"
+BCPRD="${MAX25_BCPRD:-${BCPRD:-max25-bcprd}}"
+BCPRD_INIT="${MAX25_BCPRD_INIT:-max25-bcprd-init}"
+PIDFILE="${MAX25_BCPR_PIDFILE:-/tmp/max25-bcpr/max25-bcprd.pid}"
+STATE_DIR="/tmp/max25-bcpr"
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+SMOKE_SH="$SCRIPT_DIR/max25-bcpr-rxtx-smoke.sh"
+
+msg() { echo "max25-bcpr-ctl: $*"; }
+ok() { msg "OK — $*"; }
+warn(){ msg "WARN — $*"; }
+err() { msg "ERROR — $*" >&2; }
+
+usage() {
+ cat <<USAGE
+max25-bcpr-ctl — BayCom/based PC-COM modem helper
+
+Usage: max25-bcpr-ctl [-c ini] {preflight|start|status|stop|smoke|version}
+
+ preflight check config and serial port before start
+ start start the PC-COM background service
+ status show whether the service is running
+ stop stop the service
+ smoke offline/optional live test (see --help on smoke script)
+
+Config file default: /etc/max25/max25-bcpr.ini
+Create one with: max25-lite-setup (BayCom/based path)
+
+RX before TX — confirm receive before live transmit.
+USAGE
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -c) INI="$2"; shift 2 ;;
+ -h|--help) usage; exit 0 ;;
+ *) break ;;
+ esac
+done
+CMD="${1:-}"
+if [[ $# -gt 0 ]]; then
+ shift
+fi
+
+resolve_max25_bcprd_init() {
+ if [[ -n "${BCPRD_INIT:-}" && "$BCPRD_INIT" != "max25-bcprd-init" && -x "$BCPRD_INIT" ]]; then
+ echo "$BCPRD_INIT"; return
+ fi
+ if command -v "$BCPRD_INIT" >/dev/null 2>&1; then
+ command -v "$BCPRD_INIT"; return
+ fi
+ local root cand
+ root="$(cd "$SCRIPT_DIR/../../.." && pwd)"
+ for cand in \
+ ${MAX25_BCPR_BUILD_DIR:-${BCPR_BUILD_DIR:-}}/bin/max25-bcprd-init \
+ "$root/build-max25-bcpr/bin/max25-bcprd-init" \
+ "$root/build-max25-bcpr-${USER:-user}/bin/max25-bcprd-init" \
+ "/tmp/max25-build-max25-bcpr-${USER:-user}/bin/max25-bcprd-init" \
+ "$root/build/bin/max25-bcprd-init" \
+ /usr/local/bin/max25-bcprd-init /usr/bin/max25-bcprd-init; do
+ [[ -n "$cand" && -x "$cand" ]] && { echo "$cand"; return; }
+ done
+ return 1
+}
+
+resolve_max25_bcprd() {
+ if [[ -n "${BCPRD:-}" && "$BCPRD" != "max25-bcprd" && -x "$BCPRD" ]]; then
+ echo "$BCPRD"; return
+ fi
+ if command -v "$BCPRD" >/dev/null 2>&1; then
+ command -v "$BCPRD"; return
+ fi
+ local root cand
+ root="$(cd "$SCRIPT_DIR/../../.." && pwd)"
+ for cand in \
+ ${MAX25_BCPR_BUILD_DIR:-${BCPR_BUILD_DIR:-}}/bin/max25-bcprd \
+ "$root/build-max25-bcpr/bin/max25-bcprd" \
+ "$root/build-max25-bcpr-${USER:-user}/bin/max25-bcprd" \
+ "/tmp/max25-build-max25-bcpr-${USER:-user}/bin/max25-bcprd" \
+ "$root/build/bin/max25-bcprd" \
+ /usr/local/bin/max25-bcprd /usr/bin/max25-bcprd; do
+ [[ -n "$cand" && -x "$cand" ]] && { echo "$cand"; return; }
+ done
+ return 1
+}
+
+ini_get() {
+ local section="$1" key="$2"
+ awk -F= -v s="[$section]" -v k="$key" '
+ $0 ~ /^\[/ { cur=$0; gsub(/[[:space:]]/,"",cur) }
+ cur==s && $1 ~ "^[[:space:]]*"k"[[:space:]]*$" {
+ v=$2; gsub(/^[[:space:]]+|[[:space:]]+$/,"",v); print v; exit
+ }' "$INI" 2>/dev/null || true
+}
+
+is_truthy() {
+ case "${1,,}" in yes|true|on|1) return 0 ;; *) return 1 ;; esac
+}
+
+find_setserial() {
+ local p
+ for p in /usr/bin/setserial /bin/setserial /sbin/setserial /usr/sbin/setserial; do
+ [[ -x "$p" ]] && { echo "$p"; return; }
+ done
+ command -v setserial 2>/dev/null || true
+}
+
+setserial_port_irq() {
+ local bin="$1" dev="$2" out port irq
+ out="$("$bin" -g "$dev" 2>/dev/null)" || return 1
+ port="$(printf '%s\n' "$out" | sed -n 's/.*Port:[[:space:]]*\(0x[0-9a-fA-F]\+\).*/\1/p' | head -1)"
+ irq="$(printf '%s\n' "$out" | sed -n 's/.*IRQ:[[:space:]]*\([0-9]\+\).*/\1/p' | head -1)"
+ printf '%s %s\n' "${port:-}" "${irq:-}"
+}
+
+norm_hex() {
+ local v="${1,,}"
+ if [[ "$v" =~ ^0x ]]; then
+ printf '0x%x' "$((v))"
+ else
+ printf '%s' "$v"
+ fi
+}
+
+is_freebsd() {
+ [[ "$(uname -s)" == "FreeBSD" ]]
+}
+
+port_busy() {
+ local serial="$1"
+ local out
+ if command -v lsof >/dev/null 2>&1; then
+ out="$(lsof "$serial" 2>/dev/null || true)"
+ [[ -n "$out" ]] && return 0
+ return 1
+ fi
+ if command -v fuser >/dev/null 2>&1; then
+ out="$(fuser "$serial" 2>/dev/null || true)"
+ [[ -n "$out" ]]
+ return $?
+ fi
+ return 1
+}
+
+baycom_modules_loaded() {
+ local m
+ if ! command -v lsmod >/dev/null 2>&1; then
+ return 1
+ fi
+ for m in baycom_ser_fdx baycom_ser_hdx baycom_par baycom_epp; do
+ if lsmod 2>/dev/null | awk '{print $1}' | grep -qx "$m"; then
+ echo "$m"
+ return 0
+ fi
+ done
+ return 1
+}
+
+cmd_preflight() {
+ local fail=0 dry serial irq iobase name sysirq got ss ss_port ss_irq pair
+ local -a seen_irq=() seen_serial=()
+ if [[ ! -f "$INI" ]]; then
+ err "config not found ($INI) — run max25-lite-setup or copy stacks/max25-bcpr/share/max25-bcpr.ini.example"
+ return 1
+ fi
+ dry="$(ini_get max25-bcpr dry_run)"; [[ -z "$dry" ]] && dry="$(ini_get bcpr dry_run)"
+ sd="$(ini_get max25-bcpr state_dir)"; [[ -z "$sd" ]] && sd="$(ini_get max25-bcpr state_dir)"
+ [[ -n "$sd" ]] && STATE_DIR="$sd"
+ msg "checking BayCom/based PC-COM setup (config: $INI)"
+
+ if mod="$(baycom_modules_loaded)"; then
+ err "Linux BayCom kernel driver is loaded ($mod) — unload it first: sudo modprobe -r $mod"
+ fail=1
+ else
+ ok "no conflicting Linux BayCom kernel driver"
+ fi
+
+ ss="$(find_setserial)"
+ if [[ -z "$ss" ]]; then
+ warn "setserial not installed — limited port checks (optional: sudo apt install setserial)"
+ else
+ ok "setserial found"
+ fi
+
+ for sec in bc0 bc1; do
+ serial="$(ini_get "$sec" serial)"
+ [[ -z "$serial" ]] && continue
+ enabled="$(ini_get "$sec" enabled)"
+ if [[ -n "$enabled" ]] && ! is_truthy "$enabled"; then
+ msg "section [$sec] disabled — skipped"
+ continue
+ fi
+ irq="$(ini_get "$sec" irq)"
+ iobase="$(ini_get "$sec" iobase)"
+ msg "port [$sec]: serial=$serial irq=${irq:-?} iobase=${iobase:-?}"
+
+ if is_truthy "$dry"; then
+ msg "dry-run mode — skipping live port checks"
+ continue
+ fi
+
+ if [[ ! -e "$serial" ]]; then
+ if is_freebsd && [[ -n "$iobase" && "$iobase" != "0" ]]; then
+ warn "$serial not present — using direct I/O address $iobase (FreeBSD)"
+ else
+ err "serial port missing ($serial) — check cable, enable UART in BIOS, or fix max25-bcpr.ini"
+ fail=1; continue
+ fi
+ else
+ ok "serial port exists ($serial)"
+ fi
+
+ if [[ -e "$serial" ]]; then
+ if port_busy "$serial"; then
+ err "serial port busy ($serial) — close minicom, other TNC tools, or second max25d"
+ fail=1
+ else
+ ok "serial port free ($serial)"
+ fi
+ elif is_freebsd && [[ -n "$iobase" && "$iobase" != "0" ]]; then
+ ok "no tty node — direct I/O path"
+ fi
+
+ if [[ -z "$irq" || "$irq" == "0" ]]; then
+ err "IRQ missing in [$sec] — set irq= in max25-bcpr.ini (often 4 for COM1 / ttyS0)"
+ fail=1
+ fi
+
+ name="${serial##*/}"
+ sysirq="/sys/class/tty/${name}/irq"
+ if [[ -r "$sysirq" ]]; then
+ got="$(tr -d '[:space:]' <"$sysirq")"
+ if [[ -n "$irq" && "$irq" != "0" && "$got" != "$irq" ]]; then
+ err "IRQ mismatch — config says $irq but system says $got — fix max25-bcpr.ini"
+ fail=1
+ elif [[ -n "$irq" && "$irq" != "0" ]]; then
+ ok "IRQ $irq matches system"
+ fi
+ else
+ warn "cannot read IRQ from $sysirq — check config by hand"
+ fi
+
+ if [[ -n "$ss" && -n "$iobase" ]]; then
+ pair="$(setserial_port_irq "$ss" "$serial" || true)"
+ ss_port="$(printf '%s' "$pair" | awk '{print $1}')"
+ ss_irq="$(printf '%s' "$pair" | awk '{print $2}')"
+ if [[ -n "$ss_port" ]]; then
+ if [[ "$(norm_hex "$iobase")" != "$(norm_hex "$ss_port")" ]]; then
+ err "I/O address mismatch — config $iobase vs setserial $ss_port"
+ fail=1
+ else
+ ok "I/O address matches setserial"
+ fi
+ else
+ warn "setserial did not report port address for $serial"
+ fi
+ if [[ -n "$ss_irq" && -n "$irq" && "$irq" != "0" && "$ss_irq" != "$irq" ]]; then
+ err "IRQ mismatch — config $irq vs setserial $ss_irq"
+ fail=1
+ elif [[ -n "$ss_irq" && -n "$irq" && "$irq" != "0" ]]; then
+ ok "IRQ matches setserial"
+ fi
+ elif [[ -n "$iobase" && -z "$ss" ]]; then
+ warn "cannot verify I/O address $iobase without setserial"
+ fi
+
+ for s in "${seen_serial[@]:-}"; do
+ if [[ -n "$s" && "$s" == "$serial" ]]; then
+ err "duplicate serial port $serial in config"
+ fail=1
+ fi
+ done
+ for i in "${seen_irq[@]:-}"; do
+ if [[ -n "$i" && -n "$irq" && "$irq" != "0" && "$i" == "$irq" ]]; then
+ err "duplicate IRQ $irq — use different IRQ per port"
+ fail=1
+ fi
+ done
+ seen_serial+=("$serial")
+ [[ -n "$irq" && "$irq" != "0" ]] && seen_irq+=("$irq")
+ done
+
+ if [[ "$fail" -eq 0 ]]; then
+ ok "checks passed — you can run: max25-bcpr-ctl start -c $INI"
+ else
+ err "checks failed — fix errors above, then run preflight again"
+ fi
+ return "$fail"
+}
+
+cmd_start() {
+ local bin init dry
+ cmd_preflight || return 1
+ sd="$(ini_get max25-bcpr state_dir)"; [[ -z "$sd" ]] && sd="$(ini_get bcpr state_dir)"; [[ -n "$sd" ]] && STATE_DIR="$sd"
+ mkdir -p "$STATE_DIR"
+ PIDFILE="$STATE_DIR/max25-bcprd.pid"
+ if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
+ ok "already running (pid $(cat "$PIDFILE"))"
+ return 0
+ fi
+ dry="$(ini_get max25-bcpr dry_run)"; [[ -z "$dry" ]] && dry="$(ini_get bcpr dry_run)"
+ if is_truthy "$dry"; then
+ bin="$(resolve_max25_bcprd)" || {
+ err "test program max25-bcprd not found — run: cmake -B build && cmake --build build"
+ return 1
+ }
+ "$bin" -c "$INI" --dry-run </dev/null >/dev/null 2>&1 &
+ echo $! >"$PIDFILE"
+ ok "started test mode (dry-run) pid $(cat "$PIDFILE") — no radio TX"
+ return 0
+ fi
+ init="$(resolve_max25_bcprd_init)" || {
+ err "start helper max25-bcprd-init not found — run: sudo cmake --install build"
+ return 1
+ }
+ if ! "$init" -c "$INI" </dev/null; then
+ err "PC-COM service failed to start — run preflight again; use sudo if asked"
+ return 1
+ fi
+ if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
+ ok "PC-COM service running (pid $(cat "$PIDFILE")) — next: run-max25d.sh"
+ else
+ err "service exited unexpectedly — check errors above"
+ return 1
+ fi
+}
+
+cmd_stop() {
+ sd="$(ini_get max25-bcpr state_dir 2>/dev/null || true)"
+ [[ -z "${sd:-}" ]] && sd="$(ini_get bcpr state_dir 2>/dev/null || true)"
+ [[ -n "${sd:-}" ]] && STATE_DIR="$sd"
+ PIDFILE="$STATE_DIR/max25-bcprd.pid"
+ if [[ -f "$PIDFILE" ]]; then
+ kill "$(cat "$PIDFILE")" 2>/dev/null || true
+ rm -f "$PIDFILE"
+ fi
+ pkill -x max25-bcprd 2>/dev/null || true
+ pkill -x max25-bcprd-init 2>/dev/null || true
+ ok "PC-COM service stopped"
+}
+
+cmd_status() {
+ sd="$(ini_get max25-bcpr state_dir 2>/dev/null || true)"
+ [[ -z "${sd:-}" ]] && sd="$(ini_get bcpr state_dir 2>/dev/null || true)"
+ [[ -n "${sd:-}" ]] && STATE_DIR="$sd"
+ PIDFILE="$STATE_DIR/max25-bcprd.pid"
+ if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
+ msg "PC-COM service: running (pid $(cat "$PIDFILE"))"
+ else
+ msg "PC-COM service: not running — start with: max25-bcpr-ctl start -c $INI"
+ fi
+ for link in "$STATE_DIR"/kiss-bc*; do
+ [[ -e "$link" ]] || continue
+ msg " link $link → $(readlink -f "$link" 2>/dev/null || echo '?')"
+ done
+}
+
+cmd_smoke() {
+ [[ -x "$SMOKE_SH" ]] || {
+ err "smoke test script missing ($SMOKE_SH) — rebuild the project"
+ return 1
+ }
+ exec "$SMOKE_SH" -c "$INI" "$@"
+}
+
+case "$CMD" in
+ preflight) cmd_preflight ;;
+ start) cmd_start ;;
+ stop) cmd_stop ;;
+ status) cmd_status ;;
+ smoke) cmd_smoke "$@" ;;
+ version) msg "max25-bcpr-ctl 0.5.0-lite (BayCom/based PC-COM)" ;;
+ *) usage; exit 1 ;;
+esac
diff --git a/stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh b/stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh
new file mode 100755
index 0000000..c499401
--- /dev/null
+++ b/stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh
@@ -0,0 +1,566 @@
+#!/usr/bin/env bash
+# max25-bcpr-rxtx-smoke.sh — L0…L4 nofreeze prove-out (BayCom/based / max25-bcpr).
+# Default: offline L0 only, NO TX. Live RX: --live. TX: --live --tx.
+# No calibrate. No baycom_ser_fdx product path.
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+# Tree: stacks/max25-bcpr/tools → repo root. Install: PREFIX/sbin → use installed bins.
+_cand="$(cd "$SCRIPT_DIR/../../.." 2>/dev/null && pwd || true)"
+if [[ -n "$_cand" && -f "$_cand/CMakeLists.txt" && -d "$_cand/stacks/max25-bcpr" ]]; then
+ ROOT="$_cand"
+ CTL="${SCRIPT_DIR}/max25-bcpr-ctl"
+ _INI_DEFAULT="${ROOT}/stacks/max25-bcpr/share/max25-bcpr.ini.example"
+else
+ ROOT=""
+ CTL="$(command -v max25-bcpr-ctl 2>/dev/null || true)"
+ [[ -x "${CTL:-}" ]] || CTL="/usr/local/sbin/max25-bcpr-ctl"
+ _INI_DEFAULT="/etc/max25/max25-bcpr.ini"
+fi
+INI="${MAX25_BCPR_INI:-${BCPR_INI:-$_INI_DEFAULT}}"
+SECONDS_LIVE=15
+TX_SECONDS=3
+DO_LIVE=0
+DO_TX=0
+FORCE_TX=0
+BUILD_DIR=""
+BCPRD=""
+TEST_HDLC=""
+ERR=0
+BCPRD_PID=""
+OWNED_BCPRD=0
+REUSE_STACK=0
+RX_ACTIVITY=0
+
+usage() {
+ cat <<'USAGE'
+Usage: max25-bcpr-rxtx-smoke.sh [-c ini] [--live] [--tx] [--force-tx] [--seconds N] [--tx-seconds N] [--build-dir DIR]
+ L0 offline always. Soft L1 if probes safe. L2/L3 need --live. L4 needs --tx.
+ --tx-seconds: target PTT key window (default 3; uses ~376B info ≈3s like proven inject).
+ Default: NO TX. Hard time caps. Stop only max25-bcprd started by this script.
+ §0.20: --tx requires RX/DCD activity in this run unless --force-tx (debug only).
+USAGE
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -c) INI="$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 ;;
+ --build-dir) BUILD_DIR="$2"; shift 2 ;;
+ -h|--help) usage; exit 0 ;;
+ *) echo "ERROR: unknown arg: $1"; usage; exit 2 ;;
+ esac
+done
+
+if [[ "$DO_TX" -eq 1 && "$DO_LIVE" -eq 0 ]]; then
+ echo "ERROR: --tx requires --live"
+ exit 2
+fi
+
+if [[ "$SECONDS_LIVE" -lt 1 ]]; then
+ SECONDS_LIVE=1
+fi
+# Endurance: long keys (e.g. 60s). bcprd PTT-WD unkeys ~14.5s/500ms.
+if [[ "$SECONDS_LIVE" -gt 180 ]]; then
+ echo "WARN: capping --seconds from $SECONDS_LIVE to 180"
+ SECONDS_LIVE=180
+fi
+if [[ "$TX_SECONDS" -lt 1 ]]; then
+ TX_SECONDS=1
+fi
+if [[ "$TX_SECONDS" -gt 120 ]]; then
+ echo "WARN: capping --tx-seconds from $TX_SECONDS to 120"
+ TX_SECONDS=120
+fi
+
+log() { printf '%s\n' "$*"; }
+ok() { log "PASS: $*"; }
+fail() { log "FAIL: $*"; ERR=1; }
+stage() { log ""; log "=== $* ==="; }
+
+# Attach window: TX + WD overhead + settle.
+need_live=$((TX_SECONDS + TX_SECONDS / 10 + 15))
+if [[ "$DO_TX" -eq 1 && "$SECONDS_LIVE" -lt "$need_live" ]]; then
+ log "NOTE: raising --seconds from $SECONDS_LIVE to $need_live for --tx-seconds $TX_SECONDS"
+ SECONDS_LIVE=$need_live
+fi
+
+pick_build_dir() {
+ local cand
+ if [[ -n "${BCPR_BUILD_DIR:-}" ]]; then
+ echo "$BCPR_BUILD_DIR"
+ return
+ fi
+ if [[ -n "$BUILD_DIR" ]]; then
+ echo "$BUILD_DIR"
+ return
+ fi
+ for cand in \
+ "$ROOT/build-max25-bcpr" \
+ "$ROOT/build-max25-bcpr-${USER:-user}" \
+ "/tmp/max25-build-max25-bcpr-${USER:-user}"; do
+ if [[ -d "$cand" && -w "$cand" ]]; then
+ echo "$cand"
+ return
+ fi
+ if [[ ! -e "$cand" ]] && mkdir -p "$cand" 2>/dev/null && [[ -w "$cand" ]]; then
+ echo "$cand"
+ return
+ fi
+ done
+ if [[ -d "$ROOT/build-max25-bcpr" && ! -w "$ROOT/build-max25-bcpr" ]]; then
+ cand="$ROOT/build-max25-bcpr-${USER:-user}"
+ mkdir -p "$cand"
+ echo "$cand"
+ return
+ fi
+ if [[ -z "$ROOT" ]]; then
+ echo "/tmp/max25-bcpr-installed"
+ return
+ fi
+ echo "$ROOT/build-max25-bcpr-${USER:-user}"
+}
+
+ensure_binaries() {
+ local bd="$1"
+ # Installed layout: never cmake from "/" — use PREFIX binaries.
+ if [[ -z "$ROOT" ]]; then
+ BCPRD="$(command -v max25-bcprd 2>/dev/null || true)"
+ [[ -x "${BCPRD:-}" ]] || BCPRD="/usr/local/bin/max25-bcprd"
+ TEST_HDLC="$(command -v test_hdlc_offline 2>/dev/null || true)"
+ [[ -x "${TEST_HDLC:-}" ]] || TEST_HDLC="/usr/local/bin/test_hdlc_offline"
+ if [[ -x "$BCPRD" ]]; then
+ log "using installed max25-bcprd=$BCPRD"
+ if [[ ! -x "$TEST_HDLC" ]]; then
+ log "NOTE: test_hdlc_offline not installed — skip L0 unit (max25-bcprd dry-run still runs)"
+ TEST_HDLC=""
+ fi
+ return 0
+ fi
+ log "ERROR: max25-bcprd not found (install MAX25 with -DMAX25_BUILD_MAX25_BCPR=ON)"
+ return 1
+ fi
+ BCPRD="$bd/bin/max25-bcprd"
+ TEST_HDLC="$bd/bin/test_hdlc_offline"
+ if [[ -x "$BCPRD" && -x "$TEST_HDLC" ]]; then
+ return 0
+ fi
+ # Also accept binaries from a default cmake build tree (opt-in ON only).
+ local alt
+ for alt in \
+ "$ROOT/build/bin" \
+ "$ROOT/build-default/bin" \
+ "${BCPR_BUILD_DIR:-}/bin"; do
+ if [[ -x "$alt/max25-bcprd" && -x "$alt/test_hdlc_offline" ]]; then
+ BCPRD="$alt/max25-bcprd"
+ TEST_HDLC="$alt/test_hdlc_offline"
+ return 0
+ fi
+ done
+ if [[ "${MAX25_BCPR_NO_AUTOBUILD:-0}" == "1" ]]; then
+ log "SKIP: max25-bcprd not in build tree (MAX25_BCPR_NO_AUTOBUILD=1)"
+ return 1
+ fi
+ log "Building max25-bcpr into $bd (MAX25_BUILD_MAX25_BCPR=ON)…"
+ mkdir -p "$bd"
+ cmake -S "$ROOT" -B "$bd" -DMAX25_BUILD_MAX25_BCPR=ON
+ cmake --build "$bd" --target max25-bcprd test_hdlc_offline test_config_offline
+ [[ -x "$BCPRD" && -x "$TEST_HDLC" ]]
+}
+
+stop_max25_bcprd() {
+ # Only stop bcprd we started. Never blanket-pkill — max25d may own a live stack.
+ if [[ -n "${KISS_HOLD_FD:-}" ]]; then
+ eval "exec ${KISS_HOLD_FD}<&-" 2>/dev/null || true
+ unset KISS_HOLD_FD
+ fi
+ if [[ "${OWNED_BCPRD:-0}" -ne 1 ]]; then
+ BCPRD_PID=""
+ return 0
+ fi
+ if [[ -n "${BCPRD_PID:-}" ]] && kill -0 "$BCPRD_PID" 2>/dev/null; then
+ kill "$BCPRD_PID" 2>/dev/null || true
+ local i
+ for i in 1 2 3 4 5; do
+ kill -0 "$BCPRD_PID" 2>/dev/null || break
+ sleep 0.2
+ done
+ if kill -0 "$BCPRD_PID" 2>/dev/null; then
+ kill -9 "$BCPRD_PID" 2>/dev/null || true
+ fi
+ fi
+ BCPRD_PID=""
+ OWNED_BCPRD=0
+}
+
+trap 'stop_max25_bcprd' EXIT INT TERM
+
+stage "L0 offline HDLC"
+BUILD="$(pick_build_dir)"
+log "build_dir=$BUILD"
+ensure_binaries "$BUILD" || { fail "build/binaries"; exit 1; }
+
+if [[ -n "$TEST_HDLC" && -x "$TEST_HDLC" ]]; then
+ if "$TEST_HDLC"; then
+ ok "test_hdlc_offline"
+ else
+ fail "test_hdlc_offline"
+ fi
+else
+ ok "test_hdlc_offline skipped (installed layout)"
+fi
+
+if "$BCPRD" -c "$INI" --dry-run --once; then
+ ok "max25-bcprd --dry-run --once"
+else
+ fail "max25-bcprd --dry-run --once"
+fi
+
+stage "L1 enhanced preflight"
+if [[ ! -f "$INI" ]]; then
+ fail "missing INI $INI"
+else
+ export BCPRD
+ set +e
+ "$CTL" -c "$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)"
+ else
+ log "NOTE: preflight rc=$pf_rc without --live — L0 is the offline gate"
+ # dry_run example: preflight skips UART and returns 0; real mismatch is soft-note only
+ if [[ "$pf_rc" -ne 0 ]]; then
+ log "WARN: fix INI before --live"
+ fi
+ fi
+ fi
+fi
+
+if [[ "$DO_LIVE" -eq 0 ]]; then
+ stage "summary (offline)"
+ if [[ "$ERR" -eq 0 ]]; then
+ ok "L0 complete — add --live for L2/L3 (RX), --live --tx for L4"
+ exit 0
+ fi
+ exit "$ERR"
+fi
+
+stage "L2/L3 timed attach + RX listen (${SECONDS_LIVE}s)"
+if [[ "$DO_TX" -eq 1 ]]; then
+ log "WARN: --tx asserts PTT (~${TX_SECONDS}s key window; LED/wattmeter visible)"
+fi
+
+dry_val="$(awk -F= '
+ $0 ~ /^\[/ { cur=$0; gsub(/[[:space:]]/,"",cur) }
+ cur=="[max25-bcpr]" && $1 ~ /^[[:space:]]*dry_run[[:space:]]*$/ {
+ v=$2; gsub(/^[[:space:]]+|[[:space:]]+$/,"",v); print tolower(v); exit
+ }' "$INI")"
+case "$dry_val" in
+ yes|true|on|1)
+ fail "live requires dry_run=no in $INI"
+ exit 1
+ ;;
+esac
+
+kiss_link="$(awk -F= '
+ $0 ~ /^\[/ { cur=$0; gsub(/[[:space:]]/,"",cur) }
+ cur=="[bc0]" && $1 ~ /^[[:space:]]*kiss_link[[:space:]]*$/ {
+ v=$2; gsub(/^[[:space:]]+|[[:space:]]+$/,"",v); print v; exit
+ }' "$INI")"
+kiss_link="${kiss_link:-/tmp/max25-bcpr/kiss-bc0}"
+
+iobase="$(awk -F= '
+ $0 ~ /^\[/ { cur=$0; gsub(/[[:space:]]/,"",cur) }
+ cur=="[bc0]" && $1 ~ /^[[:space:]]*iobase[[:space:]]*$/ {
+ v=$2; gsub(/^[[:space:]]+|[[:space:]]+$/,"",v); print v; exit
+ }' "$INI")"
+iobase="${iobase:-0x3f8}"
+
+export BCPRD
+"$CTL" -c "$INI" preflight || { fail "preflight before attach"; exit 1; }
+
+# Prefer live stack (max25d-owned bcprd) — proven KISS inject path.
+# Starting a second bcprd races UART MCR and can yield no visible TX.
+if pgrep -x max25-bcprd >/dev/null 2>&1 && [[ -e "$kiss_link" ]]; then
+ REUSE_STACK=1
+ OWNED_BCPRD=0
+ log "reusing live max25-bcprd + kiss_link ($kiss_link) — no second attach"
+ ok "kiss_link present ($kiss_link) — RX listen active (reuse)"
+else
+ REUSE_STACK=0
+ log "starting max25-bcprd --seconds $SECONDS_LIVE …"
+ "$BCPRD" -c "$INI" --seconds "$SECONDS_LIVE" &
+ BCPRD_PID=$!
+ OWNED_BCPRD=1
+
+ waited=0
+ while [[ "$waited" -lt 5 ]]; do
+ if [[ -e "$kiss_link" ]] || ! kill -0 "$BCPRD_PID" 2>/dev/null; then
+ break
+ fi
+ sleep 0.5
+ waited=$((waited + 1))
+ done
+
+ if ! kill -0 "$BCPRD_PID" 2>/dev/null; then
+ fail "max25-bcprd exited early (attach)"
+ wait "$BCPRD_PID" || true
+ BCPRD_PID=""
+ OWNED_BCPRD=0
+ exit 1
+ fi
+
+ if [[ -e "$kiss_link" ]]; then
+ ok "kiss_link present ($kiss_link) — RX listen active"
+ else
+ log "WARN: kiss_link not yet visible — continuing timed run"
+ fi
+ # Hold KISS slave open — without a peer, POLLHUP drops TX before MCR keys.
+ exec {KISS_HOLD_FD}<>"$kiss_link" || true
+fi
+
+# Also hold when reusing (max25d already holds; extra open is harmless).
+if [[ "$REUSE_STACK" -eq 1 && -e "$kiss_link" && -z "${KISS_HOLD_FD:-}" ]]; then
+ exec {KISS_HOLD_FD}<>"$kiss_link" || true
+fi
+
+state_dir="$(dirname "$kiss_link")"
+# Do not delete rx-activity/dcd files: Soft-DCD may only re-assert on edges;
+# wiping latches caused false L3 misses while noise/RX was actually live.
+# Require a fresh publish during the listen window via mtime when possible.
+RX_STAMP_BEFORE=$(date +%s)
+
+poll_rx_activity() {
+ local f
+ for f in "${state_dir}/dcd-bc0" "${state_dir}/dcd-bc1" \
+ "${state_dir}/rx-activity-bc0" "${state_dir}/rx-activity-bc1"; do
+ if [[ -f "$f" ]] && grep -qE 'dcd=1|rx_activity=1' "$f" 2>/dev/null; then
+ # Prefer content updated during this listen (bcprd publishes ~100ms).
+ local mt
+ mt=$(stat -c %Y "$f" 2>/dev/null || echo 0)
+ if [[ "$mt" -ge "$RX_STAMP_BEFORE" ]]; then
+ RX_ACTIVITY=1
+ return 0
+ fi
+ # Fallback: live content match (reuse / slow clock)
+ RX_ACTIVITY=1
+ return 0
+ fi
+ done
+ return 1
+}
+
+# Owned bcprd --seconds covers L3+L4: leave TX_SECONDS(+1) for L4 before exit.
+RX_LISTEN="$SECONDS_LIVE"
+if [[ "$DO_TX" -eq 1 && "$REUSE_STACK" -eq 0 ]]; then
+ RX_LISTEN=$((SECONDS_LIVE - TX_SECONDS - 1))
+ if [[ "$RX_LISTEN" -lt 2 ]]; then
+ RX_LISTEN=2
+ fi
+fi
+# --force-tx: short listen only (do not burn attach window waiting for DCD).
+if [[ "$DO_TX" -eq 1 && "$FORCE_TX" -eq 1 ]]; then
+ if [[ "$RX_LISTEN" -gt 3 ]]; then
+ RX_LISTEN=3
+ fi
+fi
+
+stage "L3 RX prove (${RX_LISTEN}s listen / ${SECONDS_LIVE}s attach)"
+log "listening for Soft-DCD/noise (dcd-bc* / rx-activity-bc*)…"
+elapsed=0
+while [[ "$elapsed" -lt "$RX_LISTEN" ]]; do
+ if poll_rx_activity; then
+ break
+ fi
+ if [[ "$REUSE_STACK" -eq 0 ]] && ! kill -0 "${BCPRD_PID:-}" 2>/dev/null; then
+ break
+ fi
+ sleep 1
+ elapsed=$((elapsed + 1))
+done
+if [[ "$RX_ACTIVITY" -eq 1 ]]; then
+ ok "RX activity detected (Soft-DCD/noise)"
+else
+ log "NOTE: no DCD/rx-activity — open SQ / noise before --tx (§0.20)"
+fi
+
+# §0.20 — no live TX without RX proof
+if [[ "$DO_TX" -eq 1 ]]; then
+ if [[ "$RX_ACTIVITY" -eq 0 && "$FORCE_TX" -eq 0 ]]; then
+ fail "TX blocked (§0.20): no RX/DCD activity — open SQ/noise, then --live --tx"
+ log "override only with --force-tx (against policy)"
+ if [[ "$REUSE_STACK" -eq 1 ]]; then
+ ok "left live max25-bcprd running (max25d/stack owned)"
+ else
+ stop_max25_bcprd || true
+ fi
+ stage "summary"
+ exit 1
+ fi
+ if [[ "$FORCE_TX" -eq 1 && "$RX_ACTIVITY" -eq 0 ]]; then
+ log "WARN: --force-tx without RX proof (against §0.20 policy)"
+ fi
+fi
+
+if [[ "$DO_TX" -eq 1 ]]; then
+ stage "L4 TX (~${TX_SECONDS}s PTT / MCR)"
+ if [[ ! -e "$kiss_link" ]]; then
+ fail "no kiss_link for TX"
+ else
+ if [[ -z "${KISS_HOLD_FD:-}" ]]; then
+ exec {KISS_HOLD_FD}<>"$kiss_link" || true
+ fi
+ if python3 - "$kiss_link" "$iobase" "$TX_SECONDS" <<'PY'
+import os, sys, threading, time
+
+path, iobase_s, tx_sec_s = sys.argv[1], sys.argv[2], sys.argv[3]
+tx_sec = max(1, int(tx_sec_s))
+iobase = int(iobase_s, 0)
+mcr_port = iobase + 4 # UART MCR
+
+REF_INFO, REF_MS = 376, 3005
+MAX_INFO = 376
+
+def call_addr(call, ssid, last=False):
+ call = call.upper().ljust(6)[:6]
+ b = bytes([(ord(c) << 1) & 0xFF for c in call])
+ ssid_byte = 0x60 | ((ssid & 0x0F) << 1)
+ if last:
+ ssid_byte |= 0x01
+ return b + bytes([ssid_byte])
+
+def kiss_frame(info: bytes) -> bytes:
+ body = call_addr("QST", 0) + call_addr("CB-0", 0, last=True) + bytes([0x03, 0xF0]) + info
+ return b"\xC0\x00" + body + b"\xC0"
+
+def read_mcr(fd):
+ os.lseek(fd, mcr_port, os.SEEK_SET)
+ return ord(os.read(fd, 1))
+
+def mcr_keyed(v):
+ return (v & 0x02) != 0
+
+def monitor_mcr(fd, duration, sample_ms=10):
+ t0 = time.monotonic()
+ first = last = None
+ vals = set()
+ while time.monotonic() - t0 < duration:
+ v = read_mcr(fd)
+ if mcr_keyed(v):
+ vals.add(v)
+ now = time.monotonic()
+ if first is None:
+ first = now
+ last = now
+ time.sleep(sample_ms / 1000.0)
+ keyed_ms = 0.0 if first is None else (last - first) * 1000.0
+ return keyed_ms, vals
+
+remaining_ms = tx_sec * 1000
+bursts = 0
+total_keyed = 0.0
+all_vals = set()
+port_fd = os.open("/dev/port", os.O_RDONLY)
+
+try:
+ while remaining_ms > 200:
+ info_len = min(MAX_INFO, max(32, int(remaining_ms * REF_INFO / REF_MS)))
+ expect_ms = info_len * REF_MS / REF_INFO
+ mon_s = expect_ms / 1000.0 + 1.5
+ info = (b"TXRX" + b"X" * info_len)[:info_len]
+ frame = kiss_frame(info)
+ box = {}
+
+ def run():
+ box["m"] = monitor_mcr(port_fd, mon_s)
+
+ t = threading.Thread(target=run)
+ t.start()
+ time.sleep(0.12)
+ f = open(path, "r+b", buffering=0)
+ try:
+ f.write(frame)
+ t.join()
+ finally:
+ f.close()
+ km, kvals = box["m"]
+ bursts += 1
+ total_keyed += km
+ all_vals |= kvals
+ kv = ",".join(hex(x) for x in sorted(kvals)) or "-"
+ print(
+ "TX: burst %d kiss=%dB info=%dB MCR_keyed=%.0fms vals=[%s]"
+ % (bursts, len(frame), info_len, km, kv)
+ )
+ remaining_ms -= expect_ms
+ if remaining_ms > 200:
+ time.sleep(0.4)
+finally:
+ os.close(port_fd)
+
+lo = tx_sec * 1000 * 0.55
+hi = tx_sec * 1000 * 1.45 + 800
+ok = (total_keyed >= lo) and (total_keyed <= hi) and bool(all_vals)
+print(
+ "TX: total MCR_keyed=%.0fms target=%ds bursts=%d %s"
+ % (total_keyed, tx_sec, bursts, "OK" if ok else "FAIL")
+)
+if not ok:
+ sys.exit(1)
+PY
+ then
+ ok "L4 TX MCR keyed (~${TX_SECONDS}s target)"
+ else
+ fail "L4 TX — no/short MCR key (check fulldup, stack, /dev/port)"
+ fi
+ fi
+fi
+
+if [[ "$REUSE_STACK" -eq 1 ]]; then
+ stage "reuse settle"
+ ok "left live max25-bcprd running (max25d/stack owned)"
+else
+ stage "wait stop"
+ deadline=5
+ elapsed=0
+ while kill -0 "${BCPRD_PID:-}" 2>/dev/null; do
+ if [[ "$elapsed" -ge "$deadline" ]]; then
+ fail "max25-bcprd still running after ${deadline}s — forcing stop"
+ stop_max25_bcprd
+ break
+ fi
+ sleep 1
+ elapsed=$((elapsed + 1))
+ done
+ set +e
+ wait "$BCPRD_PID" 2>/dev/null
+ rc=$?
+ set -e
+ if ! kill -0 "${BCPRD_PID:-}" 2>/dev/null; then
+ ok "max25-bcprd stopped (rc=$rc)"
+ BCPRD_PID=""
+ OWNED_BCPRD=0
+ else
+ fail "max25-bcprd still alive after wait"
+ fi
+fi
+
+stage "summary"
+if [[ "$ERR" -eq 0 ]]; then
+ if [[ "$DO_TX" -eq 1 ]]; then
+ ok "L0–L4 complete (live + TX; RX proven)"
+ else
+ ok "L0–L3 complete (live RX, no TX)"
+ fi
+ exit 0
+fi
+fail "one or more stages failed"
+exit 1
diff --git a/stacks/max25-bcpr/tools/max25-bcpr-ultimate-diag.sh b/stacks/max25-bcpr/tools/max25-bcpr-ultimate-diag.sh
new file mode 100755
index 0000000..e9ade6e
--- /dev/null
+++ b/stacks/max25-bcpr/tools/max25-bcpr-ultimate-diag.sh
@@ -0,0 +1,914 @@
+#!/usr/bin/env bash
+# max25-bcpr-ultimate-diag.sh — interactive BayCom/based (bcpr) TX/RX diagnostic ladder
+# Public mark: BayCom/based. Internal path: max25-bcpr. Never Konverter/converter.
+# Target: AX25WRK1 intermittent "host keys, RF sometimes" (mic OK 4W; host MCR OK).
+#
+# Soft-TNC RE (2026-07-19) — operator warnings only (no MCR/code patch here):
+# • FlexNet SER12 cal PTT watchdog: ~14.5 s keyed → ~500 ms unkey (discharge).
+# Long continuous force-tx/cal: max25-bcprd ptt_wd drops RTS ~500 ms every ~14.5 s
+# (FlexNet SER12 mirror; disable with ptt_wd=no / --no-ptt-wd).
+# • TXD: default pulse THR 0x00; experiment txd_bias=steady (UART break ≈ TFPCX).
+# • Keep max25-bcprd MCR Sailer 0x0e|bit / 0x0d (4PC-COM 0x0A is outlier — do not switch).
+#
+# Usage (Cursor IDE terminal, as operator):
+# sudo -n stacks/max25-bcpr/tools/max25-bcpr-ultimate-diag.sh
+# MAX25_BCPR_INI=/etc/max25/max25-bcpr.ini ./max25-bcpr-ultimate-diag.sh -c /etc/max25/max25-bcpr.ini
+# ./max25-bcpr-ultimate-diag.sh --help
+# ./max25-bcpr-ultimate-diag.sh --all # run phases 1–9 with pauses
+# ./max25-bcpr-ultimate-diag.sh --menu # interactive menu (default)
+#
+# Needs: /usr/bin/sudo -n for ioport / live smoke / cal. Does NOT change MCR code.
+# Does NOT use USB as product TX path. Does NOT kill max25d carelessly.
+#
+# Relies on: max25-bcpr-ctl, max25-bcpr-rxtx-smoke.sh (same directory).
+
+# Note: interactive prompts use read; keep pipefail but relax -e around optional probes.
+set -uo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+ROOT="$(cd "$SCRIPT_DIR/../../.." 2>/dev/null && pwd || true)"
+CTL="${SCRIPT_DIR}/max25-bcpr-ctl"
+SMOKE_SH="${SCRIPT_DIR}/max25-bcpr-rxtx-smoke.sh"
+INI="${MAX25_BCPR_INI:-/etc/max25/max25-bcpr.ini}"
+SUDO="/usr/bin/sudo"
+LOG=""
+MODE="menu" # menu | all | phaseN
+PHASE_ONLY=""
+RUN_TS=""
+
+# Result arrays for summary (parallel indices)
+declare -a RES_PHASE=()
+declare -a RES_HOST=()
+declare -a RES_WATT=()
+declare -a RES_BLED=()
+declare -a RES_RDISP=()
+declare -a RES_NOTE=()
+
+usage() {
+ cat <<'USAGE'
+max25-bcpr-ultimate-diag.sh — BayCom/based (bcpr) interactive diagnostic / force-TX ladder
+
+Usage:
+ max25-bcpr-ultimate-diag.sh [-c INI] [--menu|--all|--phase N] [--help]
+
+Options:
+ -c INI bcpr.ini (default: $MAX25_BCPR_INI or /etc/max25/max25-bcpr.ini)
+ --menu interactive German menu (default)
+ --all run phases 1–9 linearly with pauses + RF prompts
+ --phase N run a single phase (1–9, or 0=restart menu)
+ -h, --help this help
+
+Environment:
+ MAX25_BCPR_INI same as -c
+ BCPRD optional path to max25-bcprd binary
+
+Phases:
+ 1 Preflight (ttyS0, fuser, dosbox-x, max25-bcprd/max25d, INI, locks)
+ 2 Idle MCR sample via /dev/port
+ 3 max25-bcpr-ctl status + telem (tx-last / dcd / rx-activity)
+ 4 RX listen (soft-DCD) — open SQ reminder
+ 5 Force-TX ladder 1s / 3s / 5s / 8s + wattmeter/LED/display prompts
+ 6 Cal/high (max25-bcprd --cal high) if binary supports it
+ 7 Double-burst / back-to-back TX
+ 8 Contact hunt (DE-9 + 3.5mm) + 5s force-tx + wiggle
+ 9 Summary table + verdict hints
+ 0 Optional safe max25-bcprd restart (max25-bcpr-ctl stop/start only)
+ U USB note (SER12 unsupported on USB — not product TX)
+
+Log: /tmp/max25-bcpr-ultimate-diag-YYYYMMDD-HHMMSS.log
+
+Examples:
+ sudo -n stacks/max25-bcpr/tools/max25-bcpr-ultimate-diag.sh --all
+ MAX25_BCPR_INI=/etc/max25/max25-bcpr.ini sudo -n ./max25-bcpr-ultimate-diag.sh --menu
+USAGE
+}
+
+# ---------- logging / UI ----------
+
+log() {
+ local line
+ line="$(printf '%s' "$*")"
+ printf '%s\n' "$line"
+ if [[ -n "${LOG:-}" ]]; then
+ printf '%s\n' "$line" >>"$LOG"
+ fi
+}
+
+log_raw() {
+ # stdin → stdout + log
+ if [[ -n "${LOG:-}" ]]; then
+ tee -a "$LOG"
+ else
+ cat
+ fi
+}
+
+banner() {
+ log ""
+ log "════════════════════════════════════════════════════════════"
+ log "$*"
+ log "════════════════════════════════════════════════════════════"
+}
+
+pause_enter() {
+ local msg="${1:-Weiter mit Enter …}"
+ printf '\n%s ' "$msg" >/dev/tty
+ # shellcheck disable=SC2034
+ local _dummy
+ read -r _dummy </dev/tty || true
+ log "[pause] $msg"
+}
+
+ask() {
+ # ask VAR "prompt"
+ local __var="$1"
+ local __prompt="$2"
+ local __ans=""
+ printf '%s ' "$__prompt" >/dev/tty
+ read -r __ans </dev/tty || true
+ printf -v "$__var" '%s' "$__ans"
+ log "[antwort] $__prompt → ${__ans}"
+}
+
+ask_yn() {
+ # ask_yn VAR "prompt" → stores j/n/y/n normalized to j|n
+ local __var="$1"
+ local __prompt="$2"
+ local __ans=""
+ while true; do
+ printf '%s [j/n]: ' "$__prompt" >/dev/tty
+ read -r __ans </dev/tty || true
+ case "${__ans,,}" in
+ j|ja|y|yes) printf -v "$__var" 'j'; log "[antwort] $__prompt → j"; return 0 ;;
+ n|nein|no) printf -v "$__var" 'n'; log "[antwort] $__prompt → n"; return 0 ;;
+ *) printf 'Bitte j oder n.\n' >/dev/tty ;;
+ esac
+ done
+}
+
+record_result() {
+ local phase="$1" host="$2" watt="$3" bled="$4" rdisp="$5" note="${6:-}"
+ RES_PHASE+=("$phase")
+ RES_HOST+=("$host")
+ RES_WATT+=("$watt")
+ RES_BLED+=("$bled")
+ RES_RDISP+=("$rdisp")
+ RES_NOTE+=("$note")
+}
+
+need_sudo() {
+ if [[ ! -x "$SUDO" ]]; then
+ log "FEHLER: $SUDO fehlt — Live-Phasen brauchen sudo."
+ return 1
+ fi
+ if ! "$SUDO" -n true 2>/dev/null; then
+ log "FEHLER: sudo -n fehlgeschlagen."
+ log " Bitte einmalig: sudo -v (oder NOPASSWD für diesen User)"
+ log " Dann Skript erneut starten."
+ return 1
+ fi
+ return 0
+}
+
+run_sudo() {
+ need_sudo || return 1
+ "$SUDO" -n "$@"
+}
+
+ini_get() {
+ local section="$1" key="$2"
+ [[ -f "$INI" ]] || return 0
+ awk -F= -v s="[$section]" -v k="$key" '
+ $0 ~ /^\[/ { cur=$0; gsub(/[[:space:]]/,"",cur) }
+ cur==s && $1 ~ "^[[:space:]]*"k"[[:space:]]*$" {
+ v=$2; gsub(/^[[:space:]]+|[[:space:]]+$/,"",v); print v; exit
+ }' "$INI" 2>/dev/null || true
+}
+
+resolve_max25-bcprd() {
+ if [[ -n "${BCPRD:-}" && "$BCPRD" != "max25-bcprd" && -x "$BCPRD" ]]; then
+ printf '%s\n' "$BCPRD"; return 0
+ fi
+ if command -v max25-bcprd >/dev/null 2>&1; then
+ command -v max25-bcprd; return 0
+ fi
+ local cand
+ for cand in \
+ "${BCPR_BUILD_DIR:-}/bin/max25-bcprd" \
+ "${ROOT}/build-max25-bcpr/bin/max25-bcprd" \
+ "${ROOT}/build-max25-bcpr-${USER:-user}/bin/max25-bcprd" \
+ "/tmp/max25-build-max25-bcpr-${USER:-user}/bin/max25-bcprd" \
+ "${ROOT}/build/bin/max25-bcprd" \
+ /usr/local/bin/max25-bcprd /usr/bin/max25-bcprd; do
+ [[ -n "$cand" && -x "$cand" ]] && { printf '%s\n' "$cand"; return 0; }
+ done
+ return 1
+}
+
+serial_dev() {
+ local s
+ s="$(ini_get bc0 serial)"
+ printf '%s\n' "${s:-/dev/ttyS0}"
+}
+
+state_dir() {
+ local sd
+ sd="$(ini_get bcpr state_dir)"
+ printf '%s\n' "${sd:-/tmp/max25-bcpr}"
+}
+
+iobase_val() {
+ local b
+ b="$(ini_get bc0 iobase)"
+ printf '%s\n' "${b:-0x3f8}"
+}
+
+ensure_tools() {
+ local err=0
+ if [[ ! -x "$CTL" ]]; then
+ log "FEHLER: max25-bcpr-ctl fehlt: $CTL"; err=1
+ fi
+ if [[ ! -x "$SMOKE_SH" ]]; then
+ log "FEHLER: max25-bcpr-rxtx-smoke.sh fehlt: $SMOKE_SH"; err=1
+ fi
+ return "$err"
+}
+
+# ---------- Phase helpers ----------
+
+# Last smoke host result (PASS|FAIL) — do not capture function stdout (log noise).
+_LAST_HOST="—"
+
+smoke_force_tx() {
+ # smoke_force_tx SECONDS → sets _LAST_HOST
+ local secs="$1"
+ local listen=$((secs + 8))
+ local rc=0
+ [[ "$listen" -lt 12 ]] && listen=12
+ log "→ L4 force-tx ${secs}s (smoke --live --tx --force-tx --tx-seconds ${secs})"
+ run_sudo "$CTL" -c "$INI" smoke --live --tx --force-tx --seconds "$listen" --tx-seconds "$secs" 2>&1 | log_raw
+ rc=${PIPESTATUS[0]}
+ if [[ "$rc" -eq 0 ]]; then
+ _LAST_HOST="PASS"
+ log "HOST: PASS (MCR keyed ~${secs}s target)"
+ else
+ _LAST_HOST="FAIL"
+ log "HOST: FAIL (smoke rc=$rc)"
+ fi
+}
+
+prompt_rf_obs() {
+ # prompt_rf_obs PHASE_LABEL HOST_RESULT
+ # sets globals: _watt _bled _rdisp _extra
+ local label="$1" host="$2"
+ local watt bled rdisp extra
+ log ""
+ log "--- RF-Beobachtung: $label (Host=$host) ---"
+ log "Bitte Wattmeter / Board-LED / Radio-Display während des Keys prüfen."
+ ask watt "Wattmeter W (Zahl oder 0 / ?):"
+ ask_yn bled "Board-LED (PC-COM) an während Key?"
+ ask_yn rdisp "Radio-Display / TX-Anzeige an?"
+ ask extra "Kurznotiz (Enter = keine):"
+ _watt="$watt"
+ _bled="$bled"
+ _rdisp="$rdisp"
+ _extra="$extra"
+ record_result "$label" "$host" "$watt" "$bled" "$rdisp" "$extra"
+}
+
+# ---------- Phases ----------
+
+phase_preflight() {
+ banner "Phase 1 — Preflight"
+ local serial sd iobase irq fulldup dry
+ serial="$(serial_dev)"
+ sd="$(state_dir)"
+ iobase="$(iobase_val)"
+ irq="$(ini_get bc0 irq)"
+ fulldup="$(ini_get bc0 fulldup)"
+ dry="$(ini_get bcpr dry_run)"
+
+ log "INI: $INI"
+ if [[ ! -f "$INI" ]]; then
+ log "FEHLER: INI fehlt: $INI"
+ log " Hinweis: Beispiel → stacks/max25-bcpr/share/bcpr.ini.example → /etc/max25/max25-bcpr.ini"
+ return 1
+ fi
+
+ log "--- INI Auszug [bcpr]/[bc0] ---"
+ log " dry_run=${dry:-?} state_dir=${sd}"
+ log " serial=${serial} iobase=${iobase} irq=${irq:-?} fulldup=${fulldup:-?}"
+ log " mode=$(ini_get bc0 mode) kiss_link=$(ini_get bc0 kiss_link)"
+ log " baud=$(ini_get bc0 baud) tx_delay=$(ini_get bc0 tx_delay)"
+
+ log "--- Seriell ---"
+ if [[ -e "$serial" ]]; then
+ log "OK: $serial existiert"
+ ls -l "$serial" 2>&1 | log_raw || true
+ else
+ log "FEHLER: $serial fehlt"
+ fi
+
+ log "--- fuser / lsof (wer hält Port?) ---"
+ if command -v fuser >/dev/null 2>&1; then
+
+ run_sudo fuser -v "$serial" 2>&1 | log_raw
+
+ else
+ log "WARN: fuser nicht installiert"
+ fi
+ if command -v lsof >/dev/null 2>&1; then
+
+ run_sudo lsof "$serial" 2>&1 | log_raw
+
+ fi
+
+ log "--- dosbox-x / DOS-Gast ---"
+ if pgrep -a -f 'dosbox-x|dosbox' 2>/dev/null | log_raw; then
+ log "WARN: dosbox läuft — kann ttyS0/USB belegen (sniff/passthrough)."
+ else
+ log "OK: kein dosbox/dosbox-x Prozess"
+ fi
+
+ log "--- Prozesse max25-bcprd / max25d ---"
+ pgrep -a -x max25-bcprd 2>/dev/null | log_raw || log " max25-bcprd: nicht laufend"
+ pgrep -a -x max25d 2>/dev/null | log_raw || log " max25d: nicht laufend"
+ # wrapper scripts
+ pgrep -a -f 'run-max25d|max25d' 2>/dev/null | head -20 | log_raw || true
+
+ log "--- Lock / State ($sd) ---"
+ if [[ -d "$sd" ]]; then
+ ls -la "$sd" 2>&1 | log_raw || true
+ for f in "$sd"/lock* "$sd"/*.lock "$sd"/max25-bcprd.pid; do
+ [[ -e "$f" ]] || continue
+ log " lock/pid: $f"
+ [[ -f "$f" ]] && { log " content:"; cat "$f" 2>&1 | log_raw || true; }
+ done
+ else
+ log "WARN: state_dir fehlt: $sd"
+ fi
+
+ log "--- Kernel baycom_* Module (sollten NICHT geladen sein) ---"
+ if command -v lsmod >/dev/null 2>&1; then
+ lsmod 2>/dev/null | awk '/^baycom_/ {print}' | log_raw || log "OK: keine baycom_* Module"
+ fi
+
+ log "--- setserial (falls vorhanden) ---"
+ local ss
+ for ss in /usr/bin/setserial /bin/setserial /sbin/setserial /usr/sbin/setserial; do
+ if [[ -x "$ss" ]]; then
+
+ run_sudo "$ss" -g "$serial" 2>&1 | log_raw
+
+ break
+ fi
+ done
+
+ log "--- max25-bcpr-ctl preflight ---"
+
+ run_sudo "$CTL" -c "$INI" preflight 2>&1 | log_raw
+ local pf=$?
+
+ if [[ "$pf" -eq 0 ]]; then
+ log "OK: preflight PASS"
+ else
+ log "WARN: preflight rc=$pf (Port busy wenn Stack läuft — normal bei live max25d/max25-bcprd)"
+ fi
+
+ log "--- Tools ---"
+ log " CTL=$CTL"
+ log " SMOKE=$SMOKE_SH"
+ local bin
+ if bin="$(resolve_max25-bcprd)"; then
+ log " max25-bcprd=$bin"
+ else
+ log " WARN: max25-bcprd Binary nicht gefunden (Build: -DMAX25_BUILD_BCPR=ON)"
+ fi
+
+ pause_enter "Phase 1 fertig — Enter für weiter …"
+ return 0
+}
+
+phase_idle_mcr() {
+ banner "Phase 2 — Idle MCR Sample (/dev/port)"
+ local iobase mcr_off
+ iobase="$(iobase_val)"
+ # MCR = iobase+4
+ mcr_off=$((iobase + 4))
+ log "iobase=$iobase MCR=$(printf '0x%x' "$mcr_off") (iobase+4)"
+ log "Erwartung Idle (Sailer/bcpr): oft 0x0d (DTR+OUT2+RTS-clear) — Werte nur Info."
+
+ if ! need_sudo; then
+ log "überspringe MCR-Sample (kein sudo)"
+ pause_enter
+ return 0
+ fi
+
+ run_sudo python3 - "$iobase" <<'PY' 2>&1 | log_raw
+import os, sys, time
+iobase = int(sys.argv[1], 0)
+mcr = iobase + 4
+fd = os.open("/dev/port", os.O_RDONLY)
+try:
+ vals = []
+ for i in range(20):
+ os.lseek(fd, mcr, os.SEEK_SET)
+ v = ord(os.read(fd, 1))
+ vals.append(v)
+ time.sleep(0.05)
+ uniq = sorted(set(vals))
+ print("Idle MCR samples (20×50ms): " + ",".join("0x%02x" % v for v in vals))
+ print("Unique: " + ",".join("0x%02x" % v for v in uniq))
+ rts = [(v & 0x02) != 0 for v in vals]
+ print("RTS asserted in any sample: %s" % ("YES" if any(rts) else "no"))
+ if all(v == 0 for v in vals):
+ print("WARN: all-zero — /dev/port may be inaccessible or wrong iobase")
+finally:
+ os.close(fd)
+PY
+ local rc=$?
+
+ if [[ "$rc" -ne 0 ]]; then
+ log "WARN: Idle-MCR Sample fehlgeschlagen (rc=$rc) — CAP_SYS_RAWIO / iobase prüfen"
+ fi
+ pause_enter "Phase 2 fertig — Enter …"
+ return 0
+}
+
+phase_status() {
+ banner "Phase 3 — Status + Telemetrie"
+
+ run_sudo "$CTL" -c "$INI" status 2>&1 | log_raw
+
+ local sd
+ sd="$(state_dir)"
+ log "--- Telemetrie unter $sd ---"
+ for f in "$sd"/tx-last-bc* "$sd"/dcd-bc* "$sd"/rx-activity-bc* "$sd"/kiss-bc*; do
+ [[ -e "$f" ]] || continue
+ log "FILE: $f"
+ if [[ -L "$f" ]]; then
+ log " symlink → $(readlink -f "$f" 2>/dev/null || readlink "$f")"
+ elif [[ -f "$f" ]]; then
+ cat "$f" 2>&1 | log_raw || true
+ fi
+ done
+ pause_enter "Phase 3 fertig — Enter …"
+ return 0
+}
+
+phase_rx_listen() {
+ banner "Phase 4 — RX Listen (soft-DCD)"
+ log "OPERATOR: Squellch öffnen / Rauschen/SQ so dass Soft-DCD aktiv werden kann."
+ log " (§0.20 RX before TX — hier nur Listen, kein TX)"
+ pause_enter "SQ bereit? Enter startet ~12s RX listen …"
+
+ run_sudo "$CTL" -c "$INI" smoke --live --seconds 12 2>&1 | log_raw
+ local rc=${PIPESTATUS[0]}
+
+ if [[ "$rc" -eq 0 ]]; then
+ log "OK: RX-Listen smoke beendet (rc=0)"
+ else
+ log "WARN: RX-Listen smoke rc=$rc (siehe Log; Stack/INI prüfen)"
+ fi
+
+ local sd
+ sd="$(state_dir)"
+ for f in "$sd"/dcd-bc0 "$sd"/rx-activity-bc0; do
+ [[ -f "$f" ]] || continue
+ log "Nach RX: $f"
+ cat "$f" 2>&1 | log_raw || true
+ done
+ pause_enter "Phase 4 fertig — Enter …"
+ return 0
+}
+
+phase_force_tx_ladder() {
+ banner "Phase 5 — Force-TX Ladder (1 / 3 / 5 / 8 s)"
+ log "WARNUNG: --force-tx ohne RX-Nachweis (§0.20 Override) — Debug / Intermittent-Hunt."
+ log "Wattmeter bereithalten. USB-Pfad ist KEIN Produkt-TX."
+ log ""
+ log "PTT-WATCHDOG (FlexNet SER12 / WD-Boards):"
+ log " Hardware kann PTT nach ~14,5 s Dauer-Key abwerfen (Discharge ~500 ms)."
+ log " Bei langen Läufen / vielen Keys hintereinander: zwischendurch UNKEY / Pause ~14 s."
+ log " Sonst: RF fällt trotz Host-MCR PASS (Watchdog, kein UART-Fehler)."
+ log ""
+ log "BEOBACHTUNGSHINWEIS TXD (Charge-Pump):"
+ log " max25-bcprd/Sailer: THR 0x00 gepulst · TFPCX: TXD oft steady +12 V."
+ log " Notiz wenn RF mitten im Key einbricht / flackert (Pump/Bias-Kandidat)."
+ pause_enter "Bereit für Ladder? Enter …"
+
+ local secs host
+ for secs in 1 3 5 8; do
+ banner "Force-TX ${secs}s"
+ pause_enter "Wattmeter beobachten — Enter startet ${secs}s Key …"
+ smoke_force_tx "$secs"
+ host="$_LAST_HOST"
+ # show telem snapshot
+ local sd tl
+ sd="$(state_dir)"
+ tl="$sd/tx-last-bc0"
+ if [[ -f "$tl" ]]; then
+ log "--- tx-last-bc0 nach ${secs}s ---"
+ cat "$tl" 2>&1 | log_raw || true
+ fi
+ prompt_rf_obs "L4-${secs}s" "$host"
+ pause_enter "Nächste Stufe — Enter …"
+ done
+ return 0
+}
+
+phase_cal_high() {
+ banner "Phase 6 — Cal/high (max25-bcprd --cal high)"
+ local bin
+ if ! bin="$(resolve_max25-bcprd)"; then
+ log "WARN: max25-bcprd nicht gefunden — Cal übersprungen"
+ pause_enter
+ return 0
+ fi
+
+ # Prefer strings(1); never start bare max25-bcprd. Source contract: --cal high|low|alt.
+ if command -v strings >/dev/null 2>&1 && ! strings "$bin" 2>/dev/null | grep -qF -- '--cal'; then
+ log "WARN: strings fand kein --cal in Binary — Phase trotzdem anbieten (Operator kann abbrechen)"
+ fi
+
+ log "max25-bcprd=$bin"
+ log "Cal = kontinuierlicher SER12 Tone+PTT (DOS cal.exe Stil), kein KISS."
+ log "Sicher: nur max25-bcprd stoppen via max25-bcpr-ctl — max25d Wrapper NICHT hart killen."
+ log ""
+ log "PTT-WATCHDOG: FlexNet cal pausiert alle ~14,5 s für ~500 ms (PTT-Discharge)."
+ log " Dieses Skript: --cal high ~10 s (unter WD). Längere manuelle Cal/"
+ log " Dauer-TX: UNKEY/Pause ~14 s einplanen — sonst RF-Tot auf WD-Boards."
+ log "TXD: max25-bcprd pulst THR 0x00 (nicht TFPCX-steady) — RF-Einbruch mid-cal notieren."
+ log ""
+ local do_cal
+ ask_yn do_cal "max25-bcprd stoppen und --cal high ~10s starten?"
+ if [[ "$do_cal" != "j" ]]; then
+ log "Cal übersprungen (Operator)"
+ pause_enter
+ return 0
+ fi
+
+ if ! need_sudo; then
+ pause_enter
+ return 0
+ fi
+
+ log "→ max25-bcpr-ctl stop"
+
+ run_sudo "$CTL" -c "$INI" stop 2>&1 | log_raw
+
+ sleep 1
+
+ # Ensure no stray max25-bcprd
+ if pgrep -x max25-bcprd >/dev/null 2>&1; then
+ log "WARN: max25-bcprd noch aktiv nach stop — pkill -x max25-bcprd (kein max25d)"
+
+ run_sudo pkill -x max25-bcprd 2>&1 | log_raw
+
+ sleep 1
+ fi
+
+ log "→ $bin -c $INI --cal high --seconds 10"
+ pause_enter "Wattmeter bereit — Enter startet cal high 10s …"
+
+ run_sudo "$bin" -c "$INI" --cal high --seconds 10 2>&1 | log_raw
+ local rc=$?
+
+ local host="PASS"
+ [[ "$rc" -eq 0 ]] || host="FAIL(rc=$rc)"
+ prompt_rf_obs "CAL-high-10s" "$host"
+
+ local restart
+ ask_yn restart "max25-bcprd danach wieder starten (max25-bcpr-ctl start)?"
+ if [[ "$restart" == "j" ]]; then
+
+ run_sudo "$CTL" -c "$INI" start 2>&1 | log_raw
+
+ log "Hinweis: wenn max25d den Stack besitzt, ggf. max25d neu starten (Operator, nicht dieses Skript)."
+ fi
+ pause_enter "Phase 6 fertig — Enter …"
+ return 0
+}
+
+phase_double_burst() {
+ banner "Phase 7 — Double Burst (back-to-back TX)"
+ log "Zwei Force-TX 3s hintereinander (kurze Pause dazwischen)."
+ pause_enter "Enter startet Burst A …"
+ local host_a host_b
+ smoke_force_tx 3
+ host_a="$_LAST_HOST"
+ sleep 1
+ log "Burst B …"
+ smoke_force_tx 3
+ host_b="$_LAST_HOST"
+ prompt_rf_obs "DoubleBurst-A+B" "${host_a}/${host_b}"
+ pause_enter "Phase 7 fertig — Enter …"
+ return 0
+}
+
+phase_contact_hunt() {
+ banner "Phase 8 — Contact Hunt (DE-9 + 3.5mm)"
+ log "OPERATOR-CHECKLISTE:"
+ log " 1) DE-9 (PC-COM ↔ Host) und 3.5mm (Modem ↔ Radio) fest stecken"
+ log " 2) Während dem nächsten Key leicht wackeln (Stecker/Kabel)"
+ log " 3) Wattmeter + Board-LED + Radio-Display beobachten"
+ log " Ziel: intermittenter Kontakt vs. dauerhaft tot unterscheiden"
+ pause_enter "Stecker fest? Enter startet 5s force-tx (währenddessen wackeln) …"
+
+ local host
+ smoke_force_tx 5
+ host="$_LAST_HOST"
+ local obs
+ ask obs "Beobachtung während Wackeln (z.B. 'kurz 2W' / 'immer 0' / 'LED flackert'):"
+ prompt_rf_obs "Contact-5s" "$host"
+ # overwrite last note with wiggle observation if empty note
+ if [[ -n "$obs" && ${#RES_NOTE[@]} -gt 0 ]]; then
+ RES_NOTE[$((${#RES_NOTE[@]} - 1))]="$obs | ${_extra:-}"
+ log "[contact-note] $obs"
+ fi
+ pause_enter "Phase 8 fertig — Enter …"
+ return 0
+}
+
+phase_usb_note() {
+ banner "Hinweis U — USB SER12 (kein Produkt-TX)"
+ log "USB-UART (z.B. /dev/ttyUSB0 / FTDI) ist KEIN unterstützter max25-bcprd SER12-Pfad."
+ log " max25-bcprd braucht ioperm|/dev/port + echte ISA/LPC iobase — USB hat das nicht."
+ log " Station: RF nur ttyS0+CB; USB0 = Modem ohne Radio → Wattmeter ungültig."
+ log " DOSBox Soft-TNC auf USB ≠ bcpr Produkt-TX."
+ pause_enter
+ return 0
+}
+
+phase_restart_menu() {
+ banner "Optional 0 — Sicheres max25-bcprd Restart"
+ log "Methode (aus max25-bcpr-ctl): stop → start. Killt NICHT max25d."
+ log "Wenn max25d den Stack besitzt: nach stop/start ggf. max25d-seitig neu binden."
+ log ""
+ log " Aktueller Status:"
+
+ run_sudo "$CTL" -c "$INI" status 2>&1 | log_raw
+
+ local act
+ ask act "Aktion: [s]top / [t]start / [r]estart / [a]bbruch:"
+ case "${act,,}" in
+ s|stop)
+ run_sudo "$CTL" -c "$INI" stop 2>&1 | log_raw
+ ;;
+ t|start)
+ run_sudo "$CTL" -c "$INI" start 2>&1 | log_raw
+ ;;
+ r|restart|re)
+ run_sudo "$CTL" -c "$INI" stop 2>&1 | log_raw
+ sleep 1
+ run_sudo "$CTL" -c "$INI" start 2>&1 | log_raw
+ ;;
+ *)
+ log "Abbruch — keine Änderung"
+ ;;
+ esac
+
+ run_sudo "$CTL" -c "$INI" status 2>&1 | log_raw
+
+ pause_enter
+ return 0
+}
+
+phase_summary() {
+ banner "Phase 9 — Summary + Verdict"
+ log "Logdatei: $LOG"
+ log ""
+ log "┌──────────────────┬──────────┬──────────┬──────────┬──────────┐"
+ log "│ Phase │ Host │ Watt W │ BoardLED │ RadioTX │"
+ log "├──────────────────┼──────────┼──────────┼──────────┼──────────┤"
+ local i n
+ n=${#RES_PHASE[@]}
+ if [[ "$n" -eq 0 ]]; then
+ log "│ (keine TX-Phasen aufgezeichnet) │"
+ else
+ for ((i = 0; i < n; i++)); do
+ printf '│ %-16s │ %-8s │ %-8s │ %-8s │ %-8s │\n' \
+ "${RES_PHASE[$i]:0:16}" \
+ "${RES_HOST[$i]:0:8}" \
+ "${RES_WATT[$i]:0:8}" \
+ "${RES_BLED[$i]:0:8}" \
+ "${RES_RDISP[$i]:0:8}" | log_raw
+ if [[ -n "${RES_NOTE[$i]:-}" ]]; then
+ log "│ note: ${RES_NOTE[$i]}"
+ fi
+ done
+ fi
+ log "└──────────────────┴──────────┴──────────┴──────────┴──────────┘"
+
+ # Verdict heuristics
+ local any_host_pass=0 any_host_fail=0 any_rf=0 all_rf_zero=1
+ for ((i = 0; i < n; i++)); do
+ case "${RES_HOST[$i]}" in
+ *PASS*) any_host_pass=1 ;;
+ *FAIL*) any_host_fail=1 ;;
+ esac
+ case "${RES_WATT[$i]}" in
+ ''|'0'|'0.0'|'?'|'n'|'nein') ;;
+ *)
+ # non-zero / non-empty that looks like power
+ if [[ "${RES_WATT[$i]}" =~ ^[0-9]*\.?[0-9]+$ ]]; then
+ if awk -v w="${RES_WATT[$i]}" 'BEGIN{exit !(w>0)}'; then
+ any_rf=1
+ all_rf_zero=0
+ fi
+ else
+ # free text — if contains number >0 heuristic
+ if [[ "${RES_WATT[$i]}" =~ [1-9] ]]; then
+ any_rf=1
+ all_rf_zero=0
+ fi
+ fi
+ ;;
+ esac
+ if [[ "${RES_BLED[$i]}" == "j" || "${RES_RDISP[$i]}" == "j" ]]; then
+ any_rf=1
+ all_rf_zero=0
+ fi
+ done
+
+ log ""
+ log "=== Verdict-Hinweise (Heuristik, kein Automatik-Urteil) ==="
+ if [[ "$n" -eq 0 ]]; then
+ log "• Keine Ladder-Daten — nur Preflight/Status gelaufen."
+ elif [[ "$any_host_pass" -eq 1 && "$all_rf_zero" -eq 1 && "$any_rf" -eq 0 ]]; then
+ log "• HOST OK / RF tot oder 0 W: Fehlerklasse NACH UART"
+ log " → DE-9/3.5mm Kontakt, Charge-Pump/Vcc, PTT-Transistor, Mic-Buchse, Radio"
+ log " → Mic-alone 4 W OK stützt: Radio selbst ok; Pfad Modem↔Mic intermittierend"
+ elif [[ "$any_host_pass" -eq 1 && "$any_rf" -eq 1 ]]; then
+ log "• HOST OK / RF zeitweise sichtbar: INTERMITTENT RF (Kontakt/Pump/AF)"
+ log " → Contact-Hunt wiederholen; zwischen langen Keys Pause ~14 s (PTT-WD)"
+ log " → TXD pulse vs steady (TFPCX) als Beobachtung; Kabel/Stecker fest"
+ elif [[ "$any_host_fail" -eq 1 && "$any_host_pass" -eq 0 ]]; then
+ log "• HOST FAIL: zuerst Stack/KISS/fulldup/MCR — nicht primär Wattmeter"
+ log " → max25-bcpr-ctl status, kiss_link, fulldup=yes bei Soft-DCD, kein zweites max25-bcprd"
+ log " → MCR bleibt Sailer 0x0e|bit / 0x0d (nicht 4PC-COM 0x0A)"
+ else
+ log "• Gemischte Ergebnisse — Log + Notizen vergleichen; Phasen 5/8 wiederholen."
+ fi
+ log ""
+ log "SSoT: operator research notes (private)"
+ log " 2026-07-19-bcpr-mcr-ok-rf-zero-after-4w.md · winning-recipe · RF intermittent"
+ log " soft-TNC RE: 2026-07-19-baycom-soft-tnc-serial-ptt-re.md (WD · TXD · MCR)"
+ log "USB: kein Produkt-TX (Phase U)."
+ log ""
+ log "Log gespeichert: $LOG"
+ return 0
+}
+
+run_all() {
+ phase_preflight
+ phase_idle_mcr
+ phase_status
+ phase_rx_listen
+ phase_force_tx_ladder
+ phase_cal_high
+ phase_double_burst
+ phase_contact_hunt
+ phase_usb_note
+ phase_summary
+}
+
+menu_loop() {
+ while true; do
+ banner "max25-bcpr Ultimate Diag — Menü"
+ log "INI=$INI"
+ log "Log=$LOG"
+ log ""
+ log " 1) Preflight"
+ log " 2) Idle MCR"
+ log " 3) Status + Telem"
+ log " 4) RX Listen"
+ log " 5) Force-TX Ladder 1/3/5/8s"
+ log " 6) Cal/high"
+ log " 7) Double Burst"
+ log " 8) Contact Hunt"
+ log " 9) Summary / Verdict"
+ log " A) Alle Phasen 1–9 (+ USB-Hinweis)"
+ log " 0) max25-bcprd stop/start (sicher)"
+ log " U) USB-Hinweis"
+ log " Q) Beenden"
+ log ""
+ local choice
+ ask choice "Wahl:"
+ case "${choice^^}" in
+ 1) phase_preflight ;;
+ 2) phase_idle_mcr ;;
+ 3) phase_status ;;
+ 4) phase_rx_listen ;;
+ 5) phase_force_tx_ladder ;;
+ 6) phase_cal_high ;;
+ 7) phase_double_burst ;;
+ 8) phase_contact_hunt ;;
+ 9) phase_summary ;;
+ A|ALL) run_all ;;
+ 0) phase_restart_menu ;;
+ U) phase_usb_note ;;
+ Q|X|EXIT|QUIT)
+ phase_summary
+ log "Ende."
+ return 0
+ ;;
+ *)
+ log "Unbekannte Wahl: $choice"
+ ;;
+ esac
+ done
+}
+
+# ---------- main ----------
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -c)
+ [[ $# -ge 2 ]] || { echo "ERROR: -c needs INI path"; exit 2; }
+ INI="$2"
+ shift 2
+ ;;
+ --menu) MODE="menu"; shift ;;
+ --all) MODE="all"; shift ;;
+ --phase)
+ [[ $# -ge 2 ]] || { echo "ERROR: --phase needs N"; exit 2; }
+ MODE="phase"
+ PHASE_ONLY="$2"
+ shift 2
+ ;;
+ -h|--help) usage; exit 0 ;;
+ *)
+ echo "ERROR: unknown arg: $1"
+ usage
+ exit 2
+ ;;
+ esac
+done
+
+RUN_TS="$(date +%Y%m%d-%H%M%S)"
+LOG="/tmp/max25-bcpr-ultimate-diag-${RUN_TS}.log"
+: >"$LOG" || {
+ echo "FEHLER: kann Log nicht schreiben: $LOG"
+ exit 1
+}
+
+# Interactive reads use /dev/tty; log()/log_raw append to $LOG (no exec-tee double).
+banner "max25-bcpr Ultimate Diag — BayCom/based (bcpr)"
+log "Start: $(date -R 2>/dev/null || date)"
+log "Host: $(hostname 2>/dev/null || echo '?')"
+log "User: $(id -un 2>/dev/null || echo '?') uid=$(id -u)"
+log "INI: $INI (override: MAX25_BCPR_INI / -c)"
+log "Log: $LOG"
+log "Tools: $CTL"
+log ""
+log "Hinweis: PTT-Watchdog ~14,5 s / 500 ms — bei langen Keys unkey/pausieren."
+log " TXD: max25-bcprd pulst (Sailer); TFPCX oft steady — RF mid-key notieren."
+log " MCR: Sailer belassen (kein 4PC-COM 0x0A-Experiment in diesem Skript)."
+log ""
+
+ensure_tools || {
+ log "Abbruch: Tools fehlen."
+ exit 1
+}
+
+# Soft check sudo early (warn only for menu; hard for --all live)
+if ! need_sudo; then
+ log "WARN: ohne sudo -n sind Live/TX/Cal/MCR-Phasen blockiert."
+ if [[ "$MODE" == "all" ]]; then
+ log "Abbruch (--all braucht sudo -n)."
+ exit 1
+ fi
+fi
+
+case "$MODE" in
+ all)
+ run_all
+ ;;
+ phase)
+ case "$PHASE_ONLY" in
+ 1) phase_preflight ;;
+ 2) phase_idle_mcr ;;
+ 3) phase_status ;;
+ 4) phase_rx_listen ;;
+ 5) phase_force_tx_ladder ;;
+ 6) phase_cal_high ;;
+ 7) phase_double_burst ;;
+ 8) phase_contact_hunt ;;
+ 9) phase_summary ;;
+ 0) phase_restart_menu ;;
+ U|u) phase_usb_note ;;
+ *)
+ log "FEHLER: unbekannte Phase $PHASE_ONLY"
+ exit 2
+ ;;
+ esac
+ ;;
+ menu|*)
+ menu_loop
+ ;;
+esac
+
+log ""
+log "Fertig. Log: $LOG"
+exit 0
diff --git a/stacks/terminal/.gitignore b/stacks/terminal/.gitignore
new file mode 100644
index 0000000..bb6aac1
--- /dev/null
+++ b/stacks/terminal/.gitignore
@@ -0,0 +1,9 @@
+# Built client artifacts (make -C stacks/terminal all)
+max25-terminal
+max25-client
+*.o
+amiga/max25-terminal
+amiga/*.o
+
+# vault/agent — never ship
+AGENT-INDEX.md
diff --git a/stacks/terminal/CMakeLists.txt b/stacks/terminal/CMakeLists.txt
new file mode 100644
index 0000000..d391b9e
--- /dev/null
+++ b/stacks/terminal/CMakeLists.txt
@@ -0,0 +1,40 @@
+set(MAX25_TERMINAL_SRCS
+ max25_terminal.c
+ max25_proto.c
+ max25_ui.c
+)
+
+add_executable(max25-terminal ${MAX25_TERMINAL_SRCS})
+target_include_directories(max25-terminal PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}")
+
+find_package(Curses)
+if(Curses_FOUND AND TARGET Curses::Curses)
+ target_compile_definitions(max25-terminal PRIVATE MAX25_HAVE_NCURSES)
+ target_link_libraries(max25-terminal PRIVATE Curses::Curses)
+elseif(Curses_FOUND)
+ target_compile_definitions(max25-terminal PRIVATE MAX25_HAVE_NCURSES)
+ target_include_directories(max25-terminal PRIVATE ${CURSES_INCLUDE_DIR})
+ target_link_libraries(max25-terminal PRIVATE ${CURSES_LIBRARIES})
+elseif(UNIX AND NOT APPLE)
+ target_compile_definitions(max25-terminal PRIVATE MAX25_HAVE_NCURSES)
+ target_link_libraries(max25-terminal PRIVATE ncurses tinfo)
+endif()
+
+target_compile_options(max25-terminal PRIVATE -Wall -Wextra -std=c11 -O2)
+
+add_custom_command(TARGET max25-terminal POST_BUILD
+ COMMAND "${CMAKE_COMMAND}" -E rm -f "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/max25-client"
+ COMMAND "${CMAKE_COMMAND}" -E create_symlink max25-terminal "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/max25-client"
+ COMMENT "max25-client -> max25-terminal"
+)
+
+install(TARGETS max25-terminal RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}")
+
+install(CODE "
+ set(_dst \"\$ENV{DESTDIR}\${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_BINDIR}/max25-client\")
+ set(_src \"\$ENV{DESTDIR}\${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_BINDIR}/max25-terminal\")
+ if(EXISTS \"\${_dst}\")
+ file(REMOVE \"\${_dst}\")
+ endif()
+ execute_process(COMMAND \"${CMAKE_COMMAND}\" -E create_symlink max25-terminal \"\${_dst}\")
+")
diff --git a/stacks/terminal/README.md b/stacks/terminal/README.md
new file mode 100644
index 0000000..609468d
--- /dev/null
+++ b/stacks/terminal/README.md
@@ -0,0 +1,31 @@
+# MAX25 Terminal (`max25-terminal` / `max25-client`)
+
+Official text-only operator client for `max25d`. Cross-platform via M25/1 (TCP or Unix).
+
+| Doc | Audience |
+|-----|----------|
+| [../../include/max25/protocol.md](../../include/max25/protocol.md) | Developer — M25/1 binding |
+| [../../docs/README.md](../../docs/README.md) | Operator — doc index (stub; depth in research vault) |
+
+## Build & test
+
+```bash
+./scripts/build.sh
+bash stacks/terminal/test-terminal.sh
+```
+
+Requires C11 compiler and ncurses (`libncurses-dev` on Linux).
+
+## Run (canonical)
+
+Prefer install/PATH or `build*/bin/max25-terminal`. Tree binary may live at `./max25-terminal` after copy (gitignored).
+
+```bash
+max25-terminal -U /run/max25/modem.sock
+# with [devices] default set in max25d.ini — no -d needed
+# then: CONNECT (F10→6) → SEND …
+```
+
+Optional: `-d <id>` / `SET DEVICE` / F10→7 to override the default device.
+
+Daemon: [stacks/daemon/README.md](../daemon/README.md) — **not** under this directory.
diff --git a/stacks/terminal/amiga/Makefile b/stacks/terminal/amiga/Makefile
new file mode 100644
index 0000000..84cc53b
--- /dev/null
+++ b/stacks/terminal/amiga/Makefile
@@ -0,0 +1,32 @@
+# MAX25 Terminal — AmigaOS 3.9+ cross-build (bebbo-gcc / clib2)
+
+AMIGA_SDK ?= /opt/amiga
+CC := $(AMIGA_SDK)/bin/m68k-amigaos-gcc
+CFLAGS ?= -Wall -Wextra -std=c11 -O2 -mcrt=clib2
+LDFLAGS ?= -mcrt=clib2
+LIBS := -lnet -lunix
+
+PROTO_DIR := ..
+SRCS := max25_terminal_amiga.c $(PROTO_DIR)/max25_proto.c
+OBJS := max25_terminal_amiga.o max25_proto.o
+TARGET := max25-terminal
+
+.PHONY: all test clean
+
+all: $(TARGET)
+
+$(TARGET): $(OBJS)
+ $(CC) $(LDFLAGS) -o $@ $(OBJS) $(LIBS)
+
+max25_terminal_amiga.o: max25_terminal_amiga.c $(PROTO_DIR)/max25_proto.h
+ $(CC) $(CFLAGS) -DMAX25_AMIGA=1 -I$(PROTO_DIR) -c -o $@ max25_terminal_amiga.c
+
+max25_proto.o: $(PROTO_DIR)/max25_proto.c $(PROTO_DIR)/max25_proto.h
+ $(CC) $(CFLAGS) -DMAX25_AMIGA=1 -I$(PROTO_DIR) -c -o $@ $(PROTO_DIR)/max25_proto.c
+
+test: all
+ @test -x ./$(TARGET)
+ file ./$(TARGET) | grep -qiE 'amiga|68k|m68k' && echo "OK: Amiga m68k binary"
+
+clean:
+ rm -f $(OBJS) $(TARGET)
diff --git a/stacks/terminal/amiga/max25_terminal_amiga.c b/stacks/terminal/amiga/max25_terminal_amiga.c
new file mode 100644
index 0000000..3b0f513
--- /dev/null
+++ b/stacks/terminal/amiga/max25_terminal_amiga.c
@@ -0,0 +1,309 @@
+/*
+ * max25-terminal — AmigaOS 3.9+ reduced operator client (TCP/M25/1 only).
+ *
+ * HyBBX-style thin client: no ncurses/F10 menu; connects to Linux max25d.
+ */
+#define _DEFAULT_SOURCE 1
+
+#include "../max25_proto.h"
+
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <sys/select.h>
+
+#ifdef __AMIGA__
+#include <sys/socket.h>
+#endif
+
+typedef struct {
+ char host[256];
+ unsigned port;
+ char tcp_password[128];
+ int ax25_ui;
+ int verbose;
+ int probe;
+} amiga_config_t;
+
+static void config_defaults(amiga_config_t *cfg)
+{
+ const char *env;
+
+ memset(cfg, 0, sizeof(*cfg));
+ strncpy(cfg->host, "127.0.0.1", sizeof(cfg->host) - 1);
+ cfg->port = MAX25_DEFAULT_PORT;
+ cfg->ax25_ui = 1;
+
+ env = getenv("MAX25_HOST");
+ if (env != NULL && env[0] != '\0') {
+ strncpy(cfg->host, env, sizeof(cfg->host) - 1);
+ }
+ env = getenv("MAX25_PORT");
+ if (env != NULL && env[0] != '\0') {
+ cfg->port = (unsigned)strtoul(env, NULL, 10);
+ }
+ env = getenv("MAX25_TCP_PASSWORD");
+ if (env != NULL) {
+ strncpy(cfg->tcp_password, env, sizeof(cfg->tcp_password) - 1);
+ }
+}
+
+static void usage(const char *prog)
+{
+ fprintf(stderr,
+ "MAX25 Terminal (AmigaOS) — reduced CLI client for max25d\n\n"
+ "Usage: %s [options] [host] [port]\n\n"
+ "Options:\n"
+ " -H HOST max25d TCP host (default 127.0.0.1)\n"
+ " -p PORT max25d TCP port (default %u)\n"
+ " -P PASSWORD TCP auth password (plain-text AUTH)\n"
+ " --ax25-ui AX.25 UI mode (default on)\n"
+ " --no-ax25-ui\n"
+ " -v Verbose protocol log on stderr\n"
+ " --probe Connect, print STATUS, exit\n"
+ " -h Help\n\n"
+ "Session: type a line to SEND; /status /callerid /callid /connect /quit\n"
+ "Environment: MAX25_HOST, MAX25_PORT, MAX25_TCP_PASSWORD\n\n",
+ prog, MAX25_DEFAULT_PORT);
+}
+
+static int parse_args(int argc, char **argv, amiga_config_t *cfg)
+{
+ int i;
+ int positional = 0;
+
+ for (i = 1; i < argc; i++) {
+ if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
+ usage(argv[0]);
+ return 1;
+ }
+ if (strcmp(argv[i], "-H") == 0 && i + 1 < argc) {
+ strncpy(cfg->host, argv[++i], sizeof(cfg->host) - 1);
+ continue;
+ }
+ if (strcmp(argv[i], "-p") == 0 && i + 1 < argc) {
+ cfg->port = (unsigned)strtoul(argv[++i], NULL, 10);
+ continue;
+ }
+ if ((strcmp(argv[i], "-P") == 0 || strcmp(argv[i], "--password") == 0) &&
+ i + 1 < argc) {
+ strncpy(cfg->tcp_password, argv[++i], sizeof(cfg->tcp_password) - 1);
+ continue;
+ }
+ if (strcmp(argv[i], "-v") == 0 || strcmp(argv[i], "--verbose") == 0) {
+ cfg->verbose = 1;
+ continue;
+ }
+ if (strcmp(argv[i], "--probe") == 0) {
+ cfg->probe = 1;
+ continue;
+ }
+ if (strcmp(argv[i], "--ax25-ui") == 0) {
+ cfg->ax25_ui = 1;
+ continue;
+ }
+ if (strcmp(argv[i], "--no-ax25-ui") == 0) {
+ cfg->ax25_ui = 0;
+ continue;
+ }
+ if (argv[i][0] == '-') {
+ fprintf(stderr, "%s: unknown option %s\n", argv[0], argv[i]);
+ return -1;
+ }
+ if (positional == 0) {
+ strncpy(cfg->host, argv[i], sizeof(cfg->host) - 1);
+ } else if (positional == 1) {
+ cfg->port = (unsigned)strtoul(argv[i], NULL, 10);
+ }
+ positional++;
+ }
+ return 0;
+}
+
+static void print_rx(const char *line, int verbose)
+{
+ if (verbose) {
+ fprintf(stderr, "max25-terminal: << %s\n", line);
+ }
+ if (strncmp(line, "RX ", 3) == 0) {
+ printf("<< %s\n", line + 3);
+ } else if (strncmp(line, "ERR ", 4) == 0) {
+ fprintf(stderr, "!! %s\n", line);
+ } else if (strncmp(line, "EVENT ", 6) == 0) {
+ printf("-- %s\n", line);
+ }
+ fflush(stdout);
+ fflush(stderr);
+}
+
+static int drain_socket(max25_client_t *client, int verbose)
+{
+ char line[MAX25_LINE_MAX];
+ int got = 0;
+
+ for (;;) {
+ fd_set rfds;
+ struct timeval tv;
+ int fd = max25_client_fd(client);
+
+ FD_ZERO(&rfds);
+ FD_SET(fd, &rfds);
+ tv.tv_sec = 0;
+ tv.tv_usec = 0;
+ if (select(fd + 1, &rfds, NULL, NULL, &tv) <= 0) {
+ break;
+ }
+ if (max25_client_read_line(client, line, sizeof(line)) != 0) {
+ return -1;
+ }
+ print_rx(line, verbose);
+ got = 1;
+ if (strcmp(line, "OK") == 0) {
+ break;
+ }
+ }
+ return got ? 0 : 0;
+}
+
+static int send_cmd_wait(max25_client_t *client, const char *cmd, int verbose)
+{
+ char line[MAX25_LINE_MAX];
+
+ if (verbose) {
+ fprintf(stderr, "max25-terminal: >> %s\n", cmd);
+ }
+ if (max25_client_write(client, cmd) != 0) {
+ return -1;
+ }
+ for (;;) {
+ if (max25_client_read_line(client, line, sizeof(line)) != 0) {
+ return -1;
+ }
+ print_rx(line, verbose);
+ if (strncmp(line, "STATUS ", 7) == 0) {
+ continue;
+ }
+ if (strncmp(line, "RX ", 3) == 0) {
+ continue;
+ }
+ if (strncmp(line, "EVENT ", 6) == 0) {
+ continue;
+ }
+ return strcmp(line, "OK") == 0 ? 0 : -1;
+ }
+}
+
+static int run_session(max25_client_t *client, amiga_config_t *cfg,
+ max25_status_t *status)
+{
+ char line[MAX25_LINE_MAX];
+
+ printf("MAX25 Terminal (Amiga) — connected to %s:%u\n", cfg->host, cfg->port);
+ printf("Type text to SEND. Commands: /status /callerid X /callid X /connect /quit\n");
+
+ if (!status->connected) {
+ send_cmd_wait(client, "CONNECT", cfg->verbose);
+ status->connected = 1;
+ }
+
+ while (fgets(line, sizeof(line), stdin) != NULL) {
+ line[strcspn(line, "\r\n")] = '\0';
+ if (line[0] == '\0') {
+ continue;
+ }
+ if (strcmp(line, "/quit") == 0 || strcmp(line, "/exit") == 0) {
+ break;
+ }
+ if (strcmp(line, "/status") == 0) {
+ send_cmd_wait(client, "GET STATUS", cfg->verbose);
+ continue;
+ }
+ if (strcmp(line, "/connect") == 0) {
+ send_cmd_wait(client, "CONNECT", cfg->verbose);
+ status->connected = 1;
+ continue;
+ }
+ if (strcmp(line, "/disconnect") == 0) {
+ send_cmd_wait(client, "DISCONNECT", cfg->verbose);
+ status->connected = 0;
+ continue;
+ }
+ if (strncmp(line, "/callerid ", 10) == 0) {
+ char cmd[MAX25_LINE_MAX];
+ if (!max25_valid_callsign(line + 10)) {
+ fprintf(stderr, "invalid CALLERID\n");
+ continue;
+ }
+ snprintf(cmd, sizeof(cmd), "SET CALLERID %s", line + 10);
+ send_cmd_wait(client, cmd, cfg->verbose);
+ continue;
+ }
+ if (strncmp(line, "/callid ", 8) == 0) {
+ char cmd[MAX25_LINE_MAX];
+ if (!max25_valid_callsign(line + 8)) {
+ fprintf(stderr, "invalid CALLID\n");
+ continue;
+ }
+ snprintf(cmd, sizeof(cmd), "SET CALLID %s", line + 8);
+ send_cmd_wait(client, cmd, cfg->verbose);
+ continue;
+ }
+ {
+ char cmd[MAX25_LINE_MAX + 8];
+ snprintf(cmd, sizeof(cmd), "SEND %s", line);
+ send_cmd_wait(client, cmd, cfg->verbose);
+ }
+ (void)drain_socket(client, cfg->verbose);
+ }
+ return 0;
+}
+
+int main(int argc, char **argv)
+{
+ amiga_config_t cfg;
+ max25_client_t *client = NULL;
+ max25_status_t status;
+ char reply[MAX25_LINE_MAX];
+ int rc;
+
+ config_defaults(&cfg);
+ rc = parse_args(argc, argv, &cfg);
+ if (rc != 0) {
+ return rc < 0 ? EXIT_FAILURE : EXIT_SUCCESS;
+ }
+
+ if (max25_client_connect(cfg.host, cfg.port, "", 1, cfg.tcp_password,
+ &client, &status) != 0) {
+ fprintf(stderr, "max25-terminal: connect failed (tcp %s:%u)\n",
+ cfg.host, cfg.port);
+ return EXIT_FAILURE;
+ }
+
+ if (cfg.probe) {
+ printf("STATUS hardware=%s device=%s mode=%s callerid=%s callid=%s "
+ "ax25_ui=%s connected=%s stack=%s\n",
+ status.hardware, status.device, status.mode,
+ status.callerid, status.callid,
+ status.ax25_ui ? "on" : "off",
+ status.connected ? "yes" : "no",
+ status.stack);
+ max25_client_write(client, "DISCONNECT");
+ max25_client_free(client);
+ return EXIT_SUCCESS;
+ }
+
+ if (cfg.ax25_ui) {
+ max25_client_command(client, "SET AX25_UI on", reply, sizeof(reply));
+ status.ax25_ui = 1;
+ } else {
+ max25_client_command(client, "SET AX25_UI off", reply, sizeof(reply));
+ status.ax25_ui = 0;
+ }
+
+ run_session(client, &cfg, &status);
+ max25_client_write(client, "DISCONNECT");
+ max25_client_free(client);
+ return EXIT_SUCCESS;
+}
diff --git a/stacks/terminal/max25_proto.c b/stacks/terminal/max25_proto.c
new file mode 100644
index 0000000..b4a9725
--- /dev/null
+++ b/stacks/terminal/max25_proto.c
@@ -0,0 +1,472 @@
+#define _POSIX_C_SOURCE 200809L
+
+#include "max25_proto.h"
+
+#if defined(__AMIGA__) || defined(__amigaos__)
+#define MAX25_AMIGA 1
+#endif
+
+#include <ctype.h>
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <stdint.h>
+#include <unistd.h>
+
+#ifndef MAX25_AMIGA
+#include <netdb.h>
+#include <sys/socket.h>
+#include <sys/un.h>
+#else
+#include <sys/socket.h>
+#include <netinet/in.h>
+#include <arpa/inet.h>
+#include <netdb.h>
+#endif
+
+struct max25_client {
+ int fd;
+};
+
+static int read_line_fd(int fd, char *line, size_t line_sz)
+{
+ size_t pos = 0;
+ char ch;
+
+ while (pos + 1 < line_sz) {
+ ssize_t n = read(fd, &ch, 1);
+ if (n < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ return -1;
+ }
+ if (n == 0) {
+ return -1;
+ }
+ if (ch == '\n') {
+ line[pos] = '\0';
+ return 0;
+ }
+ if (ch != '\r') {
+ line[pos++] = ch;
+ }
+ }
+ return -1;
+}
+
+static int write_cmd(int fd, const char *cmd)
+{
+ char buf[MAX25_LINE_MAX + 2];
+ int len = snprintf(buf, sizeof(buf), "%s\n", cmd);
+ ssize_t off = 0;
+
+ if (len < 0 || (size_t)len >= sizeof(buf)) {
+ return -1;
+ }
+ while (off < len) {
+ ssize_t n = write(fd, buf + off, (size_t)len - (size_t)off);
+ if (n < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ return -1;
+ }
+ off += n;
+ }
+ return 0;
+}
+
+static int connect_tcp(const char *host, unsigned port)
+{
+#ifdef MAX25_AMIGA
+ struct hostent *he;
+ struct sockaddr_in addr;
+ int fd;
+
+ he = gethostbyname(host);
+ if (he == NULL || he->h_addr_list[0] == NULL) {
+ return -1;
+ }
+
+ fd = socket(AF_INET, SOCK_STREAM, 0);
+ if (fd < 0) {
+ return -1;
+ }
+
+ memset(&addr, 0, sizeof(addr));
+ addr.sin_family = AF_INET;
+ addr.sin_port = htons((uint16_t)port);
+ memcpy(&addr.sin_addr, he->h_addr_list[0], (size_t)he->h_length);
+
+ if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) {
+ close(fd);
+ return -1;
+ }
+ return fd;
+#else
+ char portbuf[16];
+ struct addrinfo hints;
+ struct addrinfo *res = NULL;
+ struct addrinfo *ai;
+ int fd = -1;
+
+ snprintf(portbuf, sizeof(portbuf), "%u", port);
+ memset(&hints, 0, sizeof(hints));
+ hints.ai_family = AF_UNSPEC;
+ hints.ai_socktype = SOCK_STREAM;
+
+ if (getaddrinfo(host, portbuf, &hints, &res) != 0) {
+ return -1;
+ }
+
+ for (ai = res; ai != NULL; ai = ai->ai_next) {
+ fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
+ if (fd < 0) {
+ continue;
+ }
+ if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) {
+ break;
+ }
+ close(fd);
+ fd = -1;
+ }
+ freeaddrinfo(res);
+ return fd;
+#endif
+}
+
+static int connect_unix(const char *path)
+{
+#ifndef MAX25_AMIGA
+ struct sockaddr_un addr;
+ int fd;
+
+ if (path == NULL || path[0] == '\0') {
+ return -1;
+ }
+
+ fd = socket(AF_UNIX, SOCK_STREAM, 0);
+ if (fd < 0) {
+ return -1;
+ }
+
+ memset(&addr, 0, sizeof(addr));
+ addr.sun_family = AF_UNIX;
+ strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1);
+
+ if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) {
+ close(fd);
+ return -1;
+ }
+ return fd;
+#else
+ (void)path;
+ return -1;
+#endif
+}
+
+static int tcp_authenticate(int fd, const char *password)
+{
+ char line[MAX25_LINE_MAX];
+ char cmd[MAX25_LINE_MAX + 8];
+
+ if (password == NULL || password[0] == '\0') {
+ return -1;
+ }
+ snprintf(cmd, sizeof(cmd), "AUTH %s", password);
+ if (write_cmd(fd, cmd) != 0) {
+ return -1;
+ }
+ if (read_line_fd(fd, line, sizeof(line)) != 0) {
+ return -1;
+ }
+ return strcmp(line, "OK") == 0 ? 0 : -1;
+}
+
+static int handshake(int fd, const char *tcp_password, max25_status_t *initial_status)
+{
+ char line[MAX25_LINE_MAX];
+
+ if (read_line_fd(fd, line, sizeof(line)) != 0) {
+ return -1;
+ }
+ if (strcmp(line, "AUTH required") == 0) {
+ if (tcp_authenticate(fd, tcp_password) != 0) {
+ return -1;
+ }
+ if (read_line_fd(fd, line, sizeof(line)) != 0 || strcmp(line, "OK") != 0) {
+ return -1;
+ }
+ } else if (strcmp(line, "OK") != 0) {
+ return -1;
+ }
+
+ if (read_line_fd(fd, line, sizeof(line)) != 0 ||
+ strncmp(line, "STATUS ", 7) != 0) {
+ return -1;
+ }
+
+ if (initial_status != NULL) {
+ max25_client_parse_status(line, initial_status);
+ }
+ return 0;
+}
+
+int max25_client_connect(const char *host, unsigned port, const char *unix_path,
+ int tcp_only, const char *tcp_password,
+ max25_client_t **out, max25_status_t *initial_status)
+{
+ max25_client_t *client;
+ int fd = -1;
+
+ if (out == NULL) {
+ return -1;
+ }
+
+ if (!tcp_only && unix_path != NULL && unix_path[0] != '\0') {
+#ifndef MAX25_AMIGA
+ fd = connect_unix(unix_path);
+#endif
+ }
+ if (fd < 0 && host != NULL && host[0] != '\0') {
+ fd = connect_tcp(host, port);
+ }
+ if (fd < 0) {
+ return -1;
+ }
+
+ client = calloc(1, sizeof(*client));
+ if (client == NULL) {
+ close(fd);
+ return -1;
+ }
+ client->fd = fd;
+
+ if (handshake(fd, tcp_password, initial_status) != 0) {
+ max25_client_free(client);
+ return -1;
+ }
+
+ *out = client;
+ return 0;
+}
+
+void max25_client_free(max25_client_t *client)
+{
+ if (client == NULL) {
+ return;
+ }
+ if (client->fd >= 0) {
+ close(client->fd);
+ }
+ free(client);
+}
+
+int max25_client_fd(const max25_client_t *client)
+{
+ return client != NULL ? client->fd : -1;
+}
+
+int max25_client_write(max25_client_t *client, const char *cmd)
+{
+ if (client == NULL || cmd == NULL) {
+ return -1;
+ }
+ return write_cmd(client->fd, cmd);
+}
+
+max25_line_kind_t max25_client_classify_line(const char *line)
+{
+ if (line == NULL || line[0] == '\0') {
+ return MAX25_LINE_IGNORE;
+ }
+ if (strcmp(line, "OK") == 0) {
+ return MAX25_LINE_OK;
+ }
+ if (strncmp(line, "ERR ", 4) == 0) {
+ return MAX25_LINE_ERR;
+ }
+ if (strncmp(line, "STATUS ", 7) == 0) {
+ return MAX25_LINE_STATUS;
+ }
+ if (strncmp(line, "RX ", 3) == 0) {
+ return MAX25_LINE_RX;
+ }
+ if (strncmp(line, "EVENT ", 6) == 0) {
+ return MAX25_LINE_EVENT;
+ }
+ if (strncmp(line, "DEVICE ", 7) == 0) {
+ return MAX25_LINE_DEVICE;
+ }
+ return MAX25_LINE_OTHER;
+}
+
+int max25_client_command(max25_client_t *client, const char *cmd,
+ char *reply, size_t reply_sz)
+{
+ char line[MAX25_LINE_MAX];
+
+ if (client == NULL || cmd == NULL) {
+ return -1;
+ }
+ if (write_cmd(client->fd, cmd) != 0) {
+ return -1;
+ }
+ for (;;) {
+ if (read_line_fd(client->fd, line, sizeof(line)) != 0) {
+ return -1;
+ }
+ if (strncmp(line, "STATUS ", 7) == 0) {
+ if (reply != NULL && reply_sz > 0) {
+ strncpy(reply, line, reply_sz - 1);
+ reply[reply_sz - 1] = '\0';
+ }
+ continue;
+ }
+ if (strncmp(line, "RX ", 3) == 0) {
+ continue;
+ }
+ if (strncmp(line, "EVENT ", 6) == 0) {
+ continue;
+ }
+ if (strncmp(line, "DEVICE ", 7) == 0) {
+ continue;
+ }
+ /* GET STATUS replies: STATUS … then OK — keep STATUS in reply. */
+ if (strcmp(line, "OK") == 0) {
+ if (reply != NULL && reply_sz > 0 &&
+ strncmp(reply, "STATUS ", 7) != 0) {
+ strncpy(reply, line, reply_sz - 1);
+ reply[reply_sz - 1] = '\0';
+ }
+ return 0;
+ }
+ if (reply != NULL && reply_sz > 0) {
+ strncpy(reply, line, reply_sz - 1);
+ reply[reply_sz - 1] = '\0';
+ }
+ return -1;
+ }
+}
+
+int max25_client_read_line(max25_client_t *client, char *line, size_t line_sz)
+{
+ if (client == NULL) {
+ return -1;
+ }
+ return read_line_fd(client->fd, line, line_sz);
+}
+
+static void copy_field(const char *src, char *dst, size_t dst_sz)
+{
+ if (dst == NULL || dst_sz == 0) {
+ return;
+ }
+ if (src == NULL) {
+ dst[0] = '\0';
+ return;
+ }
+ strncpy(dst, src, dst_sz - 1);
+ dst[dst_sz - 1] = '\0';
+}
+
+int max25_client_apply_event(const char *line, max25_status_t *status)
+{
+ if (line == NULL || status == NULL || strncmp(line, "EVENT ", 6) != 0) {
+ return 0;
+ }
+ if (strcmp(line, "EVENT connected") == 0) {
+ status->connected = 1;
+ return 1;
+ }
+ if (strcmp(line, "EVENT disconnected") == 0) {
+ status->connected = 0;
+ return 1;
+ }
+ return 0;
+}
+
+int max25_client_parse_status(const char *line, max25_status_t *status)
+{
+ char buf[MAX25_LINE_MAX];
+ char *save = NULL;
+ char *tok;
+
+ if (line == NULL || status == NULL || strncmp(line, "STATUS ", 7) != 0) {
+ return -1;
+ }
+
+ strncpy(buf, line + 7, sizeof(buf) - 1);
+ buf[sizeof(buf) - 1] = '\0';
+
+ memset(status, 0, sizeof(*status));
+ for (tok = strtok_r(buf, " ", &save); tok != NULL;
+ tok = strtok_r(NULL, " ", &save)) {
+ char *eq = strchr(tok, '=');
+ if (eq == NULL) {
+ continue;
+ }
+ *eq = '\0';
+ if (strcmp(tok, "hardware") == 0) {
+ copy_field(eq + 1, status->hardware, sizeof(status->hardware));
+ } else if (strcmp(tok, "device") == 0) {
+ copy_field(eq + 1, status->device, sizeof(status->device));
+ } else if (strcmp(tok, "mode") == 0) {
+ copy_field(eq + 1, status->mode, sizeof(status->mode));
+ } else if (strcmp(tok, "callerid") == 0) {
+ copy_field(eq + 1, status->callerid, sizeof(status->callerid));
+ } else if (strcmp(tok, "callid") == 0) {
+ copy_field(eq + 1, status->callid, sizeof(status->callid));
+ } else if (strcmp(tok, "ax25_ui") == 0) {
+ status->ax25_ui = (strcmp(eq + 1, "on") == 0);
+ } else if (strcmp(tok, "connected") == 0) {
+ status->connected = (strcmp(eq + 1, "yes") == 0);
+ } else if (strcmp(tok, "stack") == 0) {
+ copy_field(eq + 1, status->stack, sizeof(status->stack));
+ } else if (strcmp(tok, "error") == 0) {
+ copy_field(eq + 1, status->error, sizeof(status->error));
+ } else if (strcmp(tok, "voice") == 0) {
+ copy_field(eq + 1, status->voice, sizeof(status->voice));
+ }
+ }
+ return 0;
+}
+
+int max25_valid_callsign(const char *value)
+{
+ const char *dash;
+ size_t call_len;
+ unsigned long ssid;
+ char *end = NULL;
+ size_t i;
+
+ if (value == NULL) {
+ return 0;
+ }
+
+ dash = strchr(value, '-');
+ if (dash != NULL) {
+ call_len = (size_t)(dash - value);
+ ssid = strtoul(dash + 1, &end, 10);
+ if (end == dash + 1 || ssid > 15) {
+ return 0;
+ }
+ } else {
+ call_len = strlen(value);
+ }
+
+ if (call_len == 0 || call_len > 6) {
+ return 0;
+ }
+
+ for (i = 0; i < call_len; i++) {
+ char c = (char)toupper((unsigned char)value[i]);
+ if (!((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'))) {
+ return 0;
+ }
+ }
+
+ return 1;
+}
diff --git a/stacks/terminal/max25_proto.h b/stacks/terminal/max25_proto.h
new file mode 100644
index 0000000..25ae5d5
--- /dev/null
+++ b/stacks/terminal/max25_proto.h
@@ -0,0 +1,55 @@
+#ifndef MAX25_PROTO_H
+#define MAX25_PROTO_H
+
+#include <stddef.h>
+
+#define MAX25_DEFAULT_PORT 7325
+#define MAX25_DEFAULT_UNIX "/run/max25/modem.sock"
+#define MAX25_LINE_MAX 512
+#define MAX25_CALLSIGN_MAX 16
+
+typedef struct max25_client max25_client_t;
+
+typedef struct max25_status {
+ char hardware[32];
+ char device[32];
+ char mode[32];
+ char callerid[MAX25_CALLSIGN_MAX];
+ char callid[MAX25_CALLSIGN_MAX];
+ int ax25_ui;
+ int connected;
+ int monitor;
+ char stack[32];
+ char error[16];
+ char voice[16];
+} max25_status_t;
+
+typedef enum {
+ MAX25_LINE_IGNORE = 0,
+ MAX25_LINE_OK,
+ MAX25_LINE_ERR,
+ MAX25_LINE_STATUS,
+ MAX25_LINE_RX,
+ MAX25_LINE_EVENT,
+ MAX25_LINE_DEVICE,
+ MAX25_LINE_OTHER
+} max25_line_kind_t;
+
+int max25_client_connect(const char *host, unsigned port, const char *unix_path,
+ int tcp_only, const char *tcp_password,
+ max25_client_t **out, max25_status_t *initial_status);
+void max25_client_free(max25_client_t *client);
+int max25_client_fd(const max25_client_t *client);
+
+int max25_client_write(max25_client_t *client, const char *cmd);
+int max25_client_command(max25_client_t *client, const char *cmd,
+ char *reply, size_t reply_sz);
+int max25_client_read_line(max25_client_t *client, char *line, size_t line_sz);
+max25_line_kind_t max25_client_classify_line(const char *line);
+int max25_client_parse_status(const char *line, max25_status_t *status);
+/** Apply EVENT connected / EVENT disconnected to status. Returns 1 if handled. */
+int max25_client_apply_event(const char *line, max25_status_t *status);
+
+int max25_valid_callsign(const char *value);
+
+#endif /* MAX25_PROTO_H */
diff --git a/stacks/terminal/max25_terminal.c b/stacks/terminal/max25_terminal.c
new file mode 100644
index 0000000..8b4eb52
--- /dev/null
+++ b/stacks/terminal/max25_terminal.c
@@ -0,0 +1,767 @@
+#define _POSIX_C_SOURCE 200809L
+
+#include "max25_proto.h"
+#include "max25_ui.h"
+
+#include <errno.h>
+#include <getopt.h>
+#include <poll.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <termios.h>
+#include <unistd.h>
+
+#ifdef MAX25_HAVE_NCURSES
+#include <ncurses.h>
+#endif
+
+typedef struct {
+ char host[256];
+ unsigned port;
+ char unix_path[256];
+ char tcp_password[128];
+ char default_device[32];
+ int tcp_only;
+ int ax25_ui;
+ int verbose;
+ int probe;
+} terminal_config_t;
+
+typedef struct {
+ int waiting_reply;
+ char last_err[MAX25_LINE_MAX];
+} session_io_t;
+
+static struct termios g_saved_tty;
+static int g_tty_raw;
+
+static void config_defaults(terminal_config_t *cfg)
+{
+ const char *env;
+
+ memset(cfg, 0, sizeof(*cfg));
+ strncpy(cfg->host, "127.0.0.1", sizeof(cfg->host) - 1);
+ cfg->port = MAX25_DEFAULT_PORT;
+ strncpy(cfg->unix_path, MAX25_DEFAULT_UNIX, sizeof(cfg->unix_path) - 1);
+ cfg->ax25_ui = 1;
+
+ env = getenv("MAX25_HOST");
+ if (env != NULL && env[0] != '\0') {
+ strncpy(cfg->host, env, sizeof(cfg->host) - 1);
+ }
+ env = getenv("MAX25_PORT");
+ if (env != NULL && env[0] != '\0') {
+ cfg->port = (unsigned)strtoul(env, NULL, 10);
+ }
+ env = getenv("MAX25_UNIX");
+ if (env != NULL) {
+ strncpy(cfg->unix_path, env, sizeof(cfg->unix_path) - 1);
+ }
+ env = getenv("MAX25_TCP_PASSWORD");
+ if (env != NULL) {
+ strncpy(cfg->tcp_password, env, sizeof(cfg->tcp_password) - 1);
+ }
+}
+
+static void usage(const char *prog)
+{
+ fprintf(stderr,
+ "MAX25 Terminal — CLI operator client (max25d via TCP/M25/1)\n\n"
+ "Usage: %s [options] [host] [port]\n\n"
+ "Options:\n"
+ " -H, --host HOST max25d TCP host (default 127.0.0.1)\n"
+ " -p, --port PORT max25d TCP port (default %u)\n"
+ " -T, --tcp-only TCP only — do not try Unix socket\n"
+ " -U, --unix PATH Unix socket (default %s, before TCP)\n"
+ " --ax25-ui AX.25 UI mode (default on)\n"
+ " --no-ax25-ui Plain SEND lines\n"
+ " -P, --password PASS TCP auth password (plain-text M25/1 AUTH)\n"
+ " -d, --device ID SET DEVICE after connect (configured device id)\n"
+ " -v, --verbose Protocol log on stderr\n"
+ " --probe Connect, print STATUS, exit (CI)\n"
+ " -h, --help Show help\n\n"
+ "Remote example:\n"
+ " %s -T -H linux-host.local -p 7325\n\n"
+ "Environment: MAX25_HOST, MAX25_PORT, MAX25_UNIX, MAX25_TCP_PASSWORD\n"
+ "Symlink: max25-client -> max25-terminal\n\n",
+ prog, MAX25_DEFAULT_PORT, MAX25_DEFAULT_UNIX, prog);
+}
+
+static int tty_enable_raw(void)
+{
+ struct termios tty;
+
+ if (!isatty(STDIN_FILENO)) {
+ return 0;
+ }
+ if (tcgetattr(STDIN_FILENO, &g_saved_tty) != 0) {
+ return -1;
+ }
+ tty = g_saved_tty;
+ tty.c_lflag &= (tcflag_t)~(ICANON | ECHO);
+ tty.c_cc[VMIN] = 0;
+ tty.c_cc[VTIME] = 1;
+ if (tcsetattr(STDIN_FILENO, TCSANOW, &tty) != 0) {
+ return -1;
+ }
+ g_tty_raw = 1;
+ return 0;
+}
+
+static void tty_restore(void)
+{
+ if (g_tty_raw) {
+ tcsetattr(STDIN_FILENO, TCSANOW, &g_saved_tty);
+ g_tty_raw = 0;
+ }
+}
+
+static int begin_cmd(session_io_t *io)
+{
+ if (io == NULL) {
+ return -1;
+ }
+ io->waiting_reply = 1;
+ io->last_err[0] = '\0';
+ return 0;
+}
+
+static int send_cmd_async(max25_client_t *client, session_io_t *io,
+ const char *cmd)
+{
+ if (max25_client_write(client, cmd) != 0) {
+ return -1;
+ }
+ return begin_cmd(io);
+}
+
+static int dispatch_daemon_line(const char *line, session_io_t *io,
+ max25_ui_t *ui, max25_status_t *status,
+ terminal_config_t *cfg, int *need_redraw)
+{
+ max25_line_kind_t kind = max25_client_classify_line(line);
+
+ if (need_redraw != NULL) {
+ *need_redraw = 0;
+ }
+
+ if (cfg->verbose) {
+ fprintf(stderr, "max25-terminal: << %s\n", line);
+ }
+
+ if (kind == MAX25_LINE_RX) {
+ max25_ui_append_rx(ui, line + 3);
+ if (need_redraw != NULL) {
+ *need_redraw = 1;
+ }
+ return 0;
+ }
+
+ /* CONNECT/DISCONNECT emit EVENT before OK — update header even while waiting. */
+ if (kind == MAX25_LINE_EVENT) {
+ if (max25_client_apply_event(line, status)) {
+ max25_ui_append_rx(ui, line);
+ if (need_redraw != NULL) {
+ *need_redraw = 1;
+ }
+ } else if (cfg->verbose) {
+ max25_ui_append_rx(ui, line);
+ if (need_redraw != NULL) {
+ *need_redraw = 1;
+ }
+ }
+ if (io->waiting_reply) {
+ return 0;
+ }
+ return 0;
+ }
+
+ if (io->waiting_reply) {
+ if (kind == MAX25_LINE_STATUS && status != NULL) {
+ char summary[MAX25_LINE_MAX];
+
+ max25_client_parse_status(line, status);
+ /* Show STATUS in RX pane (F10→3 and other GET STATUS callers). */
+ snprintf(summary, sizeof(summary),
+ "STATUS device=%s stack=%s connected=%s callerid=%s callid=%s",
+ status->device[0] != '\0' ? status->device : "-",
+ status->stack[0] != '\0' ? status->stack : "-",
+ status->connected ? "yes" : "no",
+ status->callerid[0] != '\0' ? status->callerid : "-",
+ status->callid[0] != '\0' ? status->callid : "-");
+ max25_ui_append_rx(ui, summary);
+ if (need_redraw != NULL) {
+ *need_redraw = 1;
+ }
+ return 0;
+ }
+ if (kind == MAX25_LINE_DEVICE) {
+ max25_ui_append_rx(ui, line);
+ if (need_redraw != NULL) {
+ *need_redraw = 1;
+ }
+ return 0;
+ }
+ if (kind == MAX25_LINE_OK) {
+ io->waiting_reply = 0;
+ if (need_redraw != NULL) {
+ *need_redraw = 1;
+ }
+ return 0;
+ }
+ if (kind == MAX25_LINE_ERR) {
+ strncpy(io->last_err, line, sizeof(io->last_err) - 1);
+ io->last_err[sizeof(io->last_err) - 1] = '\0';
+ io->waiting_reply = 0;
+ max25_ui_append_rx(ui, line);
+ if (need_redraw != NULL) {
+ *need_redraw = 1;
+ }
+ return -1;
+ }
+ return 0;
+ }
+
+ if (kind == MAX25_LINE_ERR) {
+ max25_ui_append_rx(ui, line);
+ if (need_redraw != NULL) {
+ *need_redraw = 1;
+ }
+ }
+ return 0;
+}
+
+
+static int send_callsign(max25_client_t *client, session_io_t *io,
+ const char *field, const char *value)
+{
+ char cmd[MAX25_LINE_MAX];
+
+ if (!max25_valid_callsign(value)) {
+ fprintf(stderr, "max25-terminal: invalid %s\n", field);
+ return -1;
+ }
+ snprintf(cmd, sizeof(cmd), "SET %s %s", field, value);
+ return send_cmd_async(client, io, cmd);
+}
+
+static int send_line_async(max25_client_t *client, session_io_t *io,
+ const char *line)
+{
+ char cmd[MAX25_LINE_MAX + 8];
+ size_t max_payload = sizeof(cmd) - 6;
+
+ if (strlen(line) > max_payload) {
+ return -1;
+ }
+ snprintf(cmd, sizeof(cmd), "SEND %s", line);
+ return send_cmd_async(client, io, cmd);
+}
+
+static int refresh_status(max25_client_t *client, max25_status_t *status)
+{
+ char reply[MAX25_LINE_MAX];
+
+ if (max25_client_command(client, "GET STATUS", reply, sizeof(reply)) != 0) {
+ return -1;
+ }
+ return max25_client_parse_status(reply, status);
+}
+
+static int set_device_sync(max25_client_t *client, max25_status_t *status,
+ const char *device_id)
+{
+ char cmd[MAX25_LINE_MAX + 16];
+ char reply[MAX25_LINE_MAX];
+
+ if (device_id == NULL || device_id[0] == '\0') {
+ return -1;
+ }
+ snprintf(cmd, sizeof(cmd), "SET DEVICE %s", device_id);
+ if (max25_client_command(client, cmd, reply, sizeof(reply)) != 0) {
+ return -1;
+ }
+ return refresh_status(client, status);
+}
+
+static int fetch_devices(max25_client_t *client, max25_ui_t *ui)
+{
+ char line[MAX25_LINE_MAX];
+
+ if (max25_client_write(client, "GET DEVICES") != 0) {
+ return -1;
+ }
+ max25_ui_append_rx(ui, "-- max25d devices --");
+ for (;;) {
+ if (max25_client_read_line(client, line, sizeof(line)) != 0) {
+ return -1;
+ }
+ if (strcmp(line, "OK") == 0) {
+ break;
+ }
+ if (strncmp(line, "DEVICE ", 7) == 0) {
+ max25_ui_append_rx(ui, line);
+ continue;
+ }
+ if (strncmp(line, "ERR ", 4) == 0) {
+ max25_ui_append_rx(ui, line);
+ return -1;
+ }
+ }
+ return 0;
+}
+
+static int menu_action_needs_prompt(max25_menu_action_t action)
+{
+ return action == MAX25_MENU_CALLERID || action == MAX25_MENU_CALLID ||
+ action == MAX25_MENU_SEND || action == MAX25_MENU_DEVICE;
+}
+
+/* Returns 1 to quit session, 0 otherwise. Hides menu before action. */
+static int apply_menu_pick(max25_menu_action_t action, max25_client_t *client,
+ session_io_t *io, max25_ui_t *ui,
+ max25_status_t *status, const char *input_line)
+{
+ char buf[MAX25_LINE_MAX];
+ char cmd[64];
+
+ if (action == MAX25_MENU_NONE) {
+ return 0;
+ }
+ if (action == MAX25_MENU_QUIT) {
+ return 1;
+ }
+
+ /* Leave overlay so prompts / STATUS RX / header redraw are visible. */
+ max25_ui_hide_menu(ui);
+ max25_ui_draw_screen(ui, status, input_line != NULL ? input_line : "");
+
+ switch (action) {
+ case MAX25_MENU_CALLERID:
+ if (max25_ui_prompt(ui, "CALLERID", buf, sizeof(buf)) > 0) {
+ send_callsign(client, io, "CALLERID", buf);
+ }
+ break;
+ case MAX25_MENU_CALLID:
+ if (max25_ui_prompt(ui, "CALLID", buf, sizeof(buf)) > 0) {
+ send_callsign(client, io, "CALLID", buf);
+ }
+ break;
+ case MAX25_MENU_STATUS:
+ send_cmd_async(client, io, "GET STATUS");
+ break;
+ case MAX25_MENU_SEND:
+ if (max25_ui_prompt(ui, "Send", buf, sizeof(buf)) > 0) {
+ send_line_async(client, io, buf);
+ }
+ break;
+ case MAX25_MENU_MONITOR:
+ snprintf(cmd, sizeof(cmd), "MONITOR %s",
+ status->monitor ? "off" : "on");
+ status->monitor = !status->monitor;
+ send_cmd_async(client, io, cmd);
+ max25_ui_append_rx(ui, status->monitor ? "MONITOR on" : "MONITOR off");
+ max25_ui_draw_screen(ui, status, input_line != NULL ? input_line : "");
+ break;
+ case MAX25_MENU_CONNECT:
+ send_cmd_async(client, io,
+ status->connected ? "DISCONNECT" : "CONNECT");
+ break;
+ case MAX25_MENU_DEVICE:
+ (void)fetch_devices(client, ui);
+ if (max25_ui_prompt(ui, "DEVICE id", buf, sizeof(buf)) > 0) {
+ if (set_device_sync(client, status, buf) != 0) {
+ max25_ui_append_rx(ui, "ERR SET DEVICE failed");
+ }
+ max25_ui_draw_screen(ui, status,
+ input_line != NULL ? input_line : "");
+ }
+ break;
+ case MAX25_MENU_DEVICES:
+ (void)fetch_devices(client, ui);
+ max25_ui_draw_screen(ui, status, input_line != NULL ? input_line : "");
+ break;
+ default:
+ break;
+ }
+
+ if (!menu_action_needs_prompt(action) && action != MAX25_MENU_MONITOR) {
+ max25_ui_draw_screen(ui, status, input_line != NULL ? input_line : "");
+ }
+ return 0;
+}
+
+#ifdef MAX25_HAVE_NCURSES
+static int read_key_ncurses(void)
+{
+ int ch = getch();
+
+ if (ch == KEY_F(10)) {
+ return 999;
+ }
+ return ch;
+}
+#endif
+
+static int read_plain_key(void)
+{
+ unsigned char buf[8];
+ ssize_t n;
+
+ n = read(STDIN_FILENO, buf, sizeof(buf));
+ if (n <= 0) {
+ if (n < 0 && errno == EAGAIN) {
+ return -1;
+ }
+ return 0;
+ }
+
+ if (buf[0] == 0x1b && n >= 3 && buf[1] == '[') {
+ if (n >= 4 && buf[2] == '2' && buf[3] == '1' && (n < 5 || buf[4] == '~')) {
+ return 999;
+ }
+ }
+
+ return (int)buf[0];
+}
+
+static int run_session(max25_client_t *client, max25_ui_t *ui,
+ terminal_config_t *cfg, max25_status_t *status)
+{
+ struct pollfd pfds[2];
+ session_io_t io;
+ char line[MAX25_LINE_MAX];
+ char input[MAX25_UI_INPUT_MAX];
+ size_t input_len = 0;
+ int running = 1;
+ int use_ncurses = max25_ui_has_ncurses(ui);
+ int use_plain_raw = 0;
+
+ memset(&io, 0, sizeof(io));
+ input[0] = '\0';
+
+ if (!use_ncurses) {
+ max25_ui_apply_palette();
+ }
+ max25_ui_reset_rx(ui);
+ max25_ui_draw_screen(ui, status, input);
+
+ /* Arm TX session before accepting Enter/F10 SEND (daemon also auto-arms
+ * on SEND; sync CONNECT here so header connected=yes before first key). */
+ if (!status->connected) {
+ char creply[MAX25_LINE_MAX];
+
+ if (max25_client_command(client, "CONNECT", creply, sizeof(creply)) == 0) {
+ status->connected = 1;
+ max25_ui_append_rx(ui, "EVENT connected");
+ } else {
+ max25_ui_append_rx(ui, creply[0] ? creply : "ERR CONNECT failed");
+ }
+ max25_ui_draw_screen(ui, status, input);
+ }
+
+ (void)fetch_devices(client, ui);
+ max25_ui_draw_screen(ui, status, input);
+
+ if (!use_ncurses && isatty(STDIN_FILENO)) {
+ use_plain_raw = (tty_enable_raw() == 0);
+ }
+
+ while (running) {
+ if (!io.waiting_reply) {
+ max25_ui_draw_screen(ui, status, input);
+ }
+
+ pfds[0].fd = STDIN_FILENO;
+ pfds[0].events = POLLIN;
+ pfds[0].revents = 0;
+ pfds[1].fd = max25_client_fd(client);
+ pfds[1].events = POLLIN;
+ pfds[1].revents = 0;
+
+ if (poll(pfds, 2, 200) < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ break;
+ }
+
+ if ((pfds[1].revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) {
+ max25_ui_append_rx(ui, "max25d connection lost");
+ break;
+ }
+
+ if ((pfds[1].revents & POLLIN) != 0) {
+ int need_redraw = 0;
+
+ if (max25_client_read_line(client, line, sizeof(line)) != 0) {
+ max25_ui_append_rx(ui, "max25d connection closed");
+ break;
+ }
+ dispatch_daemon_line(line, &io, ui, status, cfg, &need_redraw);
+ if (io.last_err[0] != '\0' && cfg->verbose) {
+ fprintf(stderr, "max25-terminal: %s\n", io.last_err);
+ }
+ if (need_redraw && !max25_ui_menu_visible(ui)) {
+ max25_ui_draw_screen(ui, status, input);
+ }
+ continue;
+ }
+
+ if ((pfds[0].revents & POLLIN) == 0) {
+ continue;
+ }
+
+#ifdef MAX25_HAVE_NCURSES
+ if (use_ncurses) {
+ int ch = read_key_ncurses();
+ if (ch == -1) {
+ continue;
+ }
+ if (ch == 999) {
+ if (max25_ui_menu_visible(ui)) {
+ max25_ui_hide_menu(ui);
+ max25_ui_draw_screen(ui, status, input);
+ } else {
+ max25_ui_show_menu(ui, status);
+ }
+ continue;
+ }
+ if (max25_ui_menu_visible(ui)) {
+ if (ch == 27) {
+ max25_ui_hide_menu(ui);
+ max25_ui_draw_screen(ui, status, input);
+ continue;
+ }
+ if (apply_menu_pick(max25_ui_menu_pick(ui, ch), client, &io, ui,
+ status, input) != 0) {
+ running = 0;
+ }
+ continue;
+ }
+ if (ch >= 32 && ch < 127) {
+ if (input_len + 1 < sizeof(input)) {
+ input[input_len++] = (char)ch;
+ input[input_len] = '\0';
+ }
+ } else if (ch == '\n' || ch == KEY_ENTER || ch == '\r') {
+ if (input_len > 0) {
+ send_line_async(client, &io, input);
+ input_len = 0;
+ input[0] = '\0';
+ }
+ } else if (ch == KEY_BACKSPACE || ch == 127 || ch == '\b') {
+ if (input_len > 0) {
+ input[--input_len] = '\0';
+ }
+ }
+ continue;
+ }
+#endif
+
+ if (use_plain_raw) {
+ int ch = read_plain_key();
+ if (ch <= 0) {
+ continue;
+ }
+ if (ch == 999) {
+ if (max25_ui_menu_visible(ui)) {
+ max25_ui_hide_menu(ui);
+ max25_ui_draw_screen(ui, status, input);
+ } else {
+ max25_ui_show_menu(ui, status);
+ }
+ continue;
+ }
+ if (max25_ui_menu_visible(ui)) {
+ if (ch == 27) {
+ max25_ui_hide_menu(ui);
+ max25_ui_draw_screen(ui, status, input);
+ continue;
+ }
+ if (apply_menu_pick(max25_ui_menu_pick(ui, ch), client, &io, ui,
+ status, input) != 0) {
+ running = 0;
+ }
+ continue;
+ }
+ if (ch >= 32 && ch < 127) {
+ if (input_len + 1 < sizeof(input)) {
+ input[input_len++] = (char)ch;
+ input[input_len] = '\0';
+ }
+ } else if (ch == '\n' || ch == '\r') {
+ if (input_len > 0) {
+ send_line_async(client, &io, input);
+ input_len = 0;
+ input[0] = '\0';
+ }
+ } else if (ch == 127 || ch == '\b') {
+ if (input_len > 0) {
+ input[--input_len] = '\0';
+ }
+ }
+ continue;
+ }
+
+ if (fgets(input, sizeof(input), stdin) == NULL) {
+ running = 0;
+ } else {
+ input[strcspn(input, "\r\n")] = '\0';
+ if (max25_ui_menu_visible(ui)) {
+ if (apply_menu_pick(max25_ui_menu_pick(ui, input[0]), client,
+ &io, ui, status, "") != 0) {
+ running = 0;
+ }
+ } else if (input[0] != '\0') {
+ send_line_async(client, &io, input);
+ }
+ input[0] = '\0';
+ input_len = 0;
+ }
+ }
+
+ tty_restore();
+ return 0;
+}
+
+int main(int argc, char *argv[])
+{
+ terminal_config_t cfg;
+ max25_client_t *client = NULL;
+ max25_ui_t *ui = NULL;
+ max25_status_t status;
+ char reply[MAX25_LINE_MAX];
+ int opt;
+ int argi;
+ static const struct option long_opts[] = {
+ {"host", required_argument, NULL, 'H'},
+ {"port", required_argument, NULL, 'p'},
+ {"unix", required_argument, NULL, 'U'},
+ {"tcp-only", no_argument, NULL, 'T'},
+ {"ax25-ui", no_argument, NULL, 1000},
+ {"no-ax25-ui", no_argument, NULL, 1001},
+ {"password", required_argument, NULL, 'P'},
+ {"device", required_argument, NULL, 'd'},
+ {"verbose", no_argument, NULL, 'v'},
+ {"probe", no_argument, NULL, 1002},
+ {"help", no_argument, NULL, 'h'},
+ {NULL, 0, NULL, 0}
+ };
+
+ config_defaults(&cfg);
+
+ while ((opt = getopt_long(argc, argv, "H:p:TU:P:d:v:h", long_opts, NULL)) != -1) {
+ switch (opt) {
+ case 'H':
+ strncpy(cfg.host, optarg, sizeof(cfg.host) - 1);
+ break;
+ case 'p':
+ cfg.port = (unsigned)strtoul(optarg, NULL, 10);
+ break;
+ case 'T':
+ cfg.tcp_only = 1;
+ break;
+ case 'U':
+ strncpy(cfg.unix_path, optarg, sizeof(cfg.unix_path) - 1);
+ break;
+ case 'P':
+ strncpy(cfg.tcp_password, optarg, sizeof(cfg.tcp_password) - 1);
+ break;
+ case 'd':
+ strncpy(cfg.default_device, optarg, sizeof(cfg.default_device) - 1);
+ break;
+ case 'v':
+ cfg.verbose = 1;
+ break;
+ case 1002:
+ cfg.probe = 1;
+ cfg.tcp_only = 1;
+ break;
+ case 'h':
+ usage(argv[0]);
+ return EXIT_SUCCESS;
+ case 1000:
+ cfg.ax25_ui = 1;
+ break;
+ case 1001:
+ cfg.ax25_ui = 0;
+ break;
+ default:
+ usage(argv[0]);
+ return EXIT_FAILURE;
+ }
+ }
+
+ argi = optind;
+ if (argi < argc) {
+ strncpy(cfg.host, argv[argi++], sizeof(cfg.host) - 1);
+ }
+ if (argi < argc) {
+ cfg.port = (unsigned)strtoul(argv[argi++], NULL, 10);
+ }
+
+ if (max25_client_connect(cfg.host, cfg.port,
+ cfg.tcp_only ? "" : cfg.unix_path,
+ cfg.tcp_only, cfg.tcp_password,
+ &client, &status) != 0) {
+ fprintf(stderr, "max25-terminal: connect failed");
+ if (cfg.tcp_only) {
+ fprintf(stderr, " (tcp %s:%u)", cfg.host, cfg.port);
+ } else {
+ fprintf(stderr, " (unix %s / tcp %s:%u)",
+ cfg.unix_path, cfg.host, cfg.port);
+ }
+ fputc('\n', stderr);
+ return EXIT_FAILURE;
+ }
+
+ if (cfg.probe) {
+ printf("STATUS hardware=%s device=%s mode=%s callerid=%s callid=%s "
+ "ax25_ui=%s connected=%s stack=%s\n",
+ status.hardware, status.device, status.mode,
+ status.callerid, status.callid,
+ status.ax25_ui ? "on" : "off",
+ status.connected ? "yes" : "no",
+ status.stack);
+ max25_client_write(client, "DISCONNECT");
+ max25_client_free(client);
+ return EXIT_SUCCESS;
+ }
+
+ if (max25_ui_init(&ui) != 0) {
+ max25_client_free(client);
+ return EXIT_FAILURE;
+ }
+
+ if (cfg.default_device[0] != '\0') {
+ if (set_device_sync(client, &status, cfg.default_device) != 0) {
+ fprintf(stderr, "max25-terminal: SET DEVICE %s failed\n",
+ cfg.default_device);
+ max25_client_free(client);
+ return EXIT_FAILURE;
+ }
+ }
+
+ if (cfg.ax25_ui) {
+ max25_client_command(client, "SET AX25_UI on", reply, sizeof(reply));
+ status.ax25_ui = 1;
+ } else {
+ max25_client_command(client, "SET AX25_UI off", reply, sizeof(reply));
+ status.ax25_ui = 0;
+ }
+
+ if (cfg.verbose) {
+ fprintf(stderr, "max25-terminal: connected %s:%u tcp_only=%s ax25-ui=%s\n",
+ cfg.host, cfg.port, cfg.tcp_only ? "yes" : "no",
+ status.ax25_ui ? "on" : "off");
+ }
+
+ run_session(client, ui, &cfg, &status);
+
+ max25_client_write(client, "DISCONNECT");
+ max25_ui_shutdown(ui);
+ max25_client_free(client);
+ return EXIT_SUCCESS;
+}
diff --git a/stacks/terminal/max25_ui.c b/stacks/terminal/max25_ui.c
new file mode 100644
index 0000000..91cc977
--- /dev/null
+++ b/stacks/terminal/max25_ui.c
@@ -0,0 +1,407 @@
+#include "max25_ui.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#ifdef MAX25_HAVE_NCURSES
+#include <ncurses.h>
+#endif
+
+struct max25_ui {
+ int menu_open;
+ char rx_lines[MAX25_UI_RX_LINES][MAX25_LINE_MAX];
+ int rx_count;
+ int rx_head;
+#ifdef MAX25_HAVE_NCURSES
+ int ncurses_on;
+#endif
+};
+
+#ifdef MAX25_HAVE_NCURSES
+static int term_ok_for_ncurses(void)
+{
+ const char *term = getenv("TERM");
+
+ if (term == NULL || term[0] == '\0') {
+ return 0;
+ }
+ if (strcmp(term, "dumb") == 0) {
+ return 0;
+ }
+ if (strncmp(term, "unknown", 7) == 0) {
+ return 0;
+ }
+ return 1;
+}
+#endif
+
+int max25_ui_init(max25_ui_t **ui)
+{
+ max25_ui_t *u;
+
+ if (ui == NULL) {
+ return -1;
+ }
+
+ u = calloc(1, sizeof(*u));
+ if (u == NULL) {
+ return -1;
+ }
+
+#ifdef MAX25_HAVE_NCURSES
+ if (isatty(STDOUT_FILENO) && isatty(STDIN_FILENO) && term_ok_for_ncurses()
+ && initscr() != NULL) {
+ cbreak();
+ noecho();
+ keypad(stdscr, TRUE);
+ nodelay(stdscr, TRUE);
+ if (has_colors()) {
+ start_color();
+ init_pair(1, COLOR_WHITE, COLOR_BLACK);
+ }
+ bkgd(COLOR_PAIR(1));
+ curs_set(0);
+ clear();
+ refresh();
+ u->ncurses_on = 1;
+ }
+#endif
+
+ *ui = u;
+ return 0;
+}
+
+void max25_ui_shutdown(max25_ui_t *ui)
+{
+ if (ui == NULL) {
+ return;
+ }
+#ifdef MAX25_HAVE_NCURSES
+ if (ui->ncurses_on) {
+ endwin();
+ }
+#endif
+ free(ui);
+}
+
+void max25_ui_apply_palette(void)
+{
+ fputs(MAX25_TERM_SGR, stdout);
+ fflush(stdout);
+}
+
+void max25_ui_clear_screen(void)
+{
+ fputs(MAX25_TERM_CLEAR, stdout);
+ fputs(MAX25_TERM_SGR, stdout);
+ fflush(stdout);
+}
+
+int max25_ui_has_ncurses(const max25_ui_t *ui)
+{
+#ifdef MAX25_HAVE_NCURSES
+ return ui != NULL && ui->ncurses_on;
+#else
+ (void)ui;
+ return 0;
+#endif
+}
+
+void max25_ui_reset_rx(max25_ui_t *ui)
+{
+ if (ui == NULL) {
+ return;
+ }
+ ui->rx_count = 0;
+ ui->rx_head = 0;
+ memset(ui->rx_lines, 0, sizeof(ui->rx_lines));
+}
+
+void max25_ui_append_rx(max25_ui_t *ui, const char *text)
+{
+ int slot;
+
+ if (ui == NULL || text == NULL) {
+ return;
+ }
+
+ slot = ui->rx_head;
+ strncpy(ui->rx_lines[slot], text, MAX25_LINE_MAX - 1);
+ ui->rx_lines[slot][MAX25_LINE_MAX - 1] = '\0';
+ ui->rx_head = (ui->rx_head + 1) % MAX25_UI_RX_LINES;
+ if (ui->rx_count < MAX25_UI_RX_LINES) {
+ ui->rx_count++;
+ }
+}
+
+static void draw_header_plain(const max25_status_t *status)
+{
+ printf("%sMAX25 Terminal DEVICE: %-12s CALLERID: %-9s CALLID: %-9s ax25-ui: %s connected: %s F10=Menu\n",
+ MAX25_TERM_SGR,
+ status->device[0] != '\0' ? status->device : "-",
+ status->callerid, status->callid,
+ status->ax25_ui ? "on" : "off",
+ status->connected ? "yes" : "no");
+ fflush(stdout);
+}
+
+static void draw_rx_plain(const max25_ui_t *ui)
+{
+ int i;
+ int start;
+ int idx;
+
+ if (ui == NULL) {
+ return;
+ }
+
+ start = ui->rx_count < MAX25_UI_RX_LINES ? 0 : ui->rx_head;
+ for (i = 0; i < ui->rx_count; i++) {
+ idx = (start + i) % MAX25_UI_RX_LINES;
+ printf("%s%s\n", MAX25_TERM_SGR, ui->rx_lines[idx]);
+ }
+ fflush(stdout);
+}
+
+void max25_ui_draw_screen(max25_ui_t *ui, const max25_status_t *status,
+ const char *input_line)
+{
+ int i;
+ int start;
+ int idx;
+ int rows;
+ int y;
+
+ if (ui == NULL || status == NULL) {
+ return;
+ }
+
+#ifdef MAX25_HAVE_NCURSES
+ if (ui->ncurses_on && !ui->menu_open) {
+ getmaxyx(stdscr, rows, y);
+ (void)y;
+ if (rows < 4) {
+ rows = 24;
+ }
+
+ attron(COLOR_PAIR(1));
+ mvprintw(0, 0,
+ "MAX25 Terminal DEVICE: %-12s CALLERID: %-9s CALLID: %-9s ax25-ui: %s connected: %s F10=Menu",
+ status->device[0] != '\0' ? status->device : "-",
+ status->callerid, status->callid,
+ status->ax25_ui ? "on" : "off",
+ status->connected ? "yes" : "no");
+ clrtoeol();
+
+ start = ui->rx_count < MAX25_UI_RX_LINES ? 0 : ui->rx_head;
+ for (i = 0; i < ui->rx_count && i + 2 < rows - 2; i++) {
+ idx = (start + i) % MAX25_UI_RX_LINES;
+ mvprintw(i + 2, 0, "%.*s", (int)(rows > 0 ? 200 : 80), ui->rx_lines[idx]);
+ clrtoeol();
+ }
+
+ mvprintw(rows - 2, 0, "%s", "--------------------------------------------------------");
+ clrtoeol();
+ mvprintw(rows - 1, 0, "> %s", input_line != NULL ? input_line : "");
+ clrtoeol();
+ attroff(COLOR_PAIR(1));
+ refresh();
+ return;
+ }
+#endif
+
+ if (!ui->menu_open) {
+ fputs(MAX25_TERM_CLEAR, stdout);
+ fputs(MAX25_TERM_SGR, stdout);
+ draw_header_plain(status);
+ draw_rx_plain(ui);
+ printf("%s> %s\n", MAX25_TERM_SGR, input_line != NULL ? input_line : "");
+ fflush(stdout);
+ }
+}
+
+static void draw_menu_plain(const max25_status_t *status)
+{
+ puts("");
+ puts("+- MAX25 Terminal ---------------------+");
+ printf("| DEVICE: %-12s |\n",
+ status->device[0] != '\0' ? status->device : "-");
+ printf("| CALLERID: %-9s CALLID: %-9s |\n",
+ status->callerid, status->callid);
+ puts("+---------------------------------------+");
+ puts("| 1 Change CALLERID (live) |");
+ puts("| 2 Change CALLID (live) |");
+ puts("| 3 Status |");
+ puts("| 4 Send line |");
+ puts("| 5 RX only (Monitor) |");
+ puts("| 6 Connection on/off |");
+ puts("| 7 Change DEVICE (TX target) |");
+ puts("| 8 List DEVICES (max25d) |");
+ puts("| 0 Quit client |");
+ puts("+---------------------------------------+");
+ puts("Pick a number - F10 closes");
+ fflush(stdout);
+}
+
+#ifdef MAX25_HAVE_NCURSES
+static void draw_menu_ncurses(const max25_status_t *status, int y0)
+{
+ attron(COLOR_PAIR(1));
+ mvprintw(y0, 0, "+- MAX25 Terminal ---------------------+");
+ mvprintw(y0 + 1, 0, "| DEVICE: %-12s |",
+ status->device[0] != '\0' ? status->device : "-");
+ mvprintw(y0 + 2, 0, "| CALLERID: %-9s CALLID: %-9s |",
+ status->callerid, status->callid);
+ mvprintw(y0 + 3, 0, "+---------------------------------------+");
+ mvprintw(y0 + 4, 0, "| 1 Change CALLERID (live) |");
+ mvprintw(y0 + 5, 0, "| 2 Change CALLID (live) |");
+ mvprintw(y0 + 6, 0, "| 3 Status |");
+ mvprintw(y0 + 7, 0, "| 4 Send line |");
+ mvprintw(y0 + 8, 0, "| 5 RX only (Monitor) |");
+ mvprintw(y0 + 9, 0, "| 6 Connection on/off |");
+ mvprintw(y0 + 10, 0, "| 7 Change DEVICE (TX target) |");
+ mvprintw(y0 + 11, 0, "| 8 List DEVICES (max25d) |");
+ mvprintw(y0 + 12, 0, "| 0 Quit client |");
+ mvprintw(y0 + 13, 0, "+---------------------------------------+");
+ mvprintw(y0 + 14, 0, "Pick a number - F10 closes");
+ attroff(COLOR_PAIR(1));
+}
+#endif
+
+void max25_ui_show_menu(max25_ui_t *ui, const max25_status_t *status)
+{
+ if (ui == NULL || status == NULL) {
+ return;
+ }
+ ui->menu_open = 1;
+
+#ifdef MAX25_HAVE_NCURSES
+ if (ui->ncurses_on) {
+ clear();
+ bkgd(COLOR_PAIR(1));
+ draw_menu_ncurses(status, 2);
+ refresh();
+ return;
+ }
+#endif
+ fputs(MAX25_TERM_CLEAR, stdout);
+ fputs(MAX25_TERM_SGR, stdout);
+ draw_menu_plain(status);
+}
+
+void max25_ui_hide_menu(max25_ui_t *ui)
+{
+ if (ui == NULL) {
+ return;
+ }
+ ui->menu_open = 0;
+#ifdef MAX25_HAVE_NCURSES
+ if (ui->ncurses_on) {
+ clear();
+ bkgd(COLOR_PAIR(1));
+ refresh();
+ }
+#endif
+}
+
+int max25_ui_menu_visible(const max25_ui_t *ui)
+{
+ return ui != NULL && ui->menu_open;
+}
+
+max25_menu_action_t max25_ui_menu_pick(max25_ui_t *ui, int ch)
+{
+ (void)ui;
+ switch (ch) {
+ case '1':
+ return MAX25_MENU_CALLERID;
+ case '2':
+ return MAX25_MENU_CALLID;
+ case '3':
+ return MAX25_MENU_STATUS;
+ case '4':
+ return MAX25_MENU_SEND;
+ case '5':
+ return MAX25_MENU_MONITOR;
+ case '6':
+ return MAX25_MENU_CONNECT;
+ case '7':
+ return MAX25_MENU_DEVICE;
+ case '8':
+ return MAX25_MENU_DEVICES;
+ case '0':
+ return MAX25_MENU_QUIT;
+ default:
+ return MAX25_MENU_NONE;
+ }
+}
+
+int max25_ui_prompt(max25_ui_t *ui, const char *label, char *buf, size_t buf_sz)
+{
+ if (label == NULL || buf == NULL || buf_sz == 0) {
+ return -1;
+ }
+
+#ifdef MAX25_HAVE_NCURSES
+ if (ui != NULL && ui->ncurses_on) {
+ int ch;
+ size_t len = 0;
+ int rows;
+
+ getmaxyx(stdscr, rows, ch);
+ (void)rows;
+ echo();
+ curs_set(1);
+ attron(COLOR_PAIR(1));
+ mvprintw(14, 0, "%s: ", label);
+ clrtoeol();
+ refresh();
+ buf[0] = '\0';
+
+ nodelay(stdscr, FALSE);
+ while (len + 1 < buf_sz) {
+ ch = getch();
+ if (ch == '\n' || ch == KEY_ENTER || ch == '\r') {
+ break;
+ }
+ if (ch == 27) {
+ nodelay(stdscr, TRUE);
+ noecho();
+ curs_set(0);
+ return -1;
+ }
+ if (ch == KEY_BACKSPACE || ch == 127 || ch == '\b') {
+ if (len > 0) {
+ len--;
+ buf[len] = '\0';
+ mvprintw(14, (int)strlen(label) + 2, "%s ", buf);
+ clrtoeol();
+ refresh();
+ }
+ continue;
+ }
+ if (ch >= 32 && ch < 127) {
+ buf[len++] = (char)ch;
+ buf[len] = '\0';
+ mvprintw(14, (int)strlen(label) + 2, "%s", buf);
+ refresh();
+ }
+ }
+ nodelay(stdscr, TRUE);
+ noecho();
+ curs_set(0);
+ attroff(COLOR_PAIR(1));
+ return (int)len;
+ }
+#endif
+
+ printf("%s%s: ", MAX25_TERM_SGR, label);
+ fflush(stdout);
+ if (fgets(buf, (int)buf_sz, stdin) == NULL) {
+ return -1;
+ }
+ buf[strcspn(buf, "\r\n")] = '\0';
+ return (int)strlen(buf);
+}
diff --git a/stacks/terminal/max25_ui.h b/stacks/terminal/max25_ui.h
new file mode 100644
index 0000000..9c1928d
--- /dev/null
+++ b/stacks/terminal/max25_ui.h
@@ -0,0 +1,45 @@
+#ifndef MAX25_UI_H
+#define MAX25_UI_H
+
+#include "max25_proto.h"
+
+#define MAX25_TERM_SGR "\033[37;40m"
+#define MAX25_TERM_CLEAR "\033[2J\033[H"
+#define MAX25_UI_RX_LINES 12
+#define MAX25_UI_INPUT_MAX 256
+
+typedef struct max25_ui max25_ui_t;
+
+typedef enum {
+ MAX25_MENU_NONE = 0,
+ MAX25_MENU_CALLERID,
+ MAX25_MENU_CALLID,
+ MAX25_MENU_STATUS,
+ MAX25_MENU_SEND,
+ MAX25_MENU_MONITOR,
+ MAX25_MENU_CONNECT,
+ MAX25_MENU_DEVICE,
+ MAX25_MENU_DEVICES,
+ MAX25_MENU_QUIT
+} max25_menu_action_t;
+
+int max25_ui_init(max25_ui_t **ui);
+void max25_ui_shutdown(max25_ui_t *ui);
+
+void max25_ui_apply_palette(void);
+void max25_ui_clear_screen(void);
+
+int max25_ui_has_ncurses(const max25_ui_t *ui);
+
+void max25_ui_reset_rx(max25_ui_t *ui);
+void max25_ui_append_rx(max25_ui_t *ui, const char *text);
+void max25_ui_draw_screen(max25_ui_t *ui, const max25_status_t *status,
+ const char *input_line);
+void max25_ui_show_menu(max25_ui_t *ui, const max25_status_t *status);
+void max25_ui_hide_menu(max25_ui_t *ui);
+int max25_ui_menu_visible(const max25_ui_t *ui);
+
+max25_menu_action_t max25_ui_menu_pick(max25_ui_t *ui, int ch);
+int max25_ui_prompt(max25_ui_t *ui, const char *label, char *buf, size_t buf_sz);
+
+#endif /* MAX25_UI_H */
diff --git a/stacks/terminal/test-terminal.sh b/stacks/terminal/test-terminal.sh
new file mode 100755
index 0000000..6a510f3
--- /dev/null
+++ b/stacks/terminal/test-terminal.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# max25-terminal offline probe — binary + TCP connect test.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")" && pwd)"
+REPO="$(cd "${ROOT}/../.." && pwd)"
+TERM="${MAX25_TERMINAL:-${REPO}/build/bin/max25-terminal}"
+
+[[ -x "${TERM}" ]] || { echo "FAIL: ${TERM} missing" >&2; exit 1; }
+
+CLIENT="${REPO}/build/bin/max25-client"
+if [[ -L "${CLIENT}" ]] || [[ -x "${CLIENT}" ]]; then
+ :
+else
+ echo "WARN: max25-client symlink missing until cmake --install" >&2
+fi
+
+"${TERM}" --help >/dev/null
+
+# Unit: EVENT connected/disconnected updates status (header bug regression)
+UNIT="${ROOT}/.test_apply_event"
+gcc -std=c11 -Wall -Wextra -O2 -I"${ROOT}" \
+ -o "${UNIT}" "${ROOT}/test_apply_event.c" "${ROOT}/max25_proto.c"
+"${UNIT}"
+rm -f "${UNIT}"
+
+MAX25_TERMINAL="${TERM}" python3 "${ROOT}/test_connect.py"
+echo "OK: max25-terminal"
diff --git a/stacks/terminal/test_apply_event.c b/stacks/terminal/test_apply_event.c
new file mode 100644
index 0000000..36e10f0
--- /dev/null
+++ b/stacks/terminal/test_apply_event.c
@@ -0,0 +1,30 @@
+/* Offline unit test: EVENT connected/disconnected → status.connected */
+#include "max25_proto.h"
+
+#include <stdio.h>
+#include <string.h>
+
+int main(void)
+{
+ max25_status_t st;
+
+ memset(&st, 0, sizeof(st));
+ if (max25_client_apply_event("EVENT connected", &st) != 1 || !st.connected) {
+ fprintf(stderr, "FAIL: EVENT connected\n");
+ return 1;
+ }
+ if (max25_client_apply_event("EVENT disconnected", &st) != 1 || st.connected) {
+ fprintf(stderr, "FAIL: EVENT disconnected\n");
+ return 1;
+ }
+ if (max25_client_apply_event("EVENT device=max25e0 serial=ready", &st) != 0) {
+ fprintf(stderr, "FAIL: unrelated EVENT should be ignored\n");
+ return 1;
+ }
+ if (max25_client_apply_event("OK", &st) != 0) {
+ fprintf(stderr, "FAIL: non-EVENT\n");
+ return 1;
+ }
+ puts("OK: apply_event");
+ return 0;
+}
diff --git a/stacks/terminal/test_connect.py b/stacks/terminal/test_connect.py
new file mode 100644
index 0000000..6496b69
--- /dev/null
+++ b/stacks/terminal/test_connect.py
@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+"""TCP connect smoke test for max25-terminal --probe."""
+import socket
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+DAEMON = ROOT / "stacks/daemon/max25d"
+INI = ROOT / "share/max25/max25d.ini.example"
+
+
+def terminal_binary() -> Path:
+ import os
+
+ env = os.environ.get("MAX25_TERMINAL")
+ if env:
+ return Path(env)
+ for candidate in (
+ ROOT / "build/bin/max25-terminal",
+ ROOT / "stacks/terminal/max25-terminal",
+ ):
+ if candidate.is_file():
+ return candidate
+ return ROOT / "build/bin/max25-terminal"
+
+
+def free_port() -> int:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ sock.bind(("127.0.0.1", 0))
+ port = sock.getsockname()[1]
+ sock.close()
+ return port
+
+
+def main() -> int:
+ terminal = terminal_binary()
+ if not terminal.is_file():
+ print(
+ f"FAIL: max25-terminal missing — run cmake --build build --target max25-terminal "
+ f"(looked for {terminal})",
+ file=sys.stderr,
+ )
+ return 1
+
+ port = free_port()
+ proc = subprocess.Popen(
+ [str(DAEMON), "--no-stack", "-c", str(INI), "--tcp-port", str(port)],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ time.sleep(0.6)
+ try:
+ out = subprocess.check_output(
+ [str(terminal), "-T", "-H", "127.0.0.1", "-p", str(port), "--probe"],
+ text=True,
+ timeout=10,
+ )
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
+ proc.terminate()
+ proc.wait(timeout=5)
+ print(f"FAIL: terminal probe: {exc}", file=sys.stderr)
+ return 1
+ finally:
+ proc.terminate()
+ proc.wait(timeout=5)
+
+ if "STATUS hardware=" not in out or "callerid=" not in out:
+ print(f"FAIL: unexpected probe output: {out!r}", file=sys.stderr)
+ return 1
+
+ print("OK: max25-terminal TCP probe")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
git clone -b <branch> https://cgit.mode42.com/<repo>.git
git clone -b <branch> git://cgit.mode42.com/<repo>.git

info@mode42.com