From fa05a5f8238e9e1417235711656e5062ccc843a7 Mon Sep 17 00:00:00 2001 From: "info@mode42.com" Date: Fri, 7 Aug 2026 18:23:28 +0000 Subject: Initial push --- .github/ISSUE_TEMPLATE/bug_report.md | 38 + .github/ISSUE_TEMPLATE/config.yml | 1 + .github/ISSUE_TEMPLATE/feature_request.md | 22 + .github/pull_request_template.md | 37 + .github/workflows/ci.yml | 30 + .gitignore | 30 + CHANGELOG.md | 128 ++ CMakeLists.txt | 184 ++ CONTRIBUTING.md | 16 + LICENSE | 674 ++++++ README.md | 67 + RELEASE-READINESS.md | 53 + VERSION | 1 + cmake/MAX25Ax25.cmake | 19 + cmake/MAX25Platform.cmake | 37 + docs/README.md | 13 + include/max25/packet-radio.md | 49 + include/max25/protocol.md | 213 ++ local/README.md | 25 + scripts/build-all.sh | 54 + scripts/build-amiga-terminal.sh | 17 + scripts/build-ax25-deps.sh | 146 ++ scripts/build-freebsd.sh | 30 + scripts/build.sh | 11 + scripts/clean.sh | 9 + scripts/discover-plugins.sh | 97 + scripts/install-freebsd.sh | 111 + scripts/install-max25.sh | 116 + scripts/max25-ctl | 408 ++++ scripts/max25-lite-setup.sh | 517 +++++ scripts/max25-terminal | 3 + scripts/max25d-session.sh | 160 ++ scripts/release-check.sh | 258 +++ scripts/run-max25-terminal.sh | 62 + scripts/run-max25d.sh | 99 + scripts/terminology-check.sh | 41 + scripts/test-wizard.sh | 113 + scripts/test.sh | 12 + scripts/tx-rx-test.sh | 358 ++++ share/clients/README.md | 79 + share/clients/baycom-kiss.yaml | 46 + share/clients/index.yaml | 39 + share/clients/kiss-serial-rs232.yaml | 55 + share/clients/kiss-serial-usb.yaml | 57 + share/clients/pktnc2.yaml | 54 + share/clients/soft-crdop.yaml | 66 + share/clients/tmodem.yaml | 42 + share/clients/tnc2c.yaml | 53 + share/hybbx/README.md | 7 + share/hybbx/baycom-kiss-host.ini.example | 38 + share/hybbx/crdop-host.ini.example | 17 + share/hybbx/pktnc2-host.ini.example | 28 + share/hybbx/service-dual.ini.example | 37 + share/hybbx/tnc2c-host.ini.example | 38 + share/max25-bcpr/max25-bcpr.freebsd.ini.example | 34 + share/max25-bcpr/max25-bcpr.ini.example | 44 + share/max25/max25d.ini.example | 46 + share/max25/max25d.lite.bcpr.ini.example | 42 + share/max25/max25d.lite.crdop.ini.example | 40 + share/max25/max25d.lite.ini.example | 44 + share/max25/max25d.service.example | 13 + share/max25/serial/README.md | 22 + share/max25/serial/pccom-kiss-serial.env.example | 9 + share/max25/serial/pktnc2-serial.env.example | 5 + share/max25/serial/tnc2c-serial.env.example | 8 + share/systemd/README.md | 9 + share/systemd/max25d.service | 32 + stacks/crdop/.github/workflows/build.yml | 54 + stacks/crdop/.gitignore | 13 + stacks/crdop/CMakeLists.txt | 55 + stacks/crdop/LICENSE | 22 + stacks/crdop/NOTICE.md | 9 + stacks/crdop/README.md | 36 + stacks/crdop/ROADMAP.md | 445 ++++ stacks/crdop/VERSION | 1 + stacks/crdop/cmake/CRDOPCompiler.cmake | 37 + stacks/crdop/cmake/CRDOPPlatform.cmake | 71 + stacks/crdop/cmake/CRDOPTests.cmake | 104 + stacks/crdop/cmake/CRDOPVendorArdopcf.cmake | 127 ++ .../crdop/cmake/toolchains/aarch64-linux-gnu.cmake | 4 + .../cmake/toolchains/arm-linux-gnueabihf.cmake | 4 + .../cmake/toolchains/x86_64-w64-mingw32.cmake | 4 + stacks/crdop/docs/ACOUSTIC-TEST-PROTOCOL.md | 84 + stacks/crdop/docs/AUDIO-ARCHITECTURE.md | 80 + stacks/crdop/docs/BUILD.md | 99 + stacks/crdop/docs/CHANGELOG.md | 25 + stacks/crdop/docs/CONFIG.md | 50 + stacks/crdop/docs/DEVELOPER.md | 188 ++ stacks/crdop/docs/EXAMPLES.md | 91 + stacks/crdop/docs/FEC-SPEC.md | 99 + stacks/crdop/docs/G3RUH-DESIGN.md | 72 + stacks/crdop/docs/HARDWARE-INTERFACE.md | 194 ++ stacks/crdop/docs/HOST-PROTOCOL-SPEC.md | 103 + stacks/crdop/docs/INDEX.md | 77 + stacks/crdop/docs/LICENSE-USAGE.md | 116 + stacks/crdop/docs/MAX25-USAGE.md | 292 +++ stacks/crdop/docs/PROTOCOL.md | 36 + stacks/crdop/docs/README.md | 31 + stacks/crdop/docs/SOFTMODEM.md | 127 ++ stacks/crdop/docs/SOUNDCARD-QUALIFICATION.md | 95 + stacks/crdop/include/crdop/sound_proxy.h | 52 + stacks/crdop/include/crdop/version.h | 8 + stacks/crdop/lib/__init__.py | 1 + stacks/crdop/lib/acoustic_engine.py | 89 + stacks/crdop/lib/afsk_demodulator.py | 56 + stacks/crdop/lib/afsk_modulator.py | 51 + stacks/crdop/lib/bell202_line_code.py | 50 + stacks/crdop/lib/hdlc_codec.py | 110 + stacks/crdop/lib/m25_host_protocol.py | 142 ++ stacks/crdop/lib/sound_proxy.py | 185 ++ stacks/crdop/lib/sound_proxy_oss.py | 151 ++ stacks/crdop/lib/test_bell202_line_code.py | 34 + .../ardopcf/0001-crdop-linux-gcc-branding.patch | 186 ++ .../ardopcf/0002-crdop-version-override.patch | 18 + stacks/crdop/scripts/apply-vendor-patches.sh | 36 + stacks/crdop/scripts/build-crdop.sh | 48 + stacks/crdop/scripts/crdopc | 171 ++ stacks/crdop/scripts/import-ardopcf.sh | 17 + stacks/crdop/scripts/install-crdop.sh | 16 + stacks/crdop/scripts/refresh-vendor-ardopcf.sh | 26 + stacks/crdop/scripts/sync-upstream.sh | 21 + stacks/crdop/scripts/test-all.sh | 17 + stacks/crdop/scripts/test-smoke.sh | 24 + stacks/crdop/scripts/test-with-host.sh | 9 + stacks/crdop/share/crdop-amateur.ini.example | 15 + stacks/crdop/share/crdop-dual.ini.example | 15 + stacks/crdop/share/crdop.freebsd.ini.example | 25 + stacks/crdop/share/crdop.ini.example | 37 + stacks/crdop/src/crdop_version.c | 5 + stacks/crdop/tools/audio-dummyd | 4 + stacks/crdop/tools/audio-dummyd.py | 66 + stacks/crdop/tools/max25-signal-sniffer | 5 + stacks/crdop/tools/max25-signal-sniffer.py | 78 + stacks/crdop/vendor/README.md | 5 + stacks/crdop/vendor/ardopcf.ref | 9 + stacks/daemon/README.md | 72 + stacks/daemon/ax25_codec.py | 176 ++ stacks/daemon/banlist.py | 89 + stacks/daemon/daemon_log.py | 237 +++ stacks/daemon/device_backends.py | 1076 ++++++++++ stacks/daemon/kiss_bridge.py | 574 +++++ stacks/daemon/max25_platform.py | 83 + stacks/daemon/max25d | 21 + stacks/daemon/max25d.py | 2213 ++++++++++++++++++++ stacks/daemon/modular_tcp_server.py | 208 ++ stacks/daemon/paths.py | 281 +++ stacks/daemon/privilege_drop.py | 110 + stacks/daemon/reporting_quality.py | 178 ++ stacks/daemon/test_audio_dummy_backend.py | 49 + stacks/daemon/test_auth.py | 95 + stacks/daemon/test_ax25_codec.py | 97 + stacks/daemon/test_bans.py | 199 ++ stacks/daemon/test_baycom_backend.py | 95 + stacks/daemon/test_bcpr_backend.py | 194 ++ stacks/daemon/test_crdop_backend.py | 130 ++ stacks/daemon/test_daemon_log.py | 99 + stacks/daemon/test_kiss_bridge.py | 161 ++ stacks/daemon/test_max25_platform.py | 54 + stacks/daemon/test_modular_tcp.py | 35 + stacks/daemon/test_multi_device.py | 518 +++++ stacks/daemon/test_path_normalize.py | 17 + stacks/daemon/test_paths.py | 145 ++ stacks/daemon/test_privilege_drop.py | 55 + stacks/daemon/test_proto.py | 106 + stacks/daemon/test_reporting.py | 163 ++ stacks/daemon/test_reporting_quality.py | 123 ++ stacks/daemon/test_serial_watch.py | 241 +++ stacks/daemon/test_tx_rx_offline.py | 191 ++ stacks/daemon/test_unix_socket_ownership.py | 61 + stacks/daemon/tx_pace.py | 21 + stacks/max25-bcpr/CMakeLists.txt | 76 + stacks/max25-bcpr/NOTICE.md | 11 + stacks/max25-bcpr/README.md | 69 + stacks/max25-bcpr/VERSION | 1 + stacks/max25-bcpr/include/bcpr/bcpr.h | 16 + stacks/max25-bcpr/include/bcpr/bcpr_config.h | 70 + stacks/max25-bcpr/include/bcpr/bcpr_crc.h | 12 + stacks/max25-bcpr/include/bcpr/bcpr_daemon.h | 23 + stacks/max25-bcpr/include/bcpr/bcpr_engine.h | 62 + stacks/max25-bcpr/include/bcpr/bcpr_hdlc.h | 57 + stacks/max25-bcpr/include/bcpr/bcpr_kiss.h | 17 + stacks/max25-bcpr/include/bcpr/bcpr_lock.h | 22 + stacks/max25-bcpr/include/bcpr/bcpr_runas.h | 12 + stacks/max25-bcpr/include/bcpr/bcpr_ser12.h | 47 + stacks/max25-bcpr/include/bcpr/bcpr_uart.h | 22 + .../share/max25-bcpr.freebsd.ini.example | 34 + stacks/max25-bcpr/share/max25-bcpr.ini.example | 48 + stacks/max25-bcpr/src/bcpr_config.c | 397 ++++ stacks/max25-bcpr/src/bcpr_crc.c | 55 + stacks/max25-bcpr/src/bcpr_daemon.c | 289 +++ stacks/max25-bcpr/src/bcpr_engine.c | 652 ++++++ stacks/max25-bcpr/src/bcpr_hdlc.c | 293 +++ stacks/max25-bcpr/src/bcpr_kiss.c | 127 ++ stacks/max25-bcpr/src/bcpr_lock.c | 202 ++ stacks/max25-bcpr/src/bcpr_runas.c | 81 + stacks/max25-bcpr/src/bcpr_ser12.c | 246 +++ stacks/max25-bcpr/src/bcpr_uart.c | 251 +++ stacks/max25-bcpr/src/max25-bcprd-init.c | 255 +++ stacks/max25-bcpr/src/max25-bcprd.c | 205 ++ stacks/max25-bcpr/tests/test_config_offline.c | 53 + stacks/max25-bcpr/tests/test_hdlc_offline.c | 110 + stacks/max25-bcpr/tools/NEBENBEI-MOVED.md | 5 + stacks/max25-bcpr/tools/bcpr-ultimate-diag.sh | 916 ++++++++ stacks/max25-bcpr/tools/max25-bcpr-ctl | 379 ++++ stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh | 566 +++++ .../max25-bcpr/tools/max25-bcpr-ultimate-diag.sh | 914 ++++++++ stacks/terminal/.gitignore | 9 + stacks/terminal/CMakeLists.txt | 40 + stacks/terminal/README.md | 31 + stacks/terminal/amiga/Makefile | 32 + stacks/terminal/amiga/max25_terminal_amiga.c | 309 +++ stacks/terminal/max25_proto.c | 472 +++++ stacks/terminal/max25_proto.h | 55 + stacks/terminal/max25_terminal.c | 767 +++++++ stacks/terminal/max25_ui.c | 407 ++++ stacks/terminal/max25_ui.h | 45 + stacks/terminal/test-terminal.sh | 28 + stacks/terminal/test_apply_event.c | 30 + stacks/terminal/test_connect.py | 78 + 219 files changed, 26955 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CMakeLists.txt create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 RELEASE-READINESS.md create mode 100644 VERSION create mode 100644 cmake/MAX25Ax25.cmake create mode 100644 cmake/MAX25Platform.cmake create mode 100644 docs/README.md create mode 100644 include/max25/packet-radio.md create mode 100644 include/max25/protocol.md create mode 100644 local/README.md create mode 100755 scripts/build-all.sh create mode 100755 scripts/build-amiga-terminal.sh create mode 100755 scripts/build-ax25-deps.sh create mode 100755 scripts/build-freebsd.sh create mode 100755 scripts/build.sh create mode 100755 scripts/clean.sh create mode 100755 scripts/discover-plugins.sh create mode 100755 scripts/install-freebsd.sh create mode 100755 scripts/install-max25.sh create mode 100755 scripts/max25-ctl create mode 100755 scripts/max25-lite-setup.sh create mode 100755 scripts/max25-terminal create mode 100755 scripts/max25d-session.sh create mode 100755 scripts/release-check.sh create mode 100755 scripts/run-max25-terminal.sh create mode 100755 scripts/run-max25d.sh create mode 100755 scripts/terminology-check.sh create mode 100755 scripts/test-wizard.sh create mode 100755 scripts/test.sh create mode 100755 scripts/tx-rx-test.sh create mode 100644 share/clients/README.md create mode 100644 share/clients/baycom-kiss.yaml create mode 100644 share/clients/index.yaml create mode 100644 share/clients/kiss-serial-rs232.yaml create mode 100644 share/clients/kiss-serial-usb.yaml create mode 100644 share/clients/pktnc2.yaml create mode 100644 share/clients/soft-crdop.yaml create mode 100644 share/clients/tmodem.yaml create mode 100644 share/clients/tnc2c.yaml create mode 100644 share/hybbx/README.md create mode 100644 share/hybbx/baycom-kiss-host.ini.example create mode 100644 share/hybbx/crdop-host.ini.example create mode 100644 share/hybbx/pktnc2-host.ini.example create mode 100644 share/hybbx/service-dual.ini.example create mode 100644 share/hybbx/tnc2c-host.ini.example create mode 100644 share/max25-bcpr/max25-bcpr.freebsd.ini.example create mode 100644 share/max25-bcpr/max25-bcpr.ini.example create mode 100644 share/max25/max25d.ini.example create mode 100644 share/max25/max25d.lite.bcpr.ini.example create mode 100644 share/max25/max25d.lite.crdop.ini.example create mode 100644 share/max25/max25d.lite.ini.example create mode 100644 share/max25/max25d.service.example create mode 100644 share/max25/serial/README.md create mode 100644 share/max25/serial/pccom-kiss-serial.env.example create mode 100644 share/max25/serial/pktnc2-serial.env.example create mode 100644 share/max25/serial/tnc2c-serial.env.example create mode 100644 share/systemd/README.md create mode 100644 share/systemd/max25d.service create mode 100644 stacks/crdop/.github/workflows/build.yml create mode 100644 stacks/crdop/.gitignore create mode 100644 stacks/crdop/CMakeLists.txt create mode 100644 stacks/crdop/LICENSE create mode 100644 stacks/crdop/NOTICE.md create mode 100644 stacks/crdop/README.md create mode 100644 stacks/crdop/ROADMAP.md create mode 100644 stacks/crdop/VERSION create mode 100644 stacks/crdop/cmake/CRDOPCompiler.cmake create mode 100644 stacks/crdop/cmake/CRDOPPlatform.cmake create mode 100644 stacks/crdop/cmake/CRDOPTests.cmake create mode 100644 stacks/crdop/cmake/CRDOPVendorArdopcf.cmake create mode 100644 stacks/crdop/cmake/toolchains/aarch64-linux-gnu.cmake create mode 100644 stacks/crdop/cmake/toolchains/arm-linux-gnueabihf.cmake create mode 100644 stacks/crdop/cmake/toolchains/x86_64-w64-mingw32.cmake create mode 100644 stacks/crdop/docs/ACOUSTIC-TEST-PROTOCOL.md create mode 100644 stacks/crdop/docs/AUDIO-ARCHITECTURE.md create mode 100644 stacks/crdop/docs/BUILD.md create mode 100644 stacks/crdop/docs/CHANGELOG.md create mode 100644 stacks/crdop/docs/CONFIG.md create mode 100644 stacks/crdop/docs/DEVELOPER.md create mode 100644 stacks/crdop/docs/EXAMPLES.md create mode 100644 stacks/crdop/docs/FEC-SPEC.md create mode 100644 stacks/crdop/docs/G3RUH-DESIGN.md create mode 100644 stacks/crdop/docs/HARDWARE-INTERFACE.md create mode 100644 stacks/crdop/docs/HOST-PROTOCOL-SPEC.md create mode 100644 stacks/crdop/docs/INDEX.md create mode 100644 stacks/crdop/docs/LICENSE-USAGE.md create mode 100644 stacks/crdop/docs/MAX25-USAGE.md create mode 100644 stacks/crdop/docs/PROTOCOL.md create mode 100644 stacks/crdop/docs/README.md create mode 100644 stacks/crdop/docs/SOFTMODEM.md create mode 100644 stacks/crdop/docs/SOUNDCARD-QUALIFICATION.md create mode 100644 stacks/crdop/include/crdop/sound_proxy.h create mode 100644 stacks/crdop/include/crdop/version.h create mode 100644 stacks/crdop/lib/__init__.py create mode 100644 stacks/crdop/lib/acoustic_engine.py create mode 100644 stacks/crdop/lib/afsk_demodulator.py create mode 100644 stacks/crdop/lib/afsk_modulator.py create mode 100644 stacks/crdop/lib/bell202_line_code.py create mode 100644 stacks/crdop/lib/hdlc_codec.py create mode 100644 stacks/crdop/lib/m25_host_protocol.py create mode 100644 stacks/crdop/lib/sound_proxy.py create mode 100644 stacks/crdop/lib/sound_proxy_oss.py create mode 100644 stacks/crdop/lib/test_bell202_line_code.py create mode 100644 stacks/crdop/patches/ardopcf/0001-crdop-linux-gcc-branding.patch create mode 100644 stacks/crdop/patches/ardopcf/0002-crdop-version-override.patch create mode 100755 stacks/crdop/scripts/apply-vendor-patches.sh create mode 100755 stacks/crdop/scripts/build-crdop.sh create mode 100755 stacks/crdop/scripts/crdopc create mode 100755 stacks/crdop/scripts/import-ardopcf.sh create mode 100755 stacks/crdop/scripts/install-crdop.sh create mode 100755 stacks/crdop/scripts/refresh-vendor-ardopcf.sh create mode 100755 stacks/crdop/scripts/sync-upstream.sh create mode 100755 stacks/crdop/scripts/test-all.sh create mode 100755 stacks/crdop/scripts/test-smoke.sh create mode 100755 stacks/crdop/scripts/test-with-host.sh create mode 100644 stacks/crdop/share/crdop-amateur.ini.example create mode 100644 stacks/crdop/share/crdop-dual.ini.example create mode 100644 stacks/crdop/share/crdop.freebsd.ini.example create mode 100644 stacks/crdop/share/crdop.ini.example create mode 100644 stacks/crdop/src/crdop_version.c create mode 100755 stacks/crdop/tools/audio-dummyd create mode 100644 stacks/crdop/tools/audio-dummyd.py create mode 100755 stacks/crdop/tools/max25-signal-sniffer create mode 100644 stacks/crdop/tools/max25-signal-sniffer.py create mode 100644 stacks/crdop/vendor/README.md create mode 100644 stacks/crdop/vendor/ardopcf.ref create mode 100644 stacks/daemon/README.md create mode 100644 stacks/daemon/ax25_codec.py create mode 100644 stacks/daemon/banlist.py create mode 100644 stacks/daemon/daemon_log.py create mode 100644 stacks/daemon/device_backends.py create mode 100644 stacks/daemon/kiss_bridge.py create mode 100644 stacks/daemon/max25_platform.py create mode 100755 stacks/daemon/max25d create mode 100755 stacks/daemon/max25d.py create mode 100644 stacks/daemon/modular_tcp_server.py create mode 100644 stacks/daemon/paths.py create mode 100644 stacks/daemon/privilege_drop.py create mode 100644 stacks/daemon/reporting_quality.py create mode 100644 stacks/daemon/test_audio_dummy_backend.py create mode 100644 stacks/daemon/test_auth.py create mode 100644 stacks/daemon/test_ax25_codec.py create mode 100644 stacks/daemon/test_bans.py create mode 100644 stacks/daemon/test_baycom_backend.py create mode 100644 stacks/daemon/test_bcpr_backend.py create mode 100644 stacks/daemon/test_crdop_backend.py create mode 100644 stacks/daemon/test_daemon_log.py create mode 100644 stacks/daemon/test_kiss_bridge.py create mode 100644 stacks/daemon/test_max25_platform.py create mode 100644 stacks/daemon/test_modular_tcp.py create mode 100644 stacks/daemon/test_multi_device.py create mode 100644 stacks/daemon/test_path_normalize.py create mode 100644 stacks/daemon/test_paths.py create mode 100644 stacks/daemon/test_privilege_drop.py create mode 100644 stacks/daemon/test_proto.py create mode 100644 stacks/daemon/test_reporting.py create mode 100644 stacks/daemon/test_reporting_quality.py create mode 100644 stacks/daemon/test_serial_watch.py create mode 100644 stacks/daemon/test_tx_rx_offline.py create mode 100644 stacks/daemon/test_unix_socket_ownership.py create mode 100644 stacks/daemon/tx_pace.py create mode 100644 stacks/max25-bcpr/CMakeLists.txt create mode 100644 stacks/max25-bcpr/NOTICE.md create mode 100644 stacks/max25-bcpr/README.md create mode 100644 stacks/max25-bcpr/VERSION create mode 100644 stacks/max25-bcpr/include/bcpr/bcpr.h create mode 100644 stacks/max25-bcpr/include/bcpr/bcpr_config.h create mode 100644 stacks/max25-bcpr/include/bcpr/bcpr_crc.h create mode 100644 stacks/max25-bcpr/include/bcpr/bcpr_daemon.h create mode 100644 stacks/max25-bcpr/include/bcpr/bcpr_engine.h create mode 100644 stacks/max25-bcpr/include/bcpr/bcpr_hdlc.h create mode 100644 stacks/max25-bcpr/include/bcpr/bcpr_kiss.h create mode 100644 stacks/max25-bcpr/include/bcpr/bcpr_lock.h create mode 100644 stacks/max25-bcpr/include/bcpr/bcpr_runas.h create mode 100644 stacks/max25-bcpr/include/bcpr/bcpr_ser12.h create mode 100644 stacks/max25-bcpr/include/bcpr/bcpr_uart.h create mode 100644 stacks/max25-bcpr/share/max25-bcpr.freebsd.ini.example create mode 100644 stacks/max25-bcpr/share/max25-bcpr.ini.example create mode 100644 stacks/max25-bcpr/src/bcpr_config.c create mode 100644 stacks/max25-bcpr/src/bcpr_crc.c create mode 100644 stacks/max25-bcpr/src/bcpr_daemon.c create mode 100644 stacks/max25-bcpr/src/bcpr_engine.c create mode 100644 stacks/max25-bcpr/src/bcpr_hdlc.c create mode 100644 stacks/max25-bcpr/src/bcpr_kiss.c create mode 100644 stacks/max25-bcpr/src/bcpr_lock.c create mode 100644 stacks/max25-bcpr/src/bcpr_runas.c create mode 100644 stacks/max25-bcpr/src/bcpr_ser12.c create mode 100644 stacks/max25-bcpr/src/bcpr_uart.c create mode 100644 stacks/max25-bcpr/src/max25-bcprd-init.c create mode 100644 stacks/max25-bcpr/src/max25-bcprd.c create mode 100644 stacks/max25-bcpr/tests/test_config_offline.c create mode 100644 stacks/max25-bcpr/tests/test_hdlc_offline.c create mode 100644 stacks/max25-bcpr/tools/NEBENBEI-MOVED.md create mode 100755 stacks/max25-bcpr/tools/bcpr-ultimate-diag.sh create mode 100755 stacks/max25-bcpr/tools/max25-bcpr-ctl create mode 100755 stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh create mode 100755 stacks/max25-bcpr/tools/max25-bcpr-ultimate-diag.sh create mode 100644 stacks/terminal/.gitignore create mode 100644 stacks/terminal/CMakeLists.txt create mode 100644 stacks/terminal/README.md create mode 100644 stacks/terminal/amiga/Makefile create mode 100644 stacks/terminal/amiga/max25_terminal_amiga.c create mode 100644 stacks/terminal/max25_proto.c create mode 100644 stacks/terminal/max25_proto.h create mode 100644 stacks/terminal/max25_terminal.c create mode 100644 stacks/terminal/max25_ui.c create mode 100644 stacks/terminal/max25_ui.h create mode 100755 stacks/terminal/test-terminal.sh create mode 100644 stacks/terminal/test_apply_event.c create mode 100644 stacks/terminal/test_connect.py 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 + + + +## 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 + + 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. + 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. + + + Copyright (C) + + 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 . + +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: + + Copyright (C) + 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 +. + + 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 +. 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= device= devices= mode= callerid= callid= ax25_ui=on|off connected=yes|no stack= serial= 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 +OK +STATUS hardware=… +``` + +Client sends the password as a single line: `AUTH ` (plain text, v1). + +Wrong or missing password → `ERR auth failed` and the connection closes. + +| Client flag | Env | +|-------------|-----| +| `-P`, `--password` | `MAX25_TCP_PASSWORD` | + +`` 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 ` | Select TX target device for this session (`SELECT DEVICE` alias) | +| `SET CALLERID ` | Live source callsign (uppercase) | +| `SET CALLID ` | 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 ` | 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 ` | Block AX.25 source — incoming UI frames dropped silently | +| `UNBAN ` | 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 ` | 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= ` | Received traffic from `` for display | +| `RX ` | 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 ` / `SELECT DEVICE ` | `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 ` | `RX device= ` → `OK` (sender); other clients get `RX …` only | + +With `ax25_ui=on`, framed text looks like: `[AX25 UI >] `. + +### Errors + +| Condition | Response | +|-----------|----------| +| Unknown command | `ERR unknown command: ` | +| `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.]` 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= 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 <&2; usage; exit 2 ;; + esac +done + +echo "== MainAX25-Stack build-all (${BUILD_DIR}, ${BUILD_TYPE}) ==" +cmake -B "${BUILD_DIR}" \ + -DCMAKE_BUILD_TYPE="${BUILD_TYPE}" \ + -DMAX25_BUILD_MAX25_BCPR="${MAX25_BUILD_MAX25_BCPR:-ON}" \ + -DMAX25_BUILD_TERMINAL=ON \ + -DMAX25_BUILD_DAEMON=ON \ + -DMAX25_BUNDLE_AX25=OFF +cmake --build "${BUILD_DIR}" -j"$(nproc 2>/dev/null || echo 2)" + +echo "== plugin discovery ==" +bash scripts/discover-plugins.sh || true + +if [[ "$DO_INSTALL" -eq 1 ]]; then + echo "== install → ${PREFIX} ==" + if [[ "$(id -u)" -eq 0 ]]; then + cmake --install "${BUILD_DIR}" --prefix "${PREFIX}" + else + sudo cmake --install "${BUILD_DIR}" --prefix "${PREFIX}" + fi + echo "Launchers: ${PREFIX}/bin/run-max25d ${PREFIX}/bin/run-max25-terminal" + echo "Tests: ${PREFIX}/sbin/max25-tx-rx-test ${PREFIX}/sbin/max25-bcpr-rxtx-smoke.sh" +fi + +echo "== done ==" diff --git a/scripts/build-amiga-terminal.sh b/scripts/build-amiga-terminal.sh new file mode 100755 index 0000000..f6336b5 --- /dev/null +++ b/scripts/build-amiga-terminal.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env sh +# Cross-build max25-terminal for AmigaOS 3.9+ (bebbo-gcc / clib2). +set -eu + +ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)" +SDK="${AMIGA_SDK_PATH:-/opt/amiga}" + +if [ ! -x "$SDK/bin/m68k-amigaos-gcc" ]; then + echo "Amiga SDK not found at $SDK (set AMIGA_SDK_PATH)" >&2 + exit 1 +fi + +make -C "$ROOT/stacks/terminal/amiga" AMIGA_SDK="$SDK" all +make -C "$ROOT/stacks/terminal/amiga" AMIGA_SDK="$SDK" test + +echo "AmigaOS client:" +echo " $ROOT/stacks/terminal/amiga/max25-terminal" diff --git a/scripts/build-ax25-deps.sh b/scripts/build-ax25-deps.sh new file mode 100755 index 0000000..d909cdc --- /dev/null +++ b/scripts/build-ax25-deps.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# DEPRECATED — manual build of vendored libax25 / ax25-tools / ax25-apps (Linux). +# +# MAX25 default build does NOT invoke this script. Tarballs in third_party/ax25/ +# are study reference only; max25d uses native ax25_codec.py. +# +# Use when you need host listen/call/kissattach without distro packages: +# scripts/build-ax25-deps.sh --prefix /opt/ax25 --need-apps --need-tools +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +VENDOR="${ROOT}/third_party/ax25" +PATCHES="${VENDOR}/patches" + +LIBAX25_VER="0.0.12-rc5" +TOOLS_VER="0.0.10-rc5" +APPS_VER="0.0.8-rc5" + +usage() { + cat </../build) + --need-libax25 Build libax25 even if present on host + --need-tools Build ax25-tools + --need-apps Build ax25-apps + -h, --help This help + +Environment: + MAKE, CC, CXX Passed to upstream make/configure +EOF +} + +PREFIX="" +BUILD_DIR="" +NEED_LIBAX25=0 +NEED_TOOLS=0 +NEED_APPS=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --prefix) PREFIX="$2"; shift 2 ;; + --build-dir) BUILD_DIR="$2"; shift 2 ;; + --need-libax25) NEED_LIBAX25=1; shift ;; + --need-tools) NEED_TOOLS=1; shift ;; + --need-apps) NEED_APPS=1; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage; exit 1 ;; + esac +done + +[[ -n "$PREFIX" ]] || { echo "error: --prefix required" >&2; exit 1; } +PREFIX="$(cd "$(dirname "$PREFIX")" && pwd)/$(basename "$PREFIX")" +BUILD_DIR="${BUILD_DIR:-$(dirname "$PREFIX")/build}" +BUILD_DIR="$(mkdir -p "$BUILD_DIR" && cd "$BUILD_DIR" && pwd)" +PREFIX="$(mkdir -p "$PREFIX" && cd "$PREFIX" && pwd)" + +verify_tarball() { + local file="$1" + [[ -f "$file" ]] || { echo "error: missing tarball $file" >&2; exit 1; } + if [[ -f "${VENDOR}/SHA256SUMS" ]]; then + local base + base="$(basename "$file")" + (cd "$VENDOR" && sha256sum -c SHA256SUMS 2>/dev/null | grep -q "^${base}: OK") \ + || echo "WARN: SHA256 mismatch for ${base} — continuing" >&2 + fi +} + +extract() { + local tarball="$1" dir="$2" + verify_tarball "$tarball" + rm -rf "$dir" + tar xzf "$tarball" -C "$BUILD_DIR" +} + +apply_patches() { + local srcdir="$1" + shift + local patch failed=0 + for patch in "$@"; do + [[ -f "$patch" ]] || continue + echo "-- patch $(basename "$patch")" + if ! patch -d "$srcdir" -p1 -N -s -f < "$patch"; then + echo "error: patch failed: $(basename "$patch")" >&2 + failed=1 + fi + done + [[ "$failed" -eq 0 ]] +} + +ax25_link_flags() { + if [[ "$NEED_LIBAX25" -eq 1 ]]; then + export CPPFLAGS="-I${PREFIX}/include ${CPPFLAGS:-}" + export LDFLAGS="-L${PREFIX}/lib -Wl,-rpath,${PREFIX}/lib ${LDFLAGS:-}" + elif [[ -d /usr/include/netax25 ]]; then + export CPPFLAGS="-I/usr/include ${CPPFLAGS:-}" + export LDFLAGS="${LDFLAGS:-}" + fi +} + +build_libax25() { + local dir="${BUILD_DIR}/libax25-${LIBAX25_VER}" + echo "== libax25 ${LIBAX25_VER} -> ${PREFIX}" + extract "${VENDOR}/libax25-${LIBAX25_VER}.tar.gz" "$dir" + ( + cd "$dir" + ./configure --prefix="$PREFIX" + "${MAKE:-make}" -j"$(nproc 2>/dev/null || echo 2)" + "${MAKE:-make}" install + ) +} + +build_ax25_tools() { + local dir="${BUILD_DIR}/ax25-tools-${TOOLS_VER}" + echo "== ax25-tools ${TOOLS_VER} -> ${PREFIX}" + extract "${VENDOR}/ax25-tools-${TOOLS_VER}.tar.gz" "$dir" + ax25_link_flags + ( + cd "$dir" + ./configure --prefix="$PREFIX" + "${MAKE:-make}" -j"$(nproc 2>/dev/null || echo 2)" + "${MAKE:-make}" install + ) +} + +build_ax25_apps() { + local dir="${BUILD_DIR}/ax25-apps-${APPS_VER}" + echo "== ax25-apps ${APPS_VER} -> ${PREFIX}" + extract "${VENDOR}/ax25-apps-${APPS_VER}.tar.gz" "$dir" + apply_patches "$dir" "${PATCHES}/ax25-apps-0.0.8-termios.patch" + ax25_link_flags + ( + cd "$dir" + ./configure --prefix="$PREFIX" + "${MAKE:-make}" -j"$(nproc 2>/dev/null || echo 2)" + "${MAKE:-make}" install + ) +} + +[[ "$NEED_LIBAX25" -eq 1 ]] && build_libax25 +[[ "$NEED_TOOLS" -eq 1 ]] && build_ax25_tools +[[ "$NEED_APPS" -eq 1 ]] && build_ax25_apps + +echo "== AX.25 deps ready under ${PREFIX} ==" diff --git a/scripts/build-freebsd.sh b/scripts/build-freebsd.sh new file mode 100755 index 0000000..9c9ac6c --- /dev/null +++ b/scripts/build-freebsd.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# build-freebsd.sh — MAX25-Stack-Lite FreeBSD build (max25d, max25-bcpr, max25-terminal, CRDOP/OSS) +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +BUILD_TYPE="${CMAKE_BUILD_TYPE:-Release}" +BUILD_DIR="${BUILD_DIR:-build}" + +if [[ "$(uname -s)" != "FreeBSD" ]]; then + echo "build-freebsd.sh: FreeBSD only" >&2 + exit 1 +fi + +echo "== MAX25-Stack-Lite FreeBSD build -> ${BUILD_DIR} (${BUILD_TYPE}) ==" + +cmake -B "${BUILD_DIR}" \ + -DCMAKE_BUILD_TYPE="${BUILD_TYPE}" \ + -DMAX25_BUILD_TNCS=OFF \ + -DMAX25_BUILD_MAX25_BCPR=ON \ + -DMAX25_BUILD_CRDOP=ON \ + -DMAX25_BUILD_DAEMON=ON \ + -DMAX25_BUILD_TERMINAL=ON \ + -DMAX25_BUNDLE_AX25=OFF \ + "$@" + +cmake --build "${BUILD_DIR}" -j"$(sysctl -n hw.ncpu 2>/dev/null || echo 2)" + +echo "== build done: ${BUILD_DIR}/bin ==" diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..87c957d --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# MainAX25-Stack (MAX25-Stack) — configure and build via CMake. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +BUILD_TYPE="${CMAKE_BUILD_TYPE:-Release}" + +cmake -B build -DCMAKE_BUILD_TYPE="${BUILD_TYPE}" +cmake --build build -j"$(nproc 2>/dev/null || echo 2)" diff --git a/scripts/clean.sh b/scripts/clean.sh new file mode 100755 index 0000000..9447da2 --- /dev/null +++ b/scripts/clean.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# MainAX25-Stack (MAX25-Stack) — remove CMake build trees. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +rm -rf build stacks/crdop/build +echo "Removed build/ and stacks/crdop/build/" diff --git a/scripts/discover-plugins.sh b/scripts/discover-plugins.sh new file mode 100755 index 0000000..651b963 --- /dev/null +++ b/scripts/discover-plugins.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# discover-plugins.sh — list device backends (Lite: configured directly in max25d.ini, no plugins/ tree). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MANIFEST="${ROOT}/plugins/manifest.yaml" +JSON=0 + +usage() { + cat <<'EOF' +Usage: discover-plugins.sh [--json] [--type TYPE] [--id ID] + + --json Machine-readable output (one JSON object per line) + --type TYPE Filter: hardware | device + --id ID Show single plugin by id +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --json) JSON=1; shift ;; + --type) TYPE_FILTER="${2:-}"; shift 2 ;; + --id) ID_FILTER="${2:-}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage; exit 1 ;; + esac +done + +if [[ ! -f "$MANIFEST" ]]; then + echo "discover-plugins: no plugin manifest in MAX25-Stack-Lite — devices are set in max25d.ini directly (see README.md / max25-lite-setup)" >&2 + exit 1 +fi + +emit_plugin() { + local type="$1" id="$2" path="$3" extra="${4:-}" + local full="${ROOT}/plugins/${path}" + local yaml="${full}/plugin.yaml" + local label="" status="" hybbx="" + + if [[ -f "$yaml" ]]; then + label=$(grep -E '^label:' "$yaml" 2>/dev/null | head -1 | sed 's/^label:[[:space:]]*//' || true) + status=$(grep -E '^status:' "$yaml" 2>/dev/null | head -1 | sed 's/^status:[[:space:]]*//' || true) + hybbx=$(grep -E '^(hybbx_plugin|plugin):' "$yaml" 2>/dev/null | head -1 | sed 's/^[^:]*:[[:space:]]*//' || true) + fi + + if [[ -n "${TYPE_FILTER:-}" && "$type" != "$TYPE_FILTER" ]]; then return; fi + if [[ -n "${ID_FILTER:-}" && "$id" != "$ID_FILTER" ]]; then return; fi + + if [[ "$JSON" -eq 1 ]]; then + printf '{"type":"%s","id":"%s","path":"plugins/%s","label":"%s","status":"%s","hybbx":"%s"%s}\n' \ + "$type" "$id" "$path" "$label" "$status" "$hybbx" "$extra" + else + printf '%-14s %-18s %-30s %s\n' "$type" "$id" "plugins/$path" "${label:-—}" + fi +} + +if [[ "$JSON" -eq 0 && -z "${ID_FILTER:-}" ]]; then + printf '%-14s %-18s %-30s %s\n' "TYPE" "ID" "PATH" "LABEL" + printf '%s\n' "--------------------------------------------------------------------------------" +fi + +# Parse manifest sections (simple line-based — no PyYAML dependency) +section="" +while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%%#*}" + line="${line%"${line##*[![:space:]]}"}" + [[ -z "$line" ]] && continue + + case "$line" in + betriebsform:) section=""; continue ;; + hardware:) section=hardware; continue ;; + devices:) section=device; continue ;; + schema_version:*|stack:*|description:*) continue ;; + esac + + [[ -z "$section" ]] && continue + + if [[ "$line" =~ ^[[:space:]]*-[[:space:]]+id:[[:space:]]*(.+)$ ]]; then + cur_id="${BASH_REMATCH[1]}" + cur_path="" + continue + fi + if [[ -n "${cur_id:-}" && "$line" =~ ^[[:space:]]+path:[[:space:]]*(.+)$ ]]; then + cur_path="${BASH_REMATCH[1]}" + emit_plugin "$section" "$cur_id" "$cur_path" + cur_id="" + fi +done < "$MANIFEST" + +# Also scan for plugin.yaml not in manifest (future auto-gen) +while IFS= read -r yaml; do + dir="$(dirname "$yaml")" + rel="${dir#${ROOT}/plugins/}" + id="$(grep -E '^id:' "$yaml" | head -1 | sed 's/^id:[[:space:]]*//' || basename "$dir")" + type="$(grep -E '^type:' "$yaml" | head -1 | sed 's/^type:[[:space:]]*//' || echo unknown)" + # Skip if already emitted via manifest for same id+type would duplicate — acceptable for scaffold +done < <(find "${ROOT}/plugins" -name plugin.yaml -not -path '*/betriebsform/*' 2>/dev/null | sort) diff --git a/scripts/install-freebsd.sh b/scripts/install-freebsd.sh new file mode 100755 index 0000000..3cc89ed --- /dev/null +++ b/scripts/install-freebsd.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# install-freebsd.sh — MAX25-Stack-Lite FreeBSD build + install (daemon, max25-bcpr, terminal, CRDOP/OSS) +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PREFIX="${PREFIX:-/usr/local}" +ETC="${ETC:-${PREFIX}/etc/max25}" +INSTALL_DEPS=0 +BUILD_ONLY=0 +BUILD_DIR="${BUILD_DIR:-build}" + +usage() { + cat <&2; usage; exit 1 ;; + esac +done + +if [[ "$(uname -s)" != "FreeBSD" ]]; then + echo "install-freebsd.sh: FreeBSD only" >&2 + exit 1 +fi + +echo "== MAX25 install: $(uname -s) $(uname -m) → ${PREFIX} ==" + +if [[ "$INSTALL_DEPS" -eq 1 ]]; then + pkgs=(cmake python3 ncurses sox git gmake pkgconf) + echo "Installing packages: ${pkgs[*]}" + pkg install -y "${pkgs[@]}" +fi + +BUILD_DIR="${BUILD_DIR}" "${ROOT}/scripts/build-freebsd.sh" + +if [[ "$BUILD_ONLY" -eq 1 ]]; then + echo "== build done (no install) ==" + exit 0 +fi + +echo "-- install to ${PREFIX}" +if [[ "$(id -u)" -eq 0 ]]; then + cmake --install "${ROOT}/${BUILD_DIR}" --prefix "${PREFIX}" +else + sudo cmake --install "${ROOT}/${BUILD_DIR}" --prefix "${PREFIX}" +fi + +_install_ini() { + local src="$1" dst="$2" + if [[ ! -f "$dst" ]] && [[ -f "$src" ]]; then + mkdir -p "$(dirname "$dst")" + if [[ "$(id -u)" -eq 0 ]]; then + cp "$src" "$dst" + else + sudo cp "$src" "$dst" + fi + echo " installed $dst (from example)" + fi +} + +if [[ "$(id -u)" -eq 0 ]] || sudo -n true 2>/dev/null; then + _install_ini "${ROOT}/share/max25-bcpr/max25-bcpr.freebsd.ini.example" "${ETC}/max25-bcpr.ini" + _install_ini "${ROOT}/share/max25/max25d.lite.bcpr.ini.example" "${ETC}/max25d.ini" +fi + +echo "" +echo "== MAX25-Stack-Lite installed to ${PREFIX} ==" +echo " max25d, max25-terminal → ${PREFIX}/bin" +echo " max25-bcpr-ctl, max25-bcprd-init → ${PREFIX}/sbin" +echo " examples → ${PREFIX}/share/max25/" +echo "" +echo "Next steps (PC-COM / ExSys cuau2 — edit INI before live RF):" +echo " pw groupmod dialer -m \$USER" +echo " sudo mkdir -p /var/run/max25 ${ETC}" +echo " sudo chown \$USER:dialer /var/run/max25" +echo " # ${ETC}/max25-bcpr.ini: serial=/dev/cuau2 iobase=0xd090 irq=36" +echo " # ${ETC}/max25d.ini: [daemon] user=… group=dialer" +echo " sudo max25-bcpr-ctl start --ini ${ETC}/max25-bcpr.ini" +echo " sudo ${ROOT}/scripts/run-max25d.sh ${ETC}/max25d.ini" +echo " max25-terminal -H 127.0.0.1 -p 7325 -d max25e0" +echo "" +echo "Optional: CRDOP/OSS → ${PREFIX}/share/crdop/crdop.freebsd.ini.example" +echo "" diff --git a/scripts/install-max25.sh b/scripts/install-max25.sh new file mode 100755 index 0000000..44e3eb4 --- /dev/null +++ b/scripts/install-max25.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# install-max25.sh — MAX25-Stack-Lite build and install on Linux (daemon, bcpr, terminal, tests). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PREFIX="${PREFIX:-/usr/local}" +INSTALL_DEPS=0 +BUILD_ONLY=0 +BUILD_DIR="${BUILD_DIR:-build}" + +usage() { + cat <&2; usage; exit 1 ;; + esac +done + +if [[ "$(uname -s)" != "Linux" ]]; then + echo "install-max25.sh: Linux only" >&2 + exit 1 +fi + +echo "== MAX25 install: $(uname -s) $(uname -m) → ${PREFIX} ==" + +if [[ "$INSTALL_DEPS" -eq 1 ]]; then + if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update + pkgs=(build-essential make cmake pkg-config git python3 libncurses-dev libasound2-dev) + sudo apt-get install -y "${pkgs[@]}" || true + fi +fi + +cd "$ROOT" +echo "-- cmake (BCPR+TERMINAL+DAEMON ON — max25-bcpr default)" +cmake -B "${BUILD_DIR}" \ + -DCMAKE_BUILD_TYPE=Release \ + -DMAX25_BUILD_MAX25_BCPR=ON \ + -DMAX25_BUILD_TERMINAL=ON \ + -DMAX25_BUILD_DAEMON=ON \ + -DMAX25_BUNDLE_AX25=OFF +cmake --build "${BUILD_DIR}" -j"$(nproc 2>/dev/null || echo 2)" + +if [[ "$BUILD_ONLY" -eq 1 ]]; then + echo "== build done (no install) ==" + exit 0 +fi + +echo "-- install to ${PREFIX}" +if [[ "$(id -u)" -eq 0 ]]; then + cmake --install "${BUILD_DIR}" --prefix "$PREFIX" +else + sudo cmake --install "${BUILD_DIR}" --prefix "$PREFIX" +fi + +echo "" +echo "== MAX25 installed to ${PREFIX} ==" +echo " max25d, kiss_bridge.py, max25-terminal, max25-ctl -> ${PREFIX}/bin" +echo " max25-bcpr-ctl -> ${PREFIX}/sbin (when MAX25_BUILD_MAX25_BCPR=ON)" +echo " examples -> ${PREFIX}/share/max25/" +echo "" + +echo "" +echo "Next steps (serial TNC / USB KISS):" +echo " sudo usermod -aG dialout \$USER # serial access" +echo " sudo cp share/max25/max25d.lite.ini.example /etc/max25/max25d.ini" +echo " sudo max25d -c /etc/max25/max25d.ini" +echo " max25-terminal -U /run/max25/modem.sock" +echo "" +echo "BayCom/based (max25-bcpr): built by default (MAX25_BUILD_MAX25_BCPR=ON)." +echo " See stacks/max25-bcpr/README.md · share/max25-bcpr/max25-bcpr.ini.example" +echo "" +ETC="${ETC:-/etc/max25}" +if [[ "$(id -u)" -eq 0 ]] || sudo -n true 2>/dev/null; then + _install_ini() { + local src="$1" dst="$2" + if [[ ! -f "$dst" ]] && [[ -f "$src" ]]; then + mkdir -p "$ETC" + if [[ "$(id -u)" -eq 0 ]]; then + cp "$src" "$dst" + else + sudo cp "$src" "$dst" + fi + echo " installed $dst (from example)" + fi + } + _install_ini "$ROOT/share/max25-bcpr/max25-bcpr.ini.example" "$ETC/max25-bcpr.ini" + if [[ -f "$ETC/bcpr.ini" ]] && [[ ! -L "$ETC/max25-bcpr.ini" ]]; then + echo " note: legacy $ETC/bcpr.ini kept — paths /tmp/bcpr auto-map to /tmp/max25-bcpr at runtime" + fi +fi diff --git a/scripts/max25-ctl b/scripts/max25-ctl new file mode 100755 index 0000000..473b4a2 --- /dev/null +++ b/scripts/max25-ctl @@ -0,0 +1,408 @@ +#!/usr/bin/env bash +# max25-ctl — MAX25-Stack-Lite control entry point (Packet Radio / AX.25, localhost). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PREFIX="" +ROOT="" + +if [[ -f "${SCRIPT_DIR}/../plugins/manifest.yaml" ]]; then + ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +elif [[ -d "${SCRIPT_DIR}/../share/max25" ]]; then + PREFIX="$(cd "${SCRIPT_DIR}/.." && pwd)" + ROOT="${MAX25_ROOT:-${PREFIX}}" +else + ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +fi + +stack_script() { + local rel="$1" + if [[ -f "${ROOT}/stacks/${rel}" ]]; then + echo "${ROOT}/stacks/${rel}" + return 0 + fi + if [[ -n "${PREFIX}" && -f "${PREFIX}/share/max25/stacks/${rel}" ]]; then + echo "${PREFIX}/share/max25/stacks/${rel}" + return 0 + fi + return 1 +} + +resolve_crdop_bin() { + if [[ -n "${CRDOP_BIN:-}" && -x "${CRDOP_BIN}" ]]; then + echo "${CRDOP_BIN}" + return 0 + fi + if command -v crdopc >/dev/null 2>&1; then + command -v crdopc + return 0 + fi + if [[ -x "${ROOT}/build/bin/crdopc" ]]; then + echo "${ROOT}/build/bin/crdopc" + return 0 + fi + return 1 +} + +resolve_baycom_ctl() { + local ctl="" + ctl="$(stack_script "baycom-pr/scripts/baycom-pr-ctl" 2>/dev/null || true)" + if [[ -n "${ctl}" && -x "${ctl}" ]]; then + echo "${ctl}" + return 0 + fi + if command -v baycom-pr-ctl >/dev/null 2>&1; then + command -v baycom-pr-ctl + return 0 + fi + return 1 +} + +MODE="${MAX25_MODE:-standalone}" +HARDWARE="" +DEVICE="" +BAYCOM_INI="" +BAYCOM_PROFILE="" +ACTION="${1:-help}" +shift || true + +usage() { + cat < [options] + +Commands: + help This help + discover List local device backends + build Build the project (cmake -B build && cmake --build build) + start Start a hardware/soft-modem path + stop Stop a hardware/soft-modem path + status Show device status + test Offline tests (no root) + +Daemon (Linux): + stacks/daemon/max25d Launcher (ps shows this path, not python3) + stacks/daemon/max25d.py M25/1 daemon implementation + stacks/terminal/max25-terminal Operator UI (F10 menu, CALLERID/CALLID) + +Options (start/stop/status): + --hardware HW modems | soft-modems + --device DEV max25e0 (BayCom/based) | baycom-kiss | soft-crdop + +Examples: + max25-ctl discover + max25-ctl build + max25-ctl start --hardware modems --device max25e0 + max25-ctl start --hardware modems --device baycom-kiss + max25-ctl start --hardware soft-modems --device soft-crdop + max25-ctl test + +Lite scope (max25-terminal, max25d, BayCom/based, CRDOP): README.md + SET DEVICE → CONNECT → SEND via max25-terminal after max25d is up +EOF +} + +parse_opts() { + while [[ $# -gt 0 ]]; do + case "$1" in + --mode) MODE="$2"; shift 2 ;; + --hardware) HARDWARE="$2"; shift 2 ;; + --device) DEVICE="$2"; shift 2 ;; + --baycom-ini) BAYCOM_INI="$2"; shift 2 ;; + --baycom-profile) BAYCOM_PROFILE="$2"; shift 2 ;; + *) echo "Unknown option: $1" >&2; usage; exit 1 ;; + esac + done +} + +resolve_baycom_ini_path() { + local device="${1:-max25e0}" + local explicit="${2:-${BAYCOM_INI:-}}" + local profile="${3:-${BAYCOM_PROFILE:-}}" + + if [[ -z "${explicit}" && -n "${profile}" ]]; then + local paths_py="" + if [[ -f "${ROOT}/stacks/daemon/paths.py" ]]; then + paths_py="${ROOT}/stacks/daemon/paths.py" + elif [[ -n "${PREFIX}" && -f "${PREFIX}/bin/paths.py" ]]; then + paths_py="${PREFIX}/bin/paths.py" + fi + if [[ -n "${paths_py}" ]]; then + local prefix_py="None" + if [[ -n "${PREFIX}" ]]; then + prefix_py="Path('${PREFIX}')" + fi + explicit="$(python3 -c " +import sys +from pathlib import Path +sys.path.insert(0, '$(dirname "${paths_py}")') +from paths import resolve_baycom_profile +p = resolve_baycom_profile('${profile}', '${device}', Path('${ROOT}'), ${prefix_py}) +print(p or '') +" 2>/dev/null || true)" + if [[ -z "${explicit}" ]]; then + echo "baycom profile '${profile}' not found (use --baycom-ini PATH)" >&2 + return 1 + fi + elif [[ "${profile}" == "dual" ]]; then + for candidate in \ + "${ROOT}/stacks/baycom-pr/config/examples/baycom-pr.dual.ini" \ + "${PREFIX}/share/max25/stacks/baycom-pr/config/examples/baycom-pr.dual.ini"; do + if [[ -f "${candidate}" ]]; then + explicit="${candidate}" + break + fi + done + if [[ -z "${explicit}" ]]; then + echo "dual baycom-pr.ini not found — copy stacks/baycom-pr/config/examples/baycom-pr.dual.ini" >&2 + return 1 + fi + fi + fi + + local paths_py="" + if [[ -f "${ROOT}/stacks/daemon/paths.py" ]]; then + paths_py="${ROOT}/stacks/daemon/paths.py" + elif [[ -n "${PREFIX}" && -f "${PREFIX}/bin/paths.py" ]]; then + paths_py="${PREFIX}/bin/paths.py" + fi + if [[ -n "${paths_py}" ]]; then + local resolved="" + local prefix_py="None" + if [[ -n "${PREFIX}" ]]; then + prefix_py="Path('${PREFIX}')" + fi + resolved="$(python3 -c " +import sys +from pathlib import Path +sys.path.insert(0, '$(dirname "${paths_py}")') +from paths import resolve_baycom_ini +p = resolve_baycom_ini('${device}', Path('${ROOT}'), ${prefix_py}, '''${explicit}''') +print(p or '') +" 2>/dev/null || true)" + if [[ -n "${resolved}" && -f "${resolved}" ]]; then + echo "${resolved}" + return 0 + fi + fi + + if [[ -n "${explicit}" ]]; then + return 1 + fi + + local candidate="" + for candidate in \ + "/etc/baycom/baycom-pr.ini" \ + "/etc/baycom/baycom-pr-single.ini" \ + "${ROOT}/local/baycom-pr.ini" \ + "${ROOT}/share/baycom/baycom-pr.pccom-ttyS0-only.ini.example" \ + "${PREFIX}/share/max25/baycom/baycom-pr.pccom-ttyS0-only.ini.example" \ + "${ROOT}/stacks/baycom-pr/config/baycom-pr.ini"; do + if [[ -f "${candidate}" ]]; then + if [[ "${candidate}" == "/etc/baycom/baycom-pr.ini" ]]; then + local dual="" + dual="$(python3 -c " +from pathlib import Path +import sys +sys.path.insert(0, '${ROOT}/stacks/daemon') +try: + from paths import is_dual_baycom_ini + print('dual' if is_dual_baycom_ini(Path('/etc/baycom/baycom-pr.ini')) else 'single') +except ImportError: + print('single') +" 2>/dev/null || echo "single")" + if [[ "${dual}" == "dual" ]]; then + continue + fi + fi + echo "${candidate}" + return 0 + fi + done + return 1 +} + +start_baycom_kernel() { + # Kernel baycom-pr removed 2026-07-18 — product path is bcpr. + echo "BayCom/based uses max25-bcpr (not old kernel BayCom)." >&2 + echo "Start PC-COM first: sudo max25-bcpr-ctl start --ini /etc/max25/max25-bcpr.ini" >&2 + echo "Then max25d — see stacks/max25-bcpr/README.md" >&2 + echo "Frozen archive: MASTER vault archives/legacy/max25-stack-baycom-kernel-compa/" >&2 + exit 1 +} + +cmd_discover() { + if [[ ! -f "${ROOT}/scripts/discover-plugins.sh" ]]; then + echo "discover: source checkout required (set MAX25_ROOT)" >&2 + exit 1 + fi + bash "${ROOT}/scripts/discover-plugins.sh" "$@" +} + +cmd_build() { + if [[ ! -f "${ROOT}/scripts/build.sh" ]]; then + echo "build: source checkout required (set MAX25_ROOT)" >&2 + exit 1 + fi + bash "${ROOT}/scripts/build.sh" +} + +cmd_start() { + parse_opts "$@" + case "$HARDWARE" in + tncs) + case "$DEVICE" in + tnc2c|"") + local boot="" + boot="$(stack_script "tncs/tnc2c-boot-wait.sh")" + if [[ "${MAX25_TNC_PREP:-}" == "recover" ]]; then + echo "TNC recover-only (software ladder, no power cycle)" + exec "${boot}" --recover-only + fi + echo "TNC boot-wait rescue: power-cycle TNC while script runs if needed" + exec "${boot}" + ;; + pktnc2) + local boot="" + boot="$(stack_script "tncs/pktnc2-boot-wait.sh")" + if [[ "${MAX25_TNC_PREP:-}" == "recover" ]]; then + echo "PK-TNC2 recover-only (software ladder, no power cycle)" + exec "${boot}" --recover-only + fi + exec "${boot}" + ;; + *) echo "Unknown TNC device: $DEVICE" >&2; exit 1 ;; + esac + ;; + modems) + case "$DEVICE" in + baycom-kiss|pccom-kiss) + echo "baycom-kiss: USB/async KISS — no kernel stack start" + echo "Configure max25d [devices] baycom-kiss and [serial.baycom-kiss]; max25d opens serial directly." + exit 0 + ;; + bcpr|max25e0|max25e0:bc0|max25e0:bc1|"") + echo "BayCom/based PC-COM: sudo max25-bcpr-ctl start -c /etc/max25/max25-bcpr.ini" >&2 + echo "Then: ./scripts/run-max25d.sh — see stacks/max25-bcpr/README.md" >&2 + echo "Canonical: scripts/run-max25d.sh" >&2 + exit 1 + ;; + baycom-par96|baycom-ser12|baycom-a|baycom-b) + start_baycom_kernel "${DEVICE}" + ;; + *) + echo "Unknown modem device: $DEVICE (use max25e0 / baycom-kiss)" >&2 + exit 1 + ;; + esac + ;; + soft-modems) + case "$DEVICE" in + soft-crdop|"") + local crdop_bin="" + crdop_bin="$(resolve_crdop_bin)" || { + if [[ -f "${ROOT}/scripts/build.sh" ]]; then + echo "Building CRDOP..." + bash "${ROOT}/scripts/build.sh" + crdop_bin="$(resolve_crdop_bin)" || true + fi + } + if [[ -z "${crdop_bin}" ]]; then + echo "crdopc not found in PATH or build/bin" >&2 + exit 1 + fi + local launcher="" + launcher="$(stack_script "crdop/scripts/crdopc" 2>/dev/null || true)" + if [[ -z "${launcher}" ]]; then + echo "Starting crdopc (HyBBX crdop plugin attaches to TCP :8515)" + exec env CRDOP_BIN="${crdop_bin}" crdopc + fi + echo "Starting crdopc (HyBBX crdop plugin attaches to TCP :8515)" + CRDOP_BIN="${crdop_bin}" exec "${launcher}" + ;; + *) + echo "Unknown soft-modem device: $DEVICE" >&2 + exit 1 + ;; + esac + ;; + "") + echo "Specify --hardware modems|soft-modems" >&2 + exit 1 + ;; + *) + echo "Unknown hardware: $HARDWARE" >&2 + exit 1 + ;; + esac +} + +cmd_stop() { + parse_opts "$@" + if [[ "$HARDWARE" == "modems" || -z "$HARDWARE" ]]; then + case "$DEVICE" in + baycom-kiss) + echo "baycom-kiss: no baycom-pr-ctl stop (USB serial owned by max25d)" + ;; + *) + local baycom_ctl="" + baycom_ctl="$(resolve_baycom_ctl 2>/dev/null || true)" + if [[ -n "${baycom_ctl}" ]]; then + local ini="" + ini="$(resolve_baycom_ini_path "${DEVICE:-baycom-ser12}" "${BAYCOM_INI}" 2>/dev/null || true)" + if [[ -n "${ini}" ]]; then + sudo "${baycom_ctl}" -c "${ini}" stop 2>/dev/null || true + else + sudo "${baycom_ctl}" stop 2>/dev/null || true + fi + fi + ;; + esac + fi + echo "TNC serial: release port (stop other userspace serial owner; fuser on device)" +} + +cmd_status() { + parse_opts "$@" + echo "MAX25 mode: $MODE" + if [[ -f "${ROOT}/scripts/discover-plugins.sh" ]]; then + bash "${ROOT}/scripts/discover-plugins.sh" --type device || true + else + echo "discover: source checkout required (set MAX25_ROOT)" + fi + local baycom_ctl="" + baycom_ctl="$(resolve_baycom_ctl 2>/dev/null || true)" + if [[ -n "${baycom_ctl}" && "${DEVICE:-}" != "baycom-kiss" ]]; then + local ini="" + ini="$(resolve_baycom_ini_path "${DEVICE:-baycom-ser12}" "${BAYCOM_INI}" 2>/dev/null || true)" + if [[ -n "${ini}" ]]; then + sudo "${baycom_ctl}" -c "${ini}" status 2>/dev/null || echo "baycom-pr: not running or no root" + else + sudo "${baycom_ctl}" status 2>/dev/null || echo "baycom-pr: not running or no root" + fi + fi +} + +cmd_test() { + if [[ ! -d "${ROOT}/build" ]]; then + if [[ ! -f "${ROOT}/scripts/build.sh" ]]; then + echo "test: source checkout with build/ required (set MAX25_ROOT)" >&2 + exit 1 + fi + bash "${ROOT}/scripts/build.sh" + fi + cmake --build "${ROOT}/build" --target max25_test +} + +case "$ACTION" in + help|-h|--help) usage ;; + discover) cmd_discover "$@" ;; + build) cmd_build ;; + start) cmd_start "$@" ;; + stop) cmd_stop "$@" ;; + status) cmd_status "$@" ;; + test) cmd_test ;; + *) echo "Unknown command: $ACTION" >&2; usage; exit 1 ;; +esac diff --git a/scripts/max25-lite-setup.sh b/scripts/max25-lite-setup.sh new file mode 100755 index 0000000..412d83e --- /dev/null +++ b/scripts/max25-lite-setup.sh @@ -0,0 +1,517 @@ +#!/usr/bin/env bash +# MAX25-Stack-Lite — semi-automatic setup wizard (dialog / whiptail) +# Detects serial + audio devices, writes max25d.ini (+ crdop.ini for CRDOP path). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +UI_TITLE="MAX25-Stack-Lite setup" +UI_BACKTITLE="MAX25-Stack-Lite | local packet-radio setup" +UI_WIDTH=72 +UI_HEIGHT=18 +TMP_FILES=() + +UI="" +if command -v whiptail >/dev/null 2>&1; then + UI=whiptail +elif command -v dialog >/dev/null 2>&1; then + UI=dialog +else + echo "max25-lite-setup: install whiptail or dialog, then run this command again." >&2 + exit 1 +fi + +cleanup() { + local file + for file in "${TMP_FILES[@]}"; do + rm -f -- "$file" + done +} +trap cleanup EXIT HUP INT TERM + +require_terminal_size() { + local columns lines + [[ -t 1 && -t 2 ]] || return 0 + columns="$(tput cols 2>/dev/null || true)" + lines="$(tput lines 2>/dev/null || true)" + if [[ "$columns" =~ ^[0-9]+$ && "$lines" =~ ^[0-9]+$ ]] \ + && ((columns < UI_WIDTH || lines < UI_HEIGHT)); then + printf '%s\n' \ + "max25-lite-setup: terminal is ${columns}x${lines}; need at least ${UI_WIDTH}x${UI_HEIGHT}." \ + "Resize the terminal and run max25-lite-setup again." >&2 + exit 1 + fi +} + +show_help() { + "$UI" --backtitle "$UI_BACKTITLE" --title "Setup help" --ok-label "Back" \ + --msgbox "Use Up/Down to move, Space to mark a radio-list choice, and Enter to continue. + +Cancel or Esc stops the wizard without writing configuration files. + +The wizard writes local-only configuration. It does not start the modem, key a radio, or transmit. + +For serial and BayCom/based paths, select the device connected to this computer. For CRDOP, select the local capture/playback device." \ + "$UI_HEIGHT" "$UI_WIDTH" +} + +msgbox() { + "$UI" --backtitle "$UI_BACKTITLE" --title "$UI_TITLE" --ok-label "Continue" \ + --msgbox "$1" 14 "$UI_WIDTH" +} + +yesno() { + local prompt="$1" rc + while :; do + if "$UI" --backtitle "$UI_BACKTITLE" --title "$UI_TITLE" \ + --yes-label "Yes" --no-label "No" --help-button --help-label "Help" \ + --yesno "$prompt" 10 "$UI_WIDTH"; then + return 0 + else + rc=$? + fi + if [[ "$rc" -eq 2 ]]; then + show_help + continue + fi + return "$rc" + done +} + +inputbox() { + local prompt="$1" default="${2:-}" value rc + while :; do + if value=$("$UI" --backtitle "$UI_BACKTITLE" --title "$UI_TITLE" \ + --ok-label "Continue" --cancel-label "Cancel" --help-button --help-label "Help" \ + --inputbox "$prompt" 10 "$UI_WIDTH" "$default" 3>&1 1>&2 2>&3); then + printf '%s\n' "$value" + return 0 + else + rc=$? + fi + if [[ "$rc" -eq 2 ]]; then + show_help + continue + fi + return "$rc" + done +} + +menu() { + local prompt="$1" value rc + shift + while :; do + if value=$("$UI" --backtitle "$UI_BACKTITLE" --title "$UI_TITLE" \ + --ok-label "Continue" --cancel-label "Cancel" --help-button --help-label "Help" \ + --menu "$prompt" "$UI_HEIGHT" "$UI_WIDTH" 10 "$@" 3>&1 1>&2 2>&3); then + printf '%s\n' "$value" + return 0 + else + rc=$? + fi + if [[ "$rc" -eq 2 ]]; then + show_help + continue + fi + return "$rc" + done +} + +radiolist() { + local prompt="$1" value rc + shift + while :; do + if value=$("$UI" --backtitle "$UI_BACKTITLE" --title "$UI_TITLE" \ + --ok-label "Continue" --cancel-label "Cancel" --help-button --help-label "Help" \ + --radiolist "$prompt" "$UI_HEIGHT" "$UI_WIDTH" 10 "$@" 3>&1 1>&2 2>&3); then + printf '%s\n' "$value" + return 0 + else + rc=$? + fi + if [[ "$rc" -eq 2 ]]; then + show_help + continue + fi + return "$rc" + done +} + +require_terminal_size + +list_serial_ports() { + local -a ports=() + shopt -s nullglob + for pat in /dev/ttyUSB* /dev/ttyACM* /dev/ttyS*; do + [[ -e "$pat" ]] || continue + ports+=("$pat") + done + shopt -u nullglob + if ((${#ports[@]} == 0)); then + ports=("/dev/ttyUSB0") + fi + printf '%s\n' "${ports[@]}" +} + +list_alsa_cards() { + if command -v arecord >/dev/null 2>&1; then + arecord -l 2>/dev/null | awk '/card [0-9]+:/ {c=$2; gsub(":", "", c); desc=$0; gsub(/^.*card [0-9]+: /, "", desc); gsub(/,.*/, "", desc); printf "hw:%s,0 %s\n", c, desc}' || true + fi + if command -v aplay >/dev/null 2>&1; then + aplay -l 2>/dev/null | awk '/card [0-9]+:/ {c=$2; gsub(":", "", c); desc=$0; gsub(/^.*card [0-9]+: /, "", desc); gsub(/,.*/, "", desc); printf "hw:%s,0 %s (playback)\n", c, desc}' || true + fi +} + +msgbox "Welcome to MAX25-Stack-Lite setup. + +This wizard detects serial and audio devices on this computer, writes max25d.ini, and shows how to start max25d and max25-terminal locally. + +It changes files only after you choose a save location. Use Help for key hints, or Cancel to leave without saving." + +MODEM_KIND="$(menu "Packet radio modem path:" \ + serial "Serial TNC / USB KISS (T-Modem, TNC2)" \ + baycom "BayCom/based PC-COM (SER12 on COM/ttyS)" \ + crdop "CRDOP sound-card soft modem (MAX25-SoftModem)" \ + )" || exit 0 + +DEVICE_ID="tnc2c" +HARDWARE="tncs" +SERIAL_PATH="" +CRDOP_CAPTURE="" +CRDOP_PLAYBACK="" +WRITE_CRDOP_INI=0 +WRITE_BCPR_INI=0 +BCPR_INI_DEST="" + +if [[ "$MODEM_KIND" == "baycom" ]]; then + DEVICE_ID="max25e0" + HARDWARE="tncs" + WRITE_BCPR_INI=1 + mapfile -t PORTS < <(list_serial_ports) + menu_args=() + idx=0 + for p in "${PORTS[@]}"; do + menu_args+=("$p" "serial port" "$((idx == 0 ? 1 : 0))") + idx=$((idx + 1)) + done + SERIAL_PATH="$(radiolist "Select PC-COM serial port (often /dev/ttyS0):" "${menu_args[@]}")" || exit 0 +elif [[ "$MODEM_KIND" == "serial" ]]; then + mapfile -t PORTS < <(list_serial_ports) + menu_args=() + idx=0 + for p in "${PORTS[@]}"; do + menu_args+=("$p" "serial port" "$((idx == 0 ? 1 : 0))") + idx=$((idx + 1)) + done + SERIAL_PATH="$(radiolist "Select serial device:" "${menu_args[@]}")" || exit 0 + if [[ "$SERIAL_PATH" == /dev/ttyACM* ]]; then + if yesno "Device looks like USB KISS (T-Modem). Use device id tmodem instead of tnc2c?"; then + DEVICE_ID="tmodem" + fi + fi +else + HARDWARE="soft-modems" + DEVICE_ID="soft-crdop" + WRITE_CRDOP_INI=1 + mapfile -t ALSA_LINES < <(list_alsa_cards) + if ((${#ALSA_LINES[@]} == 0)); then + msgbox "No ALSA cards listed (arecord/aplay). You can edit ~/.config/crdop/crdop.ini later." + CRDOP_CAPTURE="hw:0,0" + CRDOP_PLAYBACK="hw:0,0" + else + menu_args=() + idx=0 + for line in "${ALSA_LINES[@]}"; do + dev="${line%% *}" + label="${line#* }" + menu_args+=("$dev" "$label" "$((idx == 0 ? 1 : 0))") + idx=$((idx + 1)) + done + sel="$(radiolist "Select capture/playback ALSA device (hw:N,0):" "${menu_args[@]}")" || exit 0 + CRDOP_CAPTURE="$sel" + CRDOP_PLAYBACK="$sel" + fi +fi + +CALLERID="$(inputbox "Your AX.25 caller ID (e.g. CB-0):" "CB-0")" || exit 0 +CALLID="$(inputbox "Default destination callsign for CONNECT tests:" "QST")" || exit 0 + +if yesno "Write system config /etc/max25/max25d.ini? (No = ./local/max25d.ini in repo)"; then + INI_DEST="/etc/max25/max25d.ini" + CRDOP_INI_DEST="${HOME}/.config/crdop/crdop.ini" + BCPR_INI_DEST="/etc/max25/max25-bcpr.ini" + NEED_SUDO=1 +else + INI_DEST="${ROOT}/local/max25d.ini" + CRDOP_INI_DEST="${ROOT}/local/crdop.ini" + BCPR_INI_DEST="${ROOT}/local/max25-bcpr.ini" + NEED_SUDO=0 +fi + +if [[ "$NEED_SUDO" -eq 1 ]]; then + if ! command -v sudo >/dev/null 2>&1; then + msgbox "System configuration needs sudo, but sudo is not available. + +Choose No at the save-location screen to write local configuration in this repository." + exit 1 + fi + sudo mkdir -p "$(dirname "$INI_DEST")" + mkdir -p "$(dirname "$CRDOP_INI_DEST")" +else + mkdir -p "$(dirname "$INI_DEST")" +fi + +write_max25d_ini() { + local dest="$1" + if [[ "$MODEM_KIND" == "baycom" ]]; then + cat >"$dest" <"$dest" <"$dest" <"$dest" <"$dest" </dev/null 2>&1; } +have_screen() { command -v screen >/dev/null 2>&1; } + +pick_backend() { + case "${1:-$BACKEND}" in + tmux) + if ! have_tmux; then + echo "max25d-session: tmux not installed" >&2 + exit 1 + fi + echo tmux + ;; + screen) + if ! have_screen; then + echo "max25d-session: screen not installed" >&2 + exit 1 + fi + echo screen + ;; + *) + echo "max25d-session: unknown backend '$1' (use tmux or screen)" >&2 + exit 1 + ;; + esac +} + +session_running() { + local b="$1" + case "$b" in + tmux) tmux has-session -t "$SESSION_NAME" 2>/dev/null ;; + screen) screen -ls | grep -q "[[:space:]]*[0-9]*\.${SESSION_NAME}[[:space:]]" ;; + esac +} + +cmd_start() { + local b + b="$(pick_backend "$BACKEND")" + if session_running "$b"; then + echo "max25d-session: session '$SESSION_NAME' already running ($b)" >&2 + echo " attach: max25d-session attach --$b" >&2 + exit 1 + fi + local cmd="${MAX25D_BIN} -c ${CONFIG}" + case "$b" in + tmux) + tmux new-session -d -s "$SESSION_NAME" \ + "${MAX25D_BIN} -c ${CONFIG}; echo; echo max25d exited \$?; read -r _" + ;; + screen) + screen -dmS "$SESSION_NAME" bash -lc "${cmd}" + ;; + esac + echo "max25d-session: started ($b) name=$SESSION_NAME config=$CONFIG" + echo "max25d-session: attach with: max25d-session attach --$b" +} + +cmd_attach() { + local b + b="$(pick_backend "$BACKEND")" + if ! session_running "$b"; then + echo "max25d-session: no session '$SESSION_NAME' ($b)" >&2 + exit 1 + fi + case "$b" in + tmux) exec tmux attach -t "$SESSION_NAME" ;; + screen) exec screen -r "$SESSION_NAME" ;; + esac +} + +cmd_status() { + local b + b="$(pick_backend "$BACKEND")" + if session_running "$b"; then + echo "max25d-session: running backend=$b name=$SESSION_NAME" + case "$b" in + tmux) tmux list-panes -t "$SESSION_NAME" -F 'pane #{pane_index}: #{pane_current_command}' 2>/dev/null || true ;; + screen) screen -ls | grep "${SESSION_NAME}" || true ;; + esac + else + echo "max25d-session: not running (backend=$b name=$SESSION_NAME)" + fi + if command -v pgrep >/dev/null 2>&1 && pgrep -x max25d >/dev/null 2>&1; then + echo "max25d-session: max25d process: $(pgrep -xa max25d || true)" + fi +} + +cmd_stop() { + local b + b="$(pick_backend "$BACKEND")" + if ! session_running "$b"; then + echo "max25d-session: not running" >&2 + exit 0 + fi + case "$b" in + tmux) tmux kill-session -t "$SESSION_NAME" ;; + screen) screen -S "$SESSION_NAME" -X quit ;; + esac + echo "max25d-session: stopped ($b) name=$SESSION_NAME" +} + +CMD="${1:-}" +shift || true +while [[ $# -gt 0 ]]; do + case "$1" in + --tmux) BACKEND=tmux; shift ;; + --screen) BACKEND=screen; shift ;; + -c|--config) + CONFIG="${2:?config path required}" + shift 2 + ;; + -h|--help) usage; exit 0 ;; + *) echo "max25d-session: unknown option: $1" >&2; usage >&2; exit 1 ;; + esac +done + +case "$CMD" in + start) cmd_start ;; + attach) cmd_attach ;; + status) cmd_status ;; + stop) cmd_stop ;; + ""|-h|--help) usage ;; + *) echo "max25d-session: unknown command: $CMD" >&2; usage >&2; exit 1 ;; +esac diff --git a/scripts/release-check.sh b/scripts/release-check.sh new file mode 100755 index 0000000..223c6b6 --- /dev/null +++ b/scripts/release-check.sh @@ -0,0 +1,258 @@ +#!/usr/bin/env bash +# release-check.sh — MAX25-Stack-Lite offline release gates (no root, no live RF TX). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +PASS=0 +FAIL=0 +WARN=0 + +ok() { echo "OK: $*"; PASS=$((PASS + 1)); } +fail() { echo "FAIL: $*" >&2; FAIL=$((FAIL + 1)); } +warn() { echo "WARN: $*"; WARN=$((WARN + 1)); } + +echo "======== MAX25-Stack-Lite release-check ($(tr -d '[:space:]' < VERSION 2>/dev/null || echo '?')) ========" + +# --- version --- +if [[ -f VERSION ]]; then + VER="$(tr -d '[:space:]' < VERSION)" + if [[ "$VER" == "1.8.0" ]]; then + ok "VERSION=${VER}" + elif [[ -n "$VER" ]]; then + fail "VERSION=${VER} (expected 1.8.0)" + else + fail "VERSION file empty" + fi +else + fail "VERSION file missing" +fi + +# --- required docs (Lite ships a stub docs/, depth lives in the research vault) --- +for doc in README.md CONTRIBUTING.md CHANGELOG.md docs/README.md local/README.md stacks/daemon/README.md stacks/max25-bcpr/README.md include/max25/protocol.md include/max25/packet-radio.md; do + [[ -f "$doc" ]] && ok "doc $doc" || fail "missing $doc" +done + +# --- scripts present + executable (Lite scope only) --- +for script in scripts/max25-lite-setup.sh scripts/test-wizard.sh scripts/run-max25d.sh scripts/run-max25-terminal.sh scripts/max25-ctl scripts/discover-plugins.sh scripts/build.sh scripts/build-all.sh scripts/clean.sh scripts/test.sh scripts/release-check.sh scripts/tx-rx-test.sh scripts/terminology-check.sh scripts/install-max25.sh; do + if [[ -x "$script" ]]; then + ok "executable $script" + else + fail "not executable: $script" + fi +done + +# --- public terminology gate (§0.16 / §0.19) --- +if [[ -x scripts/terminology-check.sh ]]; then + if scripts/terminology-check.sh; then + ok "terminology-check" + else + fail "terminology-check" + fi +else + fail "scripts/terminology-check.sh missing or not executable" +fi + +# --- daemon core files --- +if [[ -x stacks/daemon/max25d ]] && [[ -f stacks/daemon/max25d.py ]]; then + ok "executable stacks/daemon/max25d launcher + max25d.py" +else + fail "stacks/daemon/max25d launcher or max25d.py missing" +fi +for f in kiss_bridge.py ax25_codec.py privilege_drop.py; do + [[ -f "stacks/daemon/${f}" ]] && ok "stacks/daemon/${f}" || fail "missing stacks/daemon/${f}" +done +if python3 stacks/daemon/test_ax25_codec.py >/dev/null 2>&1; then + ok "ax25_codec unit tests" +else + fail "ax25_codec unit tests" +fi + +# --- Lite INI examples (localhost-only model) --- +for ini in share/max25/max25d.ini.example share/max25/max25d.lite.ini.example share/max25/max25d.lite.bcpr.ini.example share/max25/max25d.lite.crdop.ini.example share/max25-bcpr/max25-bcpr.ini.example stacks/max25-bcpr/share/max25-bcpr.ini.example stacks/crdop/share/crdop.ini.example; do + [[ -f "$ini" ]] && ok "ini $ini" || fail "missing $ini" +done + +# --- localhost TCP in shipped max25d examples --- +for ini in share/max25/max25d.ini.example share/max25/max25d.lite.ini.example share/max25/max25d.lite.bcpr.ini.example share/max25/max25d.lite.crdop.ini.example; do + if grep -Eq '^[[:space:]]*tcp_host[[:space:]]*=[[:space:]]*0\.0\.0\.0[[:space:]]*$' "$ini"; then + fail "ini $ini has tcp_host=0.0.0.0 (Lite localhost-only)" + elif grep -Eq '^[[:space:]]*tcp_host[[:space:]]*=[[:space:]]*127\.0\.0\.1[[:space:]]*$' "$ini"; then + ok "ini $ini tcp_host=127.0.0.1" + else + fail "ini $ini missing tcp_host=127.0.0.1" + fi +done + +if [[ -f share/clients/index.yaml ]] && [[ -f share/clients/tnc2c.yaml ]]; then + ok "share/clients registry" +else + warn "share/clients/ (index.yaml / tnc2c.yaml) missing (informational only in Lite)" +fi + +# --- out-of-scope surfaces must stay inert (HyBBX co-host, hardware TNC stacks, plugin tree) --- +if [[ -d plugins ]]; then + fail "plugins/ present — Lite configures devices directly in max25d.ini, no plugin tree expected" +else + ok "no plugins/ tree (Lite: direct INI device config)" +fi +if [[ -d stacks/tncs ]]; then + fail "stacks/tncs/ present — TNC2C/pktnc2 hardware stack is out of scope for Lite" +else + ok "no stacks/tncs/ (hardware TNC stack out of scope for Lite)" +fi +if grep -rq 'hybbx' CMakeLists.txt 2>/dev/null; then + fail "CMakeLists.txt references hybbx — HyBBX co-host build hooks are out of scope for Lite" +else + ok "CMakeLists.txt free of hybbx references" +fi +if [[ -f share/hybbx/README.md ]]; then + ok "share/hybbx/README.md scope note present (kept for upstream sync, not a Lite path)" +else + warn "share/hybbx/ present without a scope note (see design-principles.md sync policy)" +fi + +# --- build artifacts (CMake, Lite target set) --- +BUILD_DIR="build" +if ! cmake -B "${BUILD_DIR}" -DCMAKE_BUILD_TYPE=Release >/dev/null 2>&1; then + BUILD_DIR="build-default" + cmake -B "${BUILD_DIR}" -DCMAKE_BUILD_TYPE=Release >/dev/null 2>&1 \ + || fail "cmake configure" +fi +if cmake --build "${BUILD_DIR}" -j"$(nproc 2>/dev/null || echo 2)" >/dev/null 2>&1; then + ok "cmake build (${BUILD_DIR}/bin/)" +else + fail "cmake build" +fi +[[ -x "${BUILD_DIR}/bin/max25-terminal" ]] && ok "max25-terminal built" || fail "max25-terminal build" +if [[ -x "${BUILD_DIR}/bin/max25-bcprd" ]] && [[ -x "${BUILD_DIR}/bin/max25-bcprd-init" ]]; then + ok "max25-bcprd + max25-bcprd-init built (default ON in Lite)" +else + fail "max25-bcprd / max25-bcprd-init not built (expected default ON — see cmake/MAX25Platform.cmake)" +fi +[[ -f stacks/crdop/share/crdop.ini.example ]] && ok "CRDOP scaffold" || fail "CRDOP scaffold missing" +if [[ -x "${BUILD_DIR}/bin/audio-dummyd" ]]; then + ok "audio-dummyd binary" +else + ok "audio-dummyd not in build dir (launcher uses source tree path)" +fi + +# --- install tree hygiene: no vendor/ARDOP artifacts, no out-of-scope leakage --- +INSTALL_DIR="${BUILD_DIR}/install-release-check" +rm -rf "${INSTALL_DIR}" +if cmake --install "${BUILD_DIR}" --prefix "${INSTALL_DIR}" >/dev/null 2>&1; then + ok "cmake --install prefix" + if find "${INSTALL_DIR}" \( -path '*/vendor/ardopcf/*' -o -name 'ardopcf' -o -name 'crdopc' \) 2>/dev/null | grep -q .; then + fail "ARDOP vendor artifacts in install tree (forbidden in release)" + else + ok "install tree free of ARDOP vendor binaries/sources" + fi + if find "${INSTALL_DIR}" -type d -iname 'hybbx' 2>/dev/null | grep -q .; then + fail "hybbx/ present in install tree (out of scope for Lite)" + else + ok "install tree free of hybbx/ directories" + fi +else + warn "cmake --install skipped (non-fatal for dev trees)" +fi + +# --- offline tests --- +if [[ -x "${BUILD_DIR}/bin/max25-bcprd" ]]; then + if "${BUILD_DIR}/bin/max25-bcprd" -c stacks/max25-bcpr/share/max25-bcpr.ini.example --dry-run --once >/dev/null 2>&1; then + ok "max25-bcpr dry-run once" + else + fail "max25-bcpr dry-run once" + fi +else + warn "max25-bcpr dry-run skipped (not built)" +fi + +if python3 stacks/daemon/test_bcpr_backend.py >/dev/null 2>&1; then + ok "bcpr backend unit tests" +else + fail "bcpr backend unit tests" +fi + +if python3 stacks/daemon/test_crdop_backend.py >/dev/null 2>&1; then + ok "crdop backend unit tests" +else + fail "crdop backend unit tests" +fi + +if python3 stacks/daemon/test_paths.py >/dev/null 2>&1; then + ok "daemon path layout unit tests" +else + fail "daemon path layout unit tests" +fi + +if bash scripts/test-wizard.sh >/dev/null 2>&1; then + ok "wizard non-interactive smoke (serial/baycom/crdop)" +else + fail "wizard non-interactive smoke" +fi + +if bash scripts/discover-plugins.sh --type device >/dev/null 2>&1; then + ok "discover-plugins.sh --type device" +else + warn "discover-plugins.sh --type device (no manifest — expected in Lite, see script output)" +fi + +chmod +x stacks/daemon/max25d stacks/daemon/max25d.py 2>/dev/null || true +[[ -x stacks/daemon/max25d ]] && [[ -f stacks/daemon/max25d.py ]] && ok "max25d ready" || fail "max25d" +if [[ -L "${BUILD_DIR}/bin/max25-client" ]] || [[ -x "${BUILD_DIR}/bin/max25-client" ]]; then + ok "max25-client symlink" +else + warn "max25-client symlink missing until cmake --install" +fi +if MAX25_TERMINAL="${PWD}/${BUILD_DIR}/bin/max25-terminal" \ + bash stacks/terminal/test-terminal.sh >/dev/null 2>&1; then + ok "max25-terminal TCP probe" +else + fail "max25-terminal TCP probe" +fi + +if python3 stacks/daemon/test_proto.py >/dev/null 2>&1; then + ok "max25d protocol smoke" +else + fail "max25d protocol smoke" +fi + +if python3 stacks/daemon/test_auth.py >/dev/null 2>&1; then + ok "max25d TCP auth smoke" +else + fail "max25d TCP auth smoke" +fi + +# --- RX before TX gate (§0.20) — offline check only, no live RF TX here --- +if bash scripts/tx-rx-test.sh >/dev/null 2>&1; then + ok "tx-rx-test L0 offline (RX before TX posture)" +else + fail "tx-rx-test L0 offline" +fi + +if python3 stacks/daemon/test_serial_watch.py >/dev/null 2>&1; then + ok "max25d serial watch tests" +else + fail "max25d serial watch tests" +fi + +# --- AmigaOS terminal (optional cross-build) --- +if [[ -x /opt/amiga/bin/m68k-amigaos-gcc ]]; then + if bash scripts/build-amiga-terminal.sh >/dev/null 2>&1; then + ok "max25-terminal AmigaOS cross-build" + else + fail "max25-terminal AmigaOS cross-build" + fi +else + warn "Amiga SDK not at /opt/amiga; skip Amiga terminal build (optional)" +fi + +# --- CI workflow --- +[[ -f .github/workflows/ci.yml ]] && ok "ci workflow" || warn "missing .github/workflows/ci.yml" + +echo "======== release-check: ${PASS} passed, ${FAIL} failed, ${WARN} warnings ========" +if [[ "$FAIL" -gt 0 ]]; then + exit 1 +fi +exit 0 diff --git a/scripts/run-max25-terminal.sh b/scripts/run-max25-terminal.sh new file mode 100755 index 0000000..21f45da --- /dev/null +++ b/scripts/run-max25-terminal.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Canonical max25-terminal launcher — tree or PREFIX/bin. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +pick() { + local c + # Prefer installed binary beside this script + if [[ -x "${SCRIPT_DIR}/max25-terminal" && ! -d "${SCRIPT_DIR}/../stacks/terminal" ]]; then + c="${SCRIPT_DIR}/max25-terminal" + if "$c" -h 2>&1 | grep -q -- '-d, --device'; then + echo "$c" + return 0 + fi + fi + local TREE="" + if [[ -d "${SCRIPT_DIR}/../stacks/terminal" ]]; then + TREE="$(cd "${SCRIPT_DIR}/.." && pwd)" + fi + for c in \ + ${TREE:+"${TREE}/build/bin/max25-terminal"} \ + ${TREE:+"${TREE}/build-max25-bcpr/bin/max25-terminal"} \ + "${ROOT}/build-max25-bcpr/bin/max25-terminal" \ + "${ROOT}/build/bin/max25-terminal" \ + /usr/local/bin/max25-terminal + do + [[ -n "$c" && -x "$c" ]] || continue + if "$c" -h 2>&1 | grep -q -- '-d, --device'; then + echo "$c" + return 0 + fi + done + if command -v max25-terminal >/dev/null 2>&1; then + c="$(command -v max25-terminal)" + if "$c" -h 2>&1 | grep -q -- '-d, --device'; then + echo "$c" + return 0 + fi + fi + return 1 +} + +BIN="$(pick)" || { + echo "run-max25-terminal: no current max25-terminal binary found (or it is missing -d/--device)" >&2 + echo " build it: cmake -B build && cmake --build build --target max25-terminal" >&2 + echo " install: scripts/install-max25.sh or cmake --install build" >&2 + echo " Do not run stacks/terminal/max25-terminal leftovers." >&2 + exit 127 +} + +SOCK="${MAX25_UNIX:-/run/max25/modem.sock}" +if [[ ! -S "$SOCK" ]] && [[ "${1:-}" != "-h" ]] && [[ "${1:-}" != "--help" ]]; then + if [[ " $* " != *" -T "* ]] && [[ " $* " != *" --tcp-only "* ]]; then + echo "run-max25-terminal: no socket $SOCK — start max25d first:" >&2 + echo " run-max25d # or: ${SCRIPT_DIR}/run-max25d.sh" >&2 + exit 1 + fi +fi + +exec "$BIN" "$@" diff --git a/scripts/run-max25d.sh b/scripts/run-max25d.sh new file mode 100755 index 0000000..232caf4 --- /dev/null +++ b/scripts/run-max25d.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Canonical max25d start — escalate to root only when hardware needs it. +# Works from source tree (scripts/) and from PREFIX/bin after install. +# Root needed: ttyS / USB serial / ioperm / /dev/port / max25-bcpr SER12 (max25e0). +# Escalation: already root → sudo -n → sudo → su → pkexec. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [[ -x "${SCRIPT_DIR}/max25d" && ! -d "${SCRIPT_DIR}/../stacks/daemon" ]]; then + DAEMON="${SCRIPT_DIR}/max25d" + TREE="" +else + TREE="$(cd "${SCRIPT_DIR}/.." && pwd)" + DAEMON="${TREE}/stacks/daemon/max25d" +fi + +if [[ ! -x "$DAEMON" ]]; then + echo "run-max25d: missing $DAEMON" >&2 + exit 1 +fi + +# Match real daemon only (not agent shells whose argv text mentions max25d). +if pgrep -x max25d >/dev/null 2>&1 \ + || pgrep -f -- '/max25d(\.py)?[[:space:]]+-c[[:space:]]' >/dev/null 2>&1; then + echo "run-max25d: max25d already running" >&2 + pgrep -af -- '/max25d(\.py)?[[:space:]]+-c[[:space:]]' | head -5 || true + pgrep -ax max25d | head -5 || true + ls -la /run/max25/modem.sock /var/run/max25/modem.sock 2>/dev/null \ + || echo "run-max25d: WARNING no modem.sock under /run/max25 or /var/run/max25" >&2 + exit 0 +fi + +# Accept path or max25d-style "-c path". +INI="${1:-}" +if [[ "$INI" == "-c" || "$INI" == "--config" ]]; then + INI="${2:-}" +fi +if [[ -z "$INI" ]]; then + if [[ -n "$TREE" && -f "${TREE}/local/max25d.ini" ]]; then + INI="${TREE}/local/max25d.ini" + elif [[ -f "${PWD}/local/max25d.ini" ]]; then + INI="${PWD}/local/max25d.ini" + elif [[ -f /etc/max25/max25d.ini ]]; then + INI=/etc/max25/max25d.ini + elif [[ -f /usr/local/etc/max25/max25d.ini ]]; then + INI=/usr/local/etc/max25/max25d.ini + else + echo "run-max25d: no INI (pass path or create local/max25d.ini / /etc/max25/max25d.ini / /usr/local/etc/max25/max25d.ini)" >&2 + exit 1 + fi +fi +if [[ ! -f "$INI" ]]; then + echo "run-max25d: INI not found: $INI" >&2 + exit 1 +fi + +needs_root() { + local f="$1" + [[ -f "$f" ]] || return 1 + if grep -Eiq '^[[:space:]]*(max25[-_]bcpr|bcpr|baycom|pccom)[[:space:]]*=[[:space:]]*yes' "$f"; then return 0; fi + if grep -Eiq '^[[:space:]]*(max25e0|max25e0:bc[01]|tnc2c|pktnc2|baycom-kiss|pccom-kiss)[[:space:]]*=' "$f"; then return 0; fi + if grep -Eiq '^[[:space:]]*device[[:space:]]*=[[:space:]]*(max25e0|tnc2c|pktnc2)' "$f"; then return 0; fi + if grep -Eqi '/dev/(ttyS|ttyUSB|ttyACM|port)\b' "$f"; then return 0; fi + if grep -Eiq '^[[:space:]]*unix_socket[[:space:]]*=[[:space:]]*/(var/)?run/max25/' "$f"; then return 0; fi + return 1 +} + +if [[ "$(id -u)" -ne 0 ]]; then + if needs_root "$INI"; then + if command -v sudo >/dev/null 2>&1; then + if sudo -n true 2>/dev/null; then + echo "run-max25d: hardware path — escalating via sudo -n" >&2 + exec sudo -n "$0" "$INI" + fi + echo "run-max25d: hardware path — escalating via sudo (password may be required)" >&2 + exec sudo "$0" "$INI" + fi + if command -v su >/dev/null 2>&1; then + echo "run-max25d: hardware path — escalating via su" >&2 + exec su -c "$(printf '%q ' "$0" "$INI")" + fi + if command -v pkexec >/dev/null 2>&1; then + echo "run-max25d: hardware path — escalating via pkexec" >&2 + exec pkexec "$0" "$INI" + fi + echo "run-max25d: PC-COM or serial hardware needs admin — run: sudo $0 $INI" >&2 + exit 1 + fi + echo "run-max25d: starting unprivileged (uid=$(id -u))" >&2 +else + echo "run-max25d: running as root (uid=0)" >&2 +fi + +if [[ "$(id -u)" -eq 0 ]]; then + mkdir -p /run/max25 /var/run/max25 +fi +echo "run-max25d: ini=$INI" +exec "$DAEMON" -c "$INI" diff --git a/scripts/terminology-check.sh b/scripts/terminology-check.sh new file mode 100755 index 0000000..96c01fb --- /dev/null +++ b/scripts/terminology-check.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# terminology-check.sh — release gate for AGENT-INDEX §0.16 / §0.19 +# Fail on forbidden product terms in shipped markdown (not tests/archives). +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +ERR=0 + +check() { + local pat="$1" msg="$2" + local hits + hits="$(grep -RInE --include='*.md' "$pat" README.md CHANGELOG.md RELEASE-READINESS.md docs share/clients plugins 2>/dev/null \ + | grep -viE 'Never |never |forbidden|LEGACY|removed|prefer bcpr|BayCom/based' || true)" + if [[ -n "$hits" ]]; then + echo "FAIL: $msg" + echo "$hits" | head -40 + ERR=1 + else + echo "OK: $msg" + fi +} + +check '\bbcpr-bc0\b' 'product device id bcpr-bc0 (use max25e0)' +check '\bbcpr-bc1\b' 'product device id bcpr-bc1 (use max25e0:bc1)' +# product device face for SER12 — allow historical "removed" lines +if grep -RInE --include='*.md' 'device = baycom-ser12|`baycom-ser12` \|' README.md docs 2>/dev/null \ + | grep -vE 'removed|LEGACY|kernel baycom|not a kernel|Never' | grep -q .; then + echo "FAIL: product baycom-ser12 as active device face" + grep -RInE --include='*.md' 'device = baycom-ser12|`baycom-ser12` \|' README.md docs 2>/dev/null | head -20 || true + ERR=1 +else + echo "OK: no active baycom-ser12 product device face in README/docs" +fi +check '\bKonverter\b' 'Konverter in shipped docs' +check '\bAX25SRV\b|\bax25srv\b' 'AX25SRV in shipped docs' + +if [[ "$ERR" -ne 0 ]]; then + exit 1 +fi +echo "terminology-check: PASS" +exit 0 diff --git a/scripts/test-wizard.sh b/scripts/test-wizard.sh new file mode 100755 index 0000000..e75a401 --- /dev/null +++ b/scripts/test-wizard.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# test-wizard.sh — non-interactive smoke test for max25-lite-setup.sh. +# +# Drives the wizard through all three modem paths (serial, baycom, crdop) +# using a mock `whiptail` on PATH that answers canned responses, then checks +# the generated max25d.ini / crdop.ini / max25-bcpr.ini files are well formed. +# No sudo, no hardware, no PTT — local ./local/ destination only. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WIZARD="${ROOT}/scripts/max25-lite-setup.sh" +WORKDIR="$(mktemp -d)" +ERR=0 + +ok() { echo "PASS: $*"; } +fail() { echo "FAIL: $*" >&2; ERR=1; } + +cleanup() { rm -rf "$WORKDIR"; } +trap cleanup EXIT + +mock_bin="${WORKDIR}/bin" +mkdir -p "$mock_bin" + +# Mock whiptail: reads canned answers from $WHIPTAIL_ANSWERS (newline list, one +# per --menu/--radiolist/--inputbox/--yesno call, in call order). --msgbox is a +# no-op (informational only). Mirrors the 3>&1 1>&2 2>&3 capture convention: +# the "answer" goes to this script's stderr; exit 0 means OK/Yes. +cat >"${mock_bin}/whiptail" <<'MOCK' +#!/usr/bin/env bash +mode="" +for a in "$@"; do + case "$a" in + --msgbox) mode=msgbox ;; + --yesno) mode=yesno ;; + --inputbox) mode=inputbox ;; + --menu) mode=menu ;; + --radiolist) mode=radiolist ;; + esac +done +[[ "$mode" == "msgbox" ]] && exit 0 +answers_file="${WHIPTAIL_ANSWERS:?WHIPTAIL_ANSWERS not set}" +answer="$(head -n1 "$answers_file")" +tail -n +2 "$answers_file" > "${answers_file}.next" +mv "${answers_file}.next" "$answers_file" +if [[ "$mode" == "yesno" ]]; then + [[ "$answer" == "yes" ]] && exit 0 || exit 1 +fi +echo "$answer" >&2 +exit 0 +MOCK +chmod +x "${mock_bin}/whiptail" + +# Mock arecord/aplay: force the "no ALSA cards" branch deterministically, +# independent of the host's actual sound hardware, so the CRDOP path always +# takes the same code path in this smoke test. +for tool in arecord aplay; do + cat >"${mock_bin}/${tool}" <<'MOCK' +#!/usr/bin/env bash +exit 1 +MOCK + chmod +x "${mock_bin}/${tool}" +done + +run_path() { + local label="$1" answers="$2" ini_glob="$3" want_grep="$4" + local answers_file="${WORKDIR}/answers-${label}" + printf '%s\n' "$answers" > "$answers_file" + rm -rf "${ROOT}/local/max25d.ini" "${ROOT}/local/crdop.ini" "${ROOT}/local/max25-bcpr.ini" + if PATH="${mock_bin}:${PATH}" WHIPTAIL_ANSWERS="$answers_file" \ + bash "$WIZARD" "${WORKDIR}/out-${label}.log" 2>&1; then + if [[ -f "${ROOT}/local/max25d.ini" ]] && grep -q "$want_grep" "${ROOT}/local/max25d.ini"; then + ok "wizard ${label} path: max25d.ini written (${want_grep})" + else + fail "wizard ${label} path: max25d.ini missing or unexpected content" + cat "${WORKDIR}/out-${label}.log" >&2 + fi + else + fail "wizard ${label} path: exited non-zero" + cat "${WORKDIR}/out-${label}.log" >&2 + fi + rm -rf "${ROOT}/local/max25d.ini" "${ROOT}/local/crdop.ini" "${ROOT}/local/max25-bcpr.ini" +} + +# serial: menu=serial, radiolist=/dev/ttyUSB0, callerid, callid, save-location=no (local) +run_path "serial" "serial +/dev/ttyUSB0 +CB-0 +QST +no" "local/max25d.ini" "device = tnc2c" + +# baycom: menu=baycom, radiolist=/dev/ttyS0 (or whatever first port is), callerid, callid, no +run_path "baycom" "baycom +$(bash -c ' + shopt -s nullglob + ports=(/dev/ttyUSB* /dev/ttyACM* /dev/ttyS*) + shopt -u nullglob + echo "${ports[0]:-/dev/ttyUSB0}" +') +CB-0 +QST +no" "local/max25d.ini" "device = max25e0" + +# crdop: menu=crdop, [radiolist ALSA device only if arecord/aplay found any — assume +# none in CI so wizard shows an informational msgbox and uses hw:0,0 defaults], callerid, callid, no +run_path "crdop" "crdop +CB-0 +QST +no" "local/max25d.ini" "device = soft-crdop" + +if [[ "$ERR" -eq 0 ]]; then + echo "OK: wizard non-interactive smoke (serial / baycom / crdop)" +fi +exit "$ERR" diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..24a83d7 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# test.sh — MAX25-Stack-Lite offline tests via CMake targets. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +if [[ ! -d build ]]; then + bash "${ROOT}/scripts/build.sh" +fi + +cmake --build build --target max25_test diff --git a/scripts/tx-rx-test.sh b/scripts/tx-rx-test.sh new file mode 100755 index 0000000..cdfbdca --- /dev/null +++ b/scripts/tx-rx-test.sh @@ -0,0 +1,358 @@ +#!/usr/bin/env bash +# tx-rx-test.sh — unified TX/RX release test (BayCom/based max25-bcpr + classic TNC). +# Default: L0 offline only (CI / make test). Live/TX are operator-gated. +# Public: BayCom/based / max25-bcpr. Never Konverter/converter. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Tree: scripts/ → repo root. Install: PREFIX/bin or PREFIX/sbin → no stacks/. +if [[ -d "${SCRIPT_DIR}/../stacks/max25-bcpr" ]]; then + ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + cd "$ROOT" + _BCPR_CTL_DEFAULT="${ROOT}/stacks/max25-bcpr/tools/max25-bcpr-ctl" + _BCPR_INI_DEFAULT="${ROOT}/stacks/max25-bcpr/share/max25-bcpr.ini.example" +else + ROOT="" + cd "${PWD:-/}" + _BCPR_CTL_DEFAULT="$(command -v max25-bcpr-ctl 2>/dev/null || true)" + _BCPR_INI_DEFAULT="/etc/max25/max25-bcpr.ini" +fi + +LEVEL=0 +DEVICE=all # all | modem | tnc +DO_LIVE=0 +DO_TX=0 +FORCE_TX=0 +SECONDS_LIVE=15 +TX_SECONDS=3 +BCPR_INI="${BCPR_INI:-$_BCPR_INI_DEFAULT}" +SOCK="${MAX25_SOCK:-/run/max25/modem.sock}" +ERR=0 +BCPR_CTL="${BCPR_CTL:-$_BCPR_CTL_DEFAULT}" +if [[ -z "$BCPR_CTL" ]] || [[ ! -x "$BCPR_CTL" ]]; then + if command -v max25-bcpr-ctl >/dev/null 2>&1; then + BCPR_CTL="$(command -v max25-bcpr-ctl)" + elif [[ -n "$ROOT" && -x "$ROOT/stacks/max25-bcpr/tools/max25-bcpr-ctl" ]]; then + BCPR_CTL="$ROOT/stacks/max25-bcpr/tools/max25-bcpr-ctl" + fi +fi +BCPR_SMOKE="${BCPR_SMOKE:-}" +if [[ -z "$BCPR_SMOKE" ]]; then + if [[ -n "$ROOT" && -x "$ROOT/stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh" ]]; then + BCPR_SMOKE="$ROOT/stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh" + elif command -v max25-bcpr-rxtx-smoke.sh >/dev/null 2>&1; then + BCPR_SMOKE="$(command -v max25-bcpr-rxtx-smoke.sh)" + elif [[ -x /usr/local/sbin/max25-bcpr-rxtx-smoke.sh ]]; then + BCPR_SMOKE=/usr/local/sbin/max25-bcpr-rxtx-smoke.sh + fi +fi + +usage() { + cat <<'USAGE' +Usage: tx-rx-test.sh [--level N] [--device all|modem|tnc] [--live] [--tx] [--seconds N] [--tx-seconds N] [-c bcpr.ini] + + L0 offline TX/RX proofs (default) — CI / release-check / ctest + L1 soft preflight (no attach / no RF) + L2 live attach (modem: max25-bcprd timed; tnc: max25d sock status) + L3 live RX listen (same as L2 + RX readiness) + L4 TX (--tx required; PTT / RF risk; default ~3s MCR key) + + --live implies at least L2. --tx requires --live (L4). + --tx-seconds: modem PTT key target (default 3; proven ≈376B info). + Default: NO TX. Hard time caps. Safe for make test / CI. + + Static rule (§0.20): live --tx is refused unless RX was detected in this + run (modem: Soft-DCD/noise → state_dir/dcd-bc* or rx-activity-bc*; + TNC: live CONNECT/RX path OK first). Override only with --force-tx + (against policy — debug only). +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --level) LEVEL="$2"; shift 2 ;; + --device) DEVICE="$2"; shift 2 ;; + --live) DO_LIVE=1; shift ;; + --tx) DO_TX=1; shift ;; + --force-tx) FORCE_TX=1; shift ;; + --seconds) SECONDS_LIVE="$2"; shift 2 ;; + --tx-seconds) TX_SECONDS="$2"; shift 2 ;; + -c) BCPR_INI="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "ERROR: unknown arg: $1"; usage; exit 2 ;; + esac +done + +case "$DEVICE" in + all|modem|tnc) ;; + *) echo "ERROR: --device must be all|modem|tnc"; exit 2 ;; +esac + +if [[ "$DO_TX" -eq 1 && "$DO_LIVE" -eq 0 ]]; then + echo "ERROR: --tx requires --live" + exit 2 +fi + +if [[ "$DO_LIVE" -eq 1 && "$LEVEL" -lt 2 ]]; then + LEVEL=2 +fi +if [[ "$DO_TX" -eq 1 ]]; then + LEVEL=4 +fi + +if [[ "$SECONDS_LIVE" -lt 1 ]]; then + SECONDS_LIVE=1 +fi +if [[ "$SECONDS_LIVE" -gt 60 ]]; then + echo "WARN: capping --seconds from $SECONDS_LIVE to 60" + SECONDS_LIVE=60 +fi +if [[ "$TX_SECONDS" -lt 1 ]]; then + TX_SECONDS=1 +fi +if [[ "$TX_SECONDS" -gt 12 ]]; then + echo "WARN: capping --tx-seconds from $TX_SECONDS to 12" + TX_SECONDS=12 +fi +need_live=$((TX_SECONDS + 5)) +if [[ "$DO_TX" -eq 1 && "$SECONDS_LIVE" -lt "$need_live" ]]; then + echo "NOTE: raising --seconds from $SECONDS_LIVE to $need_live for --tx-seconds $TX_SECONDS" + SECONDS_LIVE=$need_live +fi + +log() { printf '%s\n' "$*"; } +ok() { log "PASS: $*"; } +fail() { log "FAIL: $*"; ERR=1; } +stage() { log ""; log "=== $* ==="; } +want_modem() { [[ "$DEVICE" == "all" || "$DEVICE" == "modem" ]]; } +want_tnc() { [[ "$DEVICE" == "all" || "$DEVICE" == "tnc" ]]; } + +run_l0() { + stage "L0 offline TX/RX" + if [[ -n "$ROOT" && -f "$ROOT/stacks/daemon/test_tx_rx_offline.py" ]]; then + if python3 "$ROOT/stacks/daemon/test_tx_rx_offline.py"; then + ok "test_tx_rx_offline (TNC + bcpr host)" + else + fail "test_tx_rx_offline" + fi + else + ok "test_tx_rx_offline skipped (installed layout — use bcpr-rxtx-smoke)" + fi + + if want_tnc; then + if [[ -f stacks/tncs/test_tnc_serial_recovery.py ]]; then + if python3 stacks/tncs/test_tnc_serial_recovery.py >/dev/null; then + ok "tnc serial recovery unit" + else + fail "tnc serial recovery unit" + fi + else + ok "tnc serial recovery unit skipped (stacks/tncs/ not shipped in this build — Lite: modem/BayCom-based only)" + fi + if python3 stacks/daemon/test_kiss_bridge.py >/dev/null; then + ok "kiss_bridge unit (TNC KISS)" + else + fail "kiss_bridge unit" + fi + fi + + if want_modem; then + # Lite default tree builds max25-bcpr (MAX25_BUILD_MAX25_BCPR=ON). Do not + # auto-rebuild it here for L0 in CI / release-check — only smoke when + # binaries already exist or MAX25_BCPR_ALLOW_BUILD=1. + bcprd="" + for cand in \ + "${BCPR_BUILD_DIR:-}/bin/max25-bcprd" \ + "${BUILD_DIR:-}/bin/max25-bcprd" \ + build/bin/max25-bcprd \ + build-default/bin/max25-bcprd \ + "build-max25-bcpr/bin/max25-bcprd" \ + "/tmp/max25-build-max25-bcpr-${USER:-user}/bin/max25-bcprd"; do + if [[ -n "$cand" && -x "$cand" ]]; then + bcprd="$cand" + break + fi + done + if [[ -z "$bcprd" && "${MAX25_BCPR_ALLOW_BUILD:-0}" != "1" ]]; then + ok "max25-bcpr L0 skipped (not built — run cmake --build with MAX25_BUILD_MAX25_BCPR=ON, default in Lite)" + elif [[ -x stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh ]]; then + if [[ -n "$bcprd" ]]; then + if MAX25_BCPR_NO_AUTOBUILD=1 bash stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh -c "$BCPR_INI"; then + ok "max25-bcpr-rxtx-smoke L0 (BayCom/based)" + else + fail "max25-bcpr-rxtx-smoke L0" + fi + else + if bash stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh -c "$BCPR_INI"; then + ok "max25-bcpr-rxtx-smoke L0 (BayCom/based)" + else + fail "max25-bcpr-rxtx-smoke L0" + fi + fi + else + fail "max25-bcpr-rxtx-smoke.sh missing" + fi + fi +} + +run_l1() { + stage "L1 soft preflight" + if want_modem; then + if [[ -n "${BCPR_CTL:-}" && -x "$BCPR_CTL" ]]; then + set +e + "$BCPR_CTL" -c "$BCPR_INI" preflight + pf_rc=$? + set -e + if [[ "$pf_rc" -eq 0 ]]; then + ok "max25-bcpr-ctl preflight" + else + if [[ "$DO_LIVE" -eq 1 ]]; then + fail "max25-bcpr-ctl preflight (required for --live modem)" + else + log "NOTE: bcpr preflight rc=$pf_rc — soft at L1 without --live" + fi + fi + else + fail "max25-bcpr-ctl missing (install max25-bcpr or set BCPR_CTL)" + fi + fi + if want_tnc; then + if [[ ! -d stacks/tncs ]]; then + ok "tnc preflight skipped (stacks/tncs/ not shipped in this build — Lite: modem/BayCom-based only)" + elif [[ -f share/clients/tnc2c.yaml ]] && [[ -f stacks/tncs/tnc_serial_recovery.py ]]; then + ok "tnc client profile + recovery module present" + else + fail "tnc profile/recovery missing" + fi + fi +} + +live_tnc_sock() { + local mode="$1" # rx|tx + local sock="$SOCK" + if [[ ! -S "$sock" ]]; then + if [[ -S /tmp/max25/modem.sock ]]; then + sock=/tmp/max25/modem.sock + else + fail "no max25d unix socket ($SOCK) — start max25d with TNC first" + return 1 + fi + fi + log "using unix socket $sock (mode=$mode)" + python3 - "$sock" "$mode" <<'PY' || return 1 +import socket, sys, time + +sock_path, mode = sys.argv[1], sys.argv[2] + +class LineReader: + def __init__(self, s): + self.s = s + self.buf = b"" + def read(self, timeout=8.0): + self.s.settimeout(timeout) + while b"\n" not in self.buf: + chunk = self.s.recv(4096) + if not chunk: + raise RuntimeError("connection closed") + self.buf += chunk + line, self.buf = self.buf.split(b"\n", 1) + return line.decode("utf-8", errors="replace") + +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +s.connect(sock_path) +r = LineReader(s) +hello = r.read() +assert hello == "OK", hello +status = r.read() +assert status.startswith("STATUS "), status +s.sendall(b"CONNECT\n") +ev = r.read() +assert ev == "EVENT connected", ev +assert r.read() == "OK" +if mode == "tx": + s.sendall(b"SEND TXRXTEST\n") + rx = r.read() + assert rx.startswith("RX "), rx + assert r.read() == "OK" + print("TNC live TX: SEND OK (host path)") +else: + # RX readiness: connected + status shows device + s.sendall(b"GET STATUS\n") + st = r.read() + assert "device=" in st, st + assert r.read() == "OK" + print("TNC live RX: CONNECT + STATUS OK (listen ready)") +s.close() +PY +} + +run_live() { + stage "L2/L3 live (${SECONDS_LIVE}s) device=$DEVICE" + if want_modem; then + local smoke_args=(-c "$BCPR_INI" --live --seconds "$SECONDS_LIVE") + if [[ "$DO_TX" -eq 1 ]]; then + smoke_args+=(--tx --tx-seconds "$TX_SECONDS") + if [[ "${FORCE_TX:-0}" -eq 1 ]]; then + smoke_args+=(--force-tx) + fi + log "WARN: modem --tx asserts PTT (~${TX_SECONDS}s; watch LED/wattmeter) — only after RX (§0.20)" + fi + _smoke="${BCPR_SMOKE:-stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh}" + if [[ ! -x "$_smoke" ]]; then + fail "max25-bcpr-rxtx-smoke.sh missing (install max25-bcpr or set BCPR_SMOKE)" + elif bash "$_smoke" "${smoke_args[@]}"; then + ok "bcpr live smoke (BayCom/based)" + else + fail "bcpr live smoke" + fi + fi + if want_tnc; then + local TNC_RX_OK=0 + if live_tnc_sock rx; then + ok "tnc live CONNECT (RX ready)" + TNC_RX_OK=1 + else + fail "tnc live CONNECT" + fi + if [[ "$DO_TX" -eq 1 ]]; then + if [[ "$TNC_RX_OK" -ne 1 && "${FORCE_TX:-0}" -eq 0 ]]; then + fail "TNC TX blocked (§0.20): RX/CONNECT failed" + else + log "WARN: TNC --tx sends one UI frame over air if radio keyed" + if live_tnc_sock tx; then + ok "tnc live CONNECT+SEND" + else + fail "tnc live CONNECT+SEND" + fi + fi + fi + fi +} + +# --- main --- +log "tx-rx-test level=$LEVEL device=$DEVICE live=$DO_LIVE tx=$DO_TX tx_seconds=$TX_SECONDS" +run_l0 + +if [[ "$LEVEL" -ge 1 ]]; then + run_l1 +fi + +if [[ "$LEVEL" -ge 2 || "$DO_LIVE" -eq 1 ]]; then + run_live +fi + +stage "summary" +if [[ "$ERR" -eq 0 ]]; then + if [[ "$DO_TX" -eq 1 ]]; then + ok "TX/RX complete through L4 (live + TX)" + elif [[ "$DO_LIVE" -eq 1 ]]; then + ok "TX/RX complete through L2/L3 (live RX, no TX)" + elif [[ "$LEVEL" -ge 1 ]]; then + ok "TX/RX L0+L1 complete" + else + ok "TX/RX L0 offline complete — use --live / --tx for hardware" + fi + exit 0 +fi +fail "one or more stages failed" +exit 1 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 ` / 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 . + +--- + +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_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_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: +- 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 +#include + +#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 +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 +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 `, `GET DEVICES`, `RX device= …`. + +## 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 `, `UNBAN `, `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.].""" + 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 +#include + +#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 +#include + +/* 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 + +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 + +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 + +/* 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 + +/* 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 +#include +#include +#include + +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 +#include +#include +#include +#include +#include +#include +#include + +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 +#include +#include +#include +#include +#include + +#if defined(__linux__) +#include +#include +#include +#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 +#include + +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 +#include +#include +#include +#include +#include +#include + +#if defined(__linux__) +#include +#elif defined(__FreeBSD__) +#include +#else +#include +#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 +#include +#include +#include +#include +#include +#include +#include +#include + +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 +#include +#include +#include +#include +#include + +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 +#include + +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 +#include +#include +#include +#include +#include +#include + +#if defined(__linux__) +#include +#elif defined(__FreeBSD__) +#include +#include +#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 +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static void usage(const char *argv0) +{ + fprintf(stderr, + "Usage: %s -c [--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 +#include +#include +#include +#include + +static void usage(const char *argv0) +{ + fprintf(stderr, + "Usage: %s -c [--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 +#include +#include + +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 +#include + +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 + read -r __ans /dev/tty + read -r __ans /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 </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 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 + 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 + read -r __ans /dev/tty + read -r __ans /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 ` / `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 +#include +#include +#include +#include +#include + +#ifdef __AMIGA__ +#include +#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 +#include +#include +#include +#include +#include +#include + +#ifndef MAX25_AMIGA +#include +#include +#include +#else +#include +#include +#include +#include +#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 + +#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 +#include +#include +#include +#include +#include +#include +#include + +#ifdef MAX25_HAVE_NCURSES +#include +#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 +#include +#include +#include + +#ifdef MAX25_HAVE_NCURSES +#include +#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 +#include + +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()) -- cgit v1.3.1