diff options
| author | info@mode42.com <info@mode42.com> | 2026-08-07 18:25:13 +0000 |
|---|---|---|
| committer | info@mode42.com <info@mode42.com> | 2026-08-07 18:25:13 +0000 |
| commit | 04d965d67a7264a1c7c211494aebda1953df7603 (patch) | |
| tree | 0ebd700a6e219f84a26a656f4bee8778bc75da7b /stacks | |
Initial push
Diffstat (limited to 'stacks')
208 files changed, 28137 insertions, 0 deletions
diff --git a/stacks/crdop/.github/workflows/build.yml b/stacks/crdop/.github/workflows/build.yml new file mode 100644 index 0000000..a9d6465 --- /dev/null +++ b/stacks/crdop/.github/workflows/build.yml @@ -0,0 +1,54 @@ +name: Build + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + linux-gcc: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install deps + run: sudo apt-get update && sudo apt-get install -y build-essential cmake libasound2-dev pkg-config libcmocka-dev + - name: Build and test + run: | + chmod +x scripts/*.sh + CRDOP_BUILD_TESTS=ON CRDOP_RUN_TESTS=ON ./scripts/build-crdop.sh + + linux-clang: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install deps + run: sudo apt-get update && sudo apt-get install -y clang cmake libasound2-dev pkg-config libcmocka-dev + - name: Build and test (Clang) + run: | + chmod +x scripts/*.sh + CC=clang CRDOP_BUILD_TESTS=ON CRDOP_RUN_TESTS=ON ./scripts/build-crdop.sh + + linux-aarch64-cross: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install cross toolchain + run: sudo apt-get update && sudo apt-get install -y cmake gcc-aarch64-linux-gnu libasound2-dev:arm64 pkg-config + - name: Cross-build ARM64 + run: | + chmod +x scripts/*.sh + CRDOP_TOOLCHAIN=aarch64-linux-gnu ./scripts/build-crdop.sh + + windows-cross: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install MinGW + run: sudo apt-get update && sudo apt-get install -y cmake mingw-w64 + - name: Cross-build Windows x64 + run: | + chmod +x scripts/*.sh + CRDOP_TOOLCHAIN=x86_64-w64-mingw32 ./scripts/build-crdop.sh + - name: Verify exe + run: test -x build/crdopc.exe diff --git a/stacks/crdop/.gitignore b/stacks/crdop/.gitignore new file mode 100644 index 0000000..715a31d --- /dev/null +++ b/stacks/crdop/.gitignore @@ -0,0 +1,13 @@ +build/ +build-*/ +*.o +*.a +*.so +*.exe +*.log +.DS_Store +vendor/ardopcf/ +vendor/ardopcf/.crdop-patches-applied + +# vault/agent — never ship +AGENT-INDEX.md diff --git a/stacks/crdop/CMakeLists.txt b/stacks/crdop/CMakeLists.txt new file mode 100644 index 0000000..27ab174 --- /dev/null +++ b/stacks/crdop/CMakeLists.txt @@ -0,0 +1,55 @@ +cmake_minimum_required(VERSION 3.16) +# Product label CRDOP-CUR999 (pre-release until v0.5). CMake project() requires a +# numeric VERSION; 0.0.999 is the build-time fallback. Human-facing strings live +# in VERSION (CUR999) and include/crdop/version.h (CRDOP-CUR999 / CUR999). +project(crdop VERSION 0.0.999 LANGUAGES C) + +set(CMAKE_C_STANDARD 17) +set(CMAKE_C_STANDARD_REQUIRED ON) + +include(GNUInstallDirs) + +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) +endif() + +option(CRDOP_VENDOR_ARDOPCF + "Dev-only: build legacy ARDOP (ardopcf) vendor — OFF by default; never installed in MAX25 releases" + OFF) + +set(CRDOP_ROOT "${CMAKE_CURRENT_SOURCE_DIR}") +set(VENDOR_DIR "${CRDOP_ROOT}/vendor/ardopcf") + +function(crdop_install_scaffold) + install(FILES + "${CRDOP_ROOT}/share/crdop.ini.example" + "${CRDOP_ROOT}/share/crdop-dual.ini.example" + "${CRDOP_ROOT}/share/crdop-amateur.ini.example" + "${CRDOP_ROOT}/share/crdop.freebsd.ini.example" + DESTINATION ${CMAKE_INSTALL_DATADIR}/crdop + ) + install(FILES "${CRDOP_ROOT}/VERSION" DESTINATION ${CMAKE_INSTALL_DATADIR}/crdop) + install(PROGRAMS "${CRDOP_ROOT}/scripts/crdopc" + DESTINATION ${CMAKE_INSTALL_BINDIR} RENAME crdop) + install(PROGRAMS + "${CRDOP_ROOT}/tools/max25-signal-sniffer" + "${CRDOP_ROOT}/tools/audio-dummyd" + DESTINATION ${CMAKE_INSTALL_BINDIR} + ) + install(DIRECTORY "${CRDOP_ROOT}/lib/" + DESTINATION ${CMAKE_INSTALL_DATADIR}/crdop/lib + FILES_MATCHING PATTERN "*.py") +endfunction() + +if(CRDOP_VENDOR_ARDOPCF AND EXISTS "${VENDOR_DIR}/Makefile") + include("${CRDOP_ROOT}/cmake/CRDOPVendorArdopcf.cmake") + crdop_install_scaffold() + message(STATUS "CRDOP ${PROJECT_VERSION} → crdopc (legacy vendor/ardopcf)") +elseif(CRDOP_VENDOR_ARDOPCF) + message(WARNING "CRDOP: CRDOP_VENDOR_ARDOPCF=ON but vendor/ardopcf missing — scaffold only") + crdop_install_scaffold() + message(STATUS "CRDOP: scaffold only (native modem in development)") +else() + crdop_install_scaffold() + message(STATUS "CRDOP: scaffold only (native Eigenentwicklung; -DCRDOP_VENDOR_ARDOPCF=ON for legacy vendor build)") +endif() diff --git a/stacks/crdop/LICENSE b/stacks/crdop/LICENSE new file mode 100644 index 0000000..3488377 --- /dev/null +++ b/stacks/crdop/LICENSE @@ -0,0 +1,22 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + CRDOP (MAX25-SoftModem) — subproject of MainAX25-Stack (MAX25-Stack) + Copyright (C) 2026 ngteq + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. + +--- + +Full license text: [../../LICENSE](../../LICENSE) (repository root). diff --git a/stacks/crdop/NOTICE.md b/stacks/crdop/NOTICE.md new file mode 100644 index 0000000..43c302f --- /dev/null +++ b/stacks/crdop/NOTICE.md @@ -0,0 +1,9 @@ +# Third-party notices + +**CRDOP (MAX25-SoftModem)** — Copyright (c) 2026 ngteq, **GNU GPL v3** ([LICENSE](LICENSE), [../../LICENSE](../../LICENSE)) + +In-house sound-card modem subproject of MainAX25-Stack. Sound-card based; kernel ALSA via MAX25 sound-proxy. Project rule: [docs/CRDOP.md](../../docs/CRDOP.md). + +Operators must comply with local radio regulations. + +Third-party **ARDOP** host software (optional, separate from CRDOP): [plugins/external/ardop/README.md](../../plugins/external/ardop/README.md). diff --git a/stacks/crdop/README.md b/stacks/crdop/README.md new file mode 100644 index 0000000..aeb701d --- /dev/null +++ b/stacks/crdop/README.md @@ -0,0 +1,36 @@ +# CRDOP — MAX25-SoftModem + +**CRDOP** = stack acronym for **MAX25-SoftModem** (device id `soft-crdop`). + +Sound-card software modem subproject of MAX25-Stack. + +**Stack version:** MAX25-Stack **v1.0.0** · **Dev track:** `CUR999` in `$SRC/stacks/crdop/VERSION`. + +**Canonical project rule:** [docs/CRDOP.md](../../docs/CRDOP.md) +**License:** GNU GPL v3 — [LICENSE](LICENSE) + +## Summary + +| | | +|---|---| +| Type | MAX25-Stack **subproject** (`stacks/crdop/`) | +| Interface | Sound card IN/OUT → kernel ALSA → MAX25 sound-proxy | +| Goal | **AX.25 / KISS / TNC / modem compatible** software modem | +| Mission | **Hardware solutions** (primary) + **pure software** on computers | +| Build | Default ON (`MAX25_BUILD_CRDOP=ON`) | + +Read [docs/CRDOP.md](../../docs/CRDOP.md) before changing modem, audio, or plugin code. + +## Technical docs + +**Index:** [docs/INDEX.md](docs/INDEX.md) + +| Doc | Topic | +|-----|--------| +| [docs/MAX25-USAGE.md](docs/MAX25-USAGE.md) | MAX25-Stack operator guide | +| [docs/DEVELOPER.md](docs/DEVELOPER.md) | Source tree, modules, tests | +| [docs/HARDWARE-INTERFACE.md](docs/HARDWARE-INTERFACE.md) | Generic radio interface spec | +| [docs/SOFTMODEM.md](docs/SOFTMODEM.md) | Baud, duplex, acoustic AX.25 | +| [docs/AUDIO-ARCHITECTURE.md](docs/AUDIO-ARCHITECTURE.md) | Kernel ALSA only, no PulseAudio | +| [docs/CONFIG.md](docs/CONFIG.md) | INI / launcher | +| [ROADMAP.md](ROADMAP.md) | P0/P1/P2 milestones | diff --git a/stacks/crdop/ROADMAP.md b/stacks/crdop/ROADMAP.md new file mode 100644 index 0000000..0ae1aa4 --- /dev/null +++ b/stacks/crdop/ROADMAP.md @@ -0,0 +1,445 @@ +# MAX25-SoftModem (CRDOP) — development roadmap + +> **Static rule:** **CRDOP** = stack acronym for **MAX25-SoftModem** (device id `soft-crdop`). Authoritative everywhere CRDOP is named in MAX25-Stack. + +**Stack version:** MAX25-Stack **v1.0.0** · **Dev track:** `CUR999` in `$SRC/stacks/crdop/VERSION` + +**Stack dependency:** CRDOP sources live in `stacks/crdop/` and are **standalone-capable** in principle, but **MAX25-Stack is required for integration** (build, `max25d`, INI, operator tooling) until **CRDOP-v1.0.0** (or later) marks a mature standalone release. Current stack product: **MAX25-Stack-v1.0.0**. + +**Nature:** MAX25 in-house development — open development and test program for the kernel-ALSA sound-card modem. CRDOP is the standard modem; ARDOP is a separate optional plugin. + +**Research synthesis:** 2026-07-13 — modem and integration priorities synthesized into this ROADMAP and linked in-repo docs below. **No duplicate re-copy** of external notes into this tree. + +--- + +## Protocol taxonomy (research catalog) + +| Class | Description | CRDOP relation | +|-------|-------------|----------------| +| **A** | Classic AX.25 packet (HDLC, frequency-toggle line code) | **P0/P1/P2 targets** — 1200 AFSK, G3RUH, 300 HF | +| **B** | AX.25 + FEC extension | **Reference** — FX.25 informs FEC block design | +| **C** | New L2 (not AX.25 on-air) | **Reference** — IL2P lessons (no bit-stuffing, RS FEC) | +| **D** | HF/VHF data modems (Winlink ecosystem) | **Out of scope** on-air — separate ARDOP-plugin registry | +| **E** | Keyboard/chat modes (PSK31, RTTY, …) | **Out of scope** — boundary only | + +--- + +## MAX25-Stack integration (until CRDOP-v1.0.0+) + +| Topic | Policy | +|-------|--------| +| **Source tree** | All CRDOP sources in `stacks/crdop/` — standalone CMake possible; **operator integration via MAX25-Stack** until mature **CRDOP-v1.0.0** (or later) | +| **Build** | `MAX25_BUILD_CRDOP=ON` default — installs launcher, `lib/`, tools, INI | +| **Daemon** | `max25d` → `CrdopTcpBackend` / `AudioDummyBackend` — see [docs/MAX25-USAGE.md](docs/MAX25-USAGE.md) | +| **AX.25 runtime** | Native `ax25_codec.py` in MAX25 — no libax25 bundle in releases | +| **HyBBX** | External consumer — serial ownership boundary; CRDOP via host TCP attach | +| **Docs entry** | [docs/INDEX.md](docs/INDEX.md) · project rule [docs/CRDOP.md](../../docs/CRDOP.md) | + +HyBBX boundary: [docs/HYBBX.md](../../docs/HYBBX.md) · [docs/PLUGINS-DEVICE-MODEL.md](../../docs/PLUGINS-DEVICE-MODEL.md) + +--- + +## MAX25-Stack DEV-Level gate + +> Stack DEV-Levels are approximate (*ca.*) — see [docs/V2.0.0-SCOPE.md](../../docs/V2.0.0-SCOPE.md#dev-levels-roadmap-stack-wide). CRDOP modulation milestones (P0/P1/P2 below) are **subproject delivery** labels, not stack DEV-Levels. + +**DEV-Level 1 (*ca.* current — stack priority):** modular TCP/IP + Linux/FreeBSD platform compat. CRDOP work in DEV-Level 1 is **minimal/native only** — existing backend, bench DSP, enough OSS path for FreeBSD TCP/IP hub. **Do not expand CRDOP scope** ahead of DEV-Level 1. + +**DEV-Level 4 (*ca.*):** CRDOP expansion (OSS polish, G3RUH, acoustic validation, hardware docs). Modulation milestones below remain valid but are **scheduled for DEV-Level 4** unless required to unblock DEV-Level 1 platform work. + +**WebSocket** is **DEV-Level 3** — separate from CRDOP expansion. + +Stack roadmap: [docs/V2.0.0-SCOPE.md](../../docs/V2.0.0-SCOPE.md#dev-levels-roadmap-stack-wide). + +--- + +## Current phase: development and test + +| Area | Status | +|------|--------| +| MAX25-Stack standard build/install (`MAX25_BUILD_CRDOP=ON`) | **Done** | +| `max25d` `crdop-tcp` backend + offline tests | **Done** | +| INI scaffold, launcher (`scripts/crdopc`), HyBBX attach examples | **Done** | +| Native modem DSP — 1200 baud Bell 202 AFSK (`stacks/crdop/lib/`) | **In progress** | +| Acoustic AX.25 validation vs reference 1200 AFSK paths | **In progress** | +| Half- and full-duplex operator paths + FEC strategy | **In progress** | +| Open hardware interface documentation | **In progress** | +| G3RUH 9600–19200 (direct FSK rig path) | **Planned** (DEV-Level 4 / P1) | +| 300 baud HF AFSK | **Optional** (P2) | +| Speeds >19200 baud | **Out of scope** | + +--- + +## Modulation priority matrix + +Synthesized from research catalog — implementation order for CRDOP native DSP. + +| Priority | Mode | Modulation | Baud | RF path | Bands | CRDOP target | +|----------|------|------------|------|---------|-------|--------------| +| **P0** | AX.25 Packet | **AFSK Bell 202** (1200/2200 Hz) | **1200** | Audio AFSK (Mic/Spkr/Line) | **CB, VHF/UHF** | **Primary on-air** | +| **P0** | FEC + duplex profiles | Short frames, frequent TX | 1200 | same | CB, VHF | **Strategy** (see below) | +| **P1** | AX.25 Packet | **G3RUH FSK/GMSK** (scrambled baseband) | **9600–19200** | **Direct FSK** (varactor + discriminator) | VHF/UHF backbone | **Hard max 19200** | +| **P2** | AX.25 Packet | AFSK/FSK tone pairs | **300** | SSB audio (USB/LSB) | HF | **Optional** | +| — | 2400/4800 AFSK | V.26/V.27 class | 2400–4800 | Audio | VHF | Low priority incremental | +| 📋 | FX.25 / IL2P | FEC wrappers / new L2 | 1200+ | Audio or FSK PHY | VHF/HF | **Reference only** — inform FEC design | +| ❌ | VARA / PACTOR / WINMOR | Proprietary DSP | various | Audio / SSB / hardware | HF/VHF/CB | **Out of scope** | +| 📋 | ARDOP | 4FSK/PSK/QAM Winlink modes | adaptiv | Soundcard SSB | HF/VHF | **Separate plugin** — not CRDOP | + +**Rule:** CRDOP on-air goal is **AX.25-compatible PHY + framing** at P0/P1/P2. Winlink-class protocols (VARA, PACTOR, ARDOP OTA) are **not** CRDOP targets. + +--- + +## RF path matrix + +What CRDOP implements per physical interface — hardware choice drives baud ceiling. + +| RF path | Connection | Practical max baud | CB | Amateur (VHF/UHF) | Amateur (HF) | CRDOP milestone | +|---------|------------|-------------------|-----|-------------------|--------------|-----------------| +| **Audio AFSK** | Mic IN / Speaker OUT / Line | ~4800 theory; **1200 field** | ✅ K24/K25 | ✅ APRS, digipeater access | — | **P0 bench–on-air** | +| **Audio wide** | Line, de-emphasis off | up to ~9600 (difficult) | rare | experimental | — | not primary | +| **Direct FSK** | Varactor TX + FM discriminator RX | **9600–19200** | ❌ | ✅ backbone, sat | ❌ | **P1** (DEV-Level 4) | +| **SSB audio** | USB/LSB suppressed carrier | **300** packet; kHz-BW modes separate | ❌ | 10 m APRS exception | ✅ | **P2 optional** | + +**CB note:** 9600+ G3RUH requires direct RF modulation and wide IF — standard CB FM rigs are **not** 9k6-ready. CRDOP does **not** target high baud on 27 MHz. + +--- + +## Band context + +### CB (27 MHz CEPT) + +| Item | Detail | +|------|--------| +| **Primary channel** | **K24** (27.235 MHz) — Packet QSO, APRS-like traffic | +| **Net / forward** | **K25** (27.245 MHz) — nets, data traffic; proprietary FM-narrow DSP modes appear in practice (reference only — not CRDOP OTA) | +| **Legal (brief)** | EN 300 433 / ECC (11)03 — voice service primary; FM max **4 W** ERP typical; **data tolerated or explicit** on K24/K25 in several EU administrations. Operator responsibility. | +| **CRDOP mode** | **1200 AFSK Bell 202**, half-duplex, `persist=255`, short UI frames | +| **Why not 9600+** | CB rigs lack direct FSK port and wide-IF path; FM voice channel ≈3 kHz — fits 1200/2200 Hz, not G3RUH | + +### VHF/UHF (2 m, 70 cm, …) + +| Application | Baud | Modulation | CRDOP | +|-------------|------|------------|-------| +| **APRS**, packet BBS, digipeater **access** | 1200 | AFSK Bell 202 | **P0** — acoustic/line | +| Digipeater **backbone**, satellite | 9600–19200 | G3RUH FSK | **P1** — requires data-port radio | +| IL2P | 1200/2400 | BPSK/QPSK audio | 📋 reference | +| FX.25 | 1200+ | AX.25 + RS FEC wrapper | 📋 reference for FEC block sizing | + +### HF (160 m – 10 m) + +| Item | Detail | +|------|--------| +| **Standard packet** | **300 baud** AFSK/FSK, **200 Hz shift** | +| **Common tone pairs** | 1600/1800 Hz (common default), 2110/2310 Hz, others — dial frequency must match pair | +| **10 m exception** | 1200 AFSK on FM/USB for APRS — same PHY as VHF | +| **Winlink boundary** | Proprietary HF mail modems = **separate protocol ecosystems** — not CRDOP OTA goals | +| **CRDOP** | **P2 optional** — 300 bd after P0/P1 stable; SSB USB/LSB audio path | + +### Satellite / space (reference) + +| Application | Baud | Modulation | CRDOP | +|-------------|------|------------|-------| +| Legacy FM packet | 1200 | AFSK Bell 202 | P0 PHY same | +| Pacsat / wide-IF links | 9600–38400+ | G3RUH FSK/GMSK | P1 extended; >19200 out of scope | + +--- + +## Layer model + +CRDOP splits **host integration** from **on-air PHY**. Code mapping to `stacks/crdop/lib/`: + +``` +Application (APRS, BBS, HyBBX, max25-terminal) + ↓ +AX.25 v2.0 (UI / I-frames) ← stacks/daemon/ax25_codec.py + ↓ +HDLC + frequency-toggle + CRC-16 ← hdlc_codec.py (+ bell202_line_code.py) + ↓ +Modem PHY (AFSK / future G3RUH) ← afsk_modulator.py, afsk_demodulator.py + ↓ +MAX25 sound-proxy → kernel ALSA ← sound_proxy.py (+ include/crdop/sound_proxy.h scaffold) + ↓ +Radio (FM audio / direct FSK / SSB) +``` + +| Layer | Responsibility | Module / tool | +|-------|----------------|---------------| +| **L2 AX.25** | Addresses, UI body, FCS semantics | `ax25_codec.py` (daemon), used by `acoustic_engine.py` | +| **Framing** | HDLC flags `0x7E`, bit-stuffing, CRC-16-CCITT | `hdlc_codec.py` | +| **Line code** | Bell 202 frequency-toggle (bit 0 → tone change) | `bell202_line_code.py` | +| **PHY 1200** | Continuous-phase AFSK 1200/2200 Hz, Goertzel demod | `afsk_modulator.py`, `afsk_demodulator.py` | +| **PHY 9k6+** | G3RUH scrambler + FSK — **not yet in lib/** | planned P1 (DEV-Level 4) | +| **PHY 300 HF** | 200 Hz shift tone pairs — **not yet in lib/** | planned P2 optional | +| **Audio I/O** | ALSA capture/playback, no PulseAudio | `sound_proxy.py` | +| **Bench integration** | Loopback encode/decode, sniffer | `acoustic_engine.py` | +| **Host TCP** | M25-family ctrl + KISS-semantics data | `m25_host_protocol.py` → `audio-dummyd` | +| **Operator tools** | Sniff, calibrate mark/space | `tools/max25-signal-sniffer` | + +**Host vs on-air:** `m25_host_protocol.py` carries AX.25 UI **without** HDLC on the data port; `acoustic_engine.encode_ax25_ui()` builds full on-air HDLC + AFSK. Do not confuse MAX25 TCP host with ARDOP FEC/ARQ wire modes. + +--- + +## FEC, duplex & retransmission strategy + +**Leitprinzip:** *Lieber öfter senden als oft lange.* + +Packet on CB/VHF (poor SNR, QRM, half-duplex collisions) favours **short UI frames** and **frequent repetition** over long payloads and heavy block FEC. + +### Half-duplex (CB default) + +| Aspect | Strategy | +|--------|----------| +| Channel access | CSMA + persist — CB: **`persist=255`** | +| TX slot | Short UI frames; minimal but stable TXDELAY/TXTAIL | +| FEC | Shorter codewords; optional 2–3 repeats vs one large block | +| Retransmit | Application/beacon: **send more often** with small payloads (≤128 B info) | +| PTT | Explicit — VOX off on CB | +| INI | `duplex = half`, `EXTRADELAY 150` (CB profile) | + +### Full-duplex + +| Aspect | Strategy | +|--------|----------| +| Channel | Simultaneous TX/RX — no CSMA | +| FEC | Stronger codes possible when bandwidth allows | +| Audio | Echo suppression / separate paths — full-duplex sound card | +| INI | `duplex = full`, `EXTRADELAY 0` | + +### FEC layers + +| Level | Mechanism | CRDOP status | +|-------|-----------|--------------| +| **L1 modem** | CRC-16-CCITT (mandatory), preamble sync | **Done** in `hdlc_codec.py` | +| **L1 optional** | Lightweight FEC for short blocks | **Planned** — offline AWGN simulator | +| **L2 AX.25** | UI = no ARQ; I-frames = host may ARQ | Host policy | +| **Application** | Frequent beacons, staggered dual-TNC | HyBBX / max25d INI | +| **Reference** | FX.25 (RS wrapper), IL2P (new L2) | Inform design; not mandatory on-air | + +### FEC design takeaways (see [docs/FEC-SPEC.md](docs/FEC-SPEC.md)) + +| Source | Lesson for CRDOP | +|--------|------------------| +| **FX.25** | Reed-Solomon FEC **wrapper** around AX.25 — backward-compatible idea; AX.25 bit-stuffing weakens naive FEC | +| **IL2P** | Eliminates bit-stuffing; packet-sync LFSR scramble; RS FEC — **reference** for future optional L2, not P0 on-air | +| **Operator** | UI frames have no L2-ARQ — reliability via **short payloads + frequent TX** before heavy block FEC | +| **VARA / IL2P DSP** | Multi-stage FEC and adaptive rate — **reference for DSP strategy only**, not protocol target | + +### Host-protocol alignment + +| Component | Status | +|-----------|--------| +| Spec | **[HOST-PROTOCOL-SPEC.md](docs/HOST-PROTOCOL-SPEC.md)** — frozen v1.0.0 | +| `max25d CrdopTcpBackend` (default) | `PROTOCOLMODE KISS`, `[CRDOP AX25 UI …]` display | +| `m25_host_protocol.py` | Defaults **8515/8516** — matches `crdopc` INI | +| `audio-dummyd` bench | Same ports via CLI; `audio-dummy` max25d **host** mode may use **8520** when :8515 busy | + +--- + +## Implementation status (`stacks/crdop/lib/`) + +Honest mapping — 2026-07-13. + +| Component | File / tool | Status | Notes | +|-----------|-------------|--------|-------| +| Bell 202 line code | `bell202_line_code.py` | **Done** | Mark 1200 / Space 2200; frequency-toggle encode/decode | +| Line code tests | `test_bell202_line_code.py` | **Done** | Round-trip unit tests | +| AFSK modulator | `afsk_modulator.py` | **Done** | Continuous-phase PCM @ 48 kHz default | +| AFSK demodulator | `afsk_demodulator.py` | **Done** | Per-symbol Goertzel demod | +| HDLC + CRC | `hdlc_codec.py` | **Done** | Flags, bit-stuff, CRC-16 via `ax25_crc` | +| Sound-proxy (Python) | `sound_proxy.py` | **In progress** | `arecord`/`aplay` subprocess path; C header scaffold separate | +| Acoustic bench engine | `acoustic_engine.py` | **In progress** | Full loopback UI→HDLC→AFSK→demod→parse; RF path not closed | +| M25 host protocol | `m25_host_protocol.py` | **In progress** | TCP ctrl/data for bench; port alignment with `max25d` pending | +| `audio-dummyd` | `tools/audio-dummyd.py` | **In progress** | M25 host + `AcousticEngine`; default launcher target | +| `max25-signal-sniffer` | `tools/max25-signal-sniffer.py` | **In progress** | ALSA/WAV/loopback Bell 202 analysis | +| G3RUH 9600–19200 | — | **Planned** | No module yet; direct FSK radio required | +| 300 HF AFSK | — | **Planned** | Optional P2 | +| FEC codec (short blocks) | — | **Planned** | Offline inject-bit-error tests first | +| `speed_baud` INI → launcher | — | **Not implemented** | Research gap | +| Native C `crdopc` DSP binary | `include/crdop/` | **Scaffold** | Python lib leads; C port follows validation | +| RF automated CI | — | **Planned** | Manual acoustic/TNC tests today | + +--- + +## Milestones — modulation delivery (P0 / P1 / P2) + +> CRDOP subproject milestones — distinct from stack [DEV-Levels](../../docs/V2.0.0-SCOPE.md#dev-levels-roadmap-stack-wide). P0/P1/P2 work lands primarily in **DEV-Level 4** except bench/minimal path in DEV-Level 1. + +### P0 bench — Acoustic bench (current) + +**Goal:** Prove Bell 202 + HDLC + AX.25 UI in software loopback and ALSA bench — no RF required. + +| Deliverable | Status | +|-------------|--------| +| `bell202_line_code` + `afsk_*` + `hdlc_codec` unit path | Done | +| `acoustic_engine.loopback_self_test()` | Done | +| `max25-signal-sniffer --loopback` | In progress | +| `audio-dummyd` + `audio-dummy` max25d device | In progress | +| Document acceptable sound cards (community list) | Planned | +| Host protocol single definition (launcher ↔ max25d) | Planned | + +**Exit criteria:** Stable loopback decode; sniffer reports AX.25 UI from generated PCM; `test_crdop_backend.py` green. + +### P0 on-air — CB/VHF 1200 on-air + +**Goal:** Field-validated 1200 AFSK interoperable with standard AX.25 packet peers on CB and VHF APRS-class paths. + +| Deliverable | Status | +|-------------|--------| +| Acoustic and line-level coupling validation | In progress | +| Line interface to FM rig (CB channels) | Planned | +| Half-duplex PTT timing in INI + launcher | In progress | +| FEC strategy: short frames, beacon stagger | In progress | +| Hardware interface guide (generic spec) | In progress — [docs/HARDWARE-INTERFACE.md](docs/HARDWARE-INTERFACE.md) | +| Decode parity vs reference 1200 AFSK captures | Planned | + +**Exit criteria:** Verified AX.25 UI exchange with at least one reference 1200 AFSK path (acoustic or line-level). + +### P1 — G3RUH 9600–19200 + +**Goal:** Direct-FSK VHF/UHF backbone rates up to **19200 baud** hard maximum. + +| Deliverable | Status | +|-------------|--------| +| G3RUH scrambler + FSK mod/demod module | Planned | +| Data-port radio integration docs | Planned | +| Wide-IF rig requirements checklist | Planned | +| Loopback with recorded IQ/baseband fixtures | Planned | + +**Prerequisite:** P0 on-air stable; radio with suitable data port and wide-IF path for direct FSK. + +**Exit criteria:** Scrambler-compatible exchange with G3RUH reference captures or peer at 9600 minimum. + +### P2 — HF 300 baud (optional) + +**Goal:** SSB USB/LSB 300 bd packet — 1600/1800 Hz pair first. + +| Deliverable | Status | +|-------------|--------| +| 300 bd demod/mod (200 Hz shift) | Planned | +| Tone-pair INI selection | Planned | +| HF dial-frequency calculator notes | Planned | + +**Scope:** Optional; does not block CRDOP-v0.5 if deferred. + +--- + +## Validation targets (generic) + +Interop validation uses **reference signal classes**, not product endorsements. + +| Reference class | Mode | Interface | CRDOP milestone | +|-----------------|------|-----------|-----------------| +| **1200 AFSK hardware modem** | Bell 202 | Serial KISS or acoustic | P0 bench–on-air | +| **Kernel bit-bang modem** | 1200 AFSK | Parallel/serial | P0 bench tone reference | +| **Mobile KISS → audio** | 1200 AFSK | Bluetooth/audio bridge | P0 on-air mobile | +| **G3RUH FSK modem** | 4800–19200 | Direct FSK data port | P1 golden reference | +| **Multi-mode HF TNC** | 300 HF + 1200 VHF | Multi-mode serial | P2 optional | +| **Sound-card reference decoder** | 300–9600+ | ALSA | Software decode parity (1200) | + +Open-hardware documentation: [docs/HARDWARE-INTERFACE.md](docs/HARDWARE-INTERFACE.md). Audio QA: [docs/SOUNDCARD-QUALIFICATION.md](docs/SOUNDCARD-QUALIFICATION.md). + +--- + +## Technical goals (unchanged intent) + +1. **1200 baud Bell 202** — reference AX.25 AFSK; primary on-air target (P0). +2. **Acoustic compatibility** — verifiable against standard 1200 AFSK tone paths. +3. **Hardware modem parity** — sound IN/OUT + radio usable like serial/KISS hardware in `max25d`. +4. **Documented DIY hardware** — generic interface spec for builders ([HARDWARE-INTERFACE.md](docs/HARDWARE-INTERFACE.md)). +5. **Good sound card enforcement** — kernel ALSA direct; MAX25 sound-proxy API; requirements scale with baud. +6. **G3RUH to 19200** — incremental after 1200 stable (P1). +7. **FEC via operator strategy** — frequent short sends before heavy on-air FEC (P0). + +--- + +## Not in MAX25 releases + +| Item | Policy | +|------|--------| +| `vendor/ardopcf` dev tree | Local dev only (`CRDOP_VENDOR_ARDOPCF=ON`); not in release install | +| **libax25 / ax25-tools / ax25-apps** bundles | Reference tarballs only — not shipped as CRDOP dependency | +| VARA / PACTOR / WINMOR implementations | Out of scope | +| Speeds **>19200 baud** | Out of scope (current phase) | + +--- + +## Next release checklist (CRDOP-v0.5 target) + +- [ ] Loopback + acoustic bench test automation in CI +- [ ] Published acceptable sound-card list (community + lab) +- [ ] Native `crdopc` / `audio-dummyd` path unified in default build +- [ ] Field validation on CB reference channel (K24) +- [ ] Hardware interface guide — [docs/HARDWARE-INTERFACE.md](docs/HARDWARE-INTERFACE.md) +- [ ] Host protocol alignment (`m25_host_protocol` ↔ `max25d` backend) +- [ ] `speed_baud` INI wired to launcher + +--- + +## Research synthesis (document map) + +Synthesis **2026-07-13** — topics below are covered in this ROADMAP and in-repo CRDOP docs. Site-specific operator INI and lab hardware runbooks stay outside this tree. + +| Source topic | Synthesized in this ROADMAP | +|----------------|----------------------------| +| `reference/modems-packet-radio/MASTER-SOLUTIONS-CATALOG.md` | Taxonomy, baud table, RF matrix, CRDOP priorities | +| `cb-packet-solutions.md` | CB band context, K24/K25, legal, 1200-only rationale | +| `amateur-vhf-uhf-solutions.md` | VHF/UHF table, APRS, G3RUH backbone | +| `amateur-hf-packet-solutions.md` | HF 300 bd, Winlink boundary | +| `l2-fec-protocols.md` | FEC layers, FX.25/IL2P takeaways | +| `modern-softmodems.md` | Class D out-of-scope, decision matrix (generic) | +| `bell-202-afsk-1200.md` | P0 PHY parameters, P0 bench–on-air | +| `g3ruh-fsk-9600-19200.md` | P1 direct FSK, DEV-Level 4 | +| `hf-300-baud-afsk.md` | P2 tone pairs, SSB path | +| `nrzi-hdlc-ax25-layers.md` | Layer model + `lib/` mapping | +| `MODULATION-REFERENCE.md` | Modulation priority matrix | +| `soundcard-requirements.md` | ALSA requirements, baud scaling | +| `acoustic-coupling-hardware.md` | → [HARDWARE-INTERFACE.md](docs/HARDWARE-INTERFACE.md) | +| `projects/max25-stack/2026-07-13-crdop-development-master.md` | Product scope, MAX25 integration | +| `projects/max25-stack/2026-07-13-crdop-fec-duplex-strategy.md` | FEC/duplex section | +| `reference/2026-07-12-cb-eu-packet.md` | CB legal/channel detail | +| `reference/2026-07-12-packet-radio-fundamentals.md` | Background — no CRDOP code impact | +| `reference/2026-07-12-modems-linux.md` | Linux sound-modem landscape — reference | +| `reference/2026-07-12-linux-ax25-ecosystem.md` | Kernel AX.25 deprecation context | +| `projects/integration/2026-07-12-max25-hybbx-boundary-final.md` | HyBBX attach / serial ownership | +| `DEVELOPMENT.md` (operator P0–P2) | Parallel to stack release — see [docs/DEVELOPER.md](docs/DEVELOPER.md) | + +**Not in CRDOP tree:** site TNC hardware runbooks, production INI, serial acceptance logs — operator-local only. + +### Scenario → CRDOP mode (generic, from research) + +| Scenario | CRDOP mode | +|----------|------------| +| CB packet QSO (FM audio) | **P0** — 1200 AFSK AX.25 | +| CB net with heavy QRM | **P0** + short-frame FEC strategy; proprietary FM-narrow DSP = separate protocol | +| VHF APRS / digipeater access | **P0** — 1200 AFSK | +| VHF/UHF backbone | **P1** — G3RUH direct FSK (≤19200) | +| HF packet 300 bd | **P2** optional | +| HF mail / Winlink-class | **Out of scope** — separate protocols | +| MAX25 RF broadcast / soft-modem path | **P0** target — native CRDOP in stack | + +--- + +## In-repo technical docs + +| Document | Topic | +|----------|--------| +| [docs/INDEX.md](docs/INDEX.md) | Documentation index | +| [docs/SOFTMODEM.md](docs/SOFTMODEM.md) | Product definition | +| [docs/MAX25-USAGE.md](docs/MAX25-USAGE.md) | MAX25-Stack operator guide | +| [docs/DEVELOPER.md](docs/DEVELOPER.md) | Developer guide | +| [docs/HARDWARE-INTERFACE.md](docs/HARDWARE-INTERFACE.md) | Generic radio interface spec | +| [docs/LICENSE-USAGE.md](docs/LICENSE-USAGE.md) | GPLv3 private + commercial | +| [docs/HOST-PROTOCOL-SPEC.md](../stacks/crdop/docs/HOST-PROTOCOL-SPEC.md) | M25 host wire (frozen) | +| [docs/ACOUSTIC-TEST-PROTOCOL.md](../stacks/crdop/docs/ACOUSTIC-TEST-PROTOCOL.md) | P0 bench | +| [docs/FEC-SPEC.md](../stacks/crdop/docs/FEC-SPEC.md) | FEC/duplex parameters | +| [docs/SOUNDCARD-QUALIFICATION.md](../stacks/crdop/docs/SOUNDCARD-QUALIFICATION.md) | Audio interface QA | +| [docs/G3RUH-DESIGN.md](../stacks/crdop/docs/G3RUH-DESIGN.md) | P1 — 9600–19200 direct FSK | +| [docs/CRDOP.md](../../docs/CRDOP.md) | Static project rule | +| [docs/AUDIO-ARCHITECTURE.md](docs/AUDIO-ARCHITECTURE.md) | Kernel ALSA, sound-proxy | +| [docs/PROTOCOL.md](docs/PROTOCOL.md) | Host TCP interface | +| [docs/CONFIG.md](docs/CONFIG.md) | INI keys | + +--- + +*Roadmap synthesized 2026-07-13 from modem catalog and MAX25 integration notes. Update when DEV-Level gates close or modulation priorities change.* +.* diff --git a/stacks/crdop/VERSION b/stacks/crdop/VERSION new file mode 100644 index 0000000..2f5e876 --- /dev/null +++ b/stacks/crdop/VERSION @@ -0,0 +1 @@ +CUR999 diff --git a/stacks/crdop/cmake/CRDOPCompiler.cmake b/stacks/crdop/cmake/CRDOPCompiler.cmake new file mode 100644 index 0000000..e1aab37 --- /dev/null +++ b/stacks/crdop/cmake/CRDOPCompiler.cmake @@ -0,0 +1,37 @@ +# CRDOP compiler detection and warning flags (GCC, Clang, MSVC) + +if(CMAKE_C_COMPILER_ID STREQUAL "GNU") + set(CRDOP_COMPILER "gcc") +elseif(CMAKE_C_COMPILER_ID MATCHES "Clang|AppleClang") + set(CRDOP_COMPILER "clang") +elseif(MSVC) + set(CRDOP_COMPILER "msvc") +else() + set(CRDOP_COMPILER "${CMAKE_C_COMPILER_ID}") +endif() + +message(STATUS "CRDOP compiler=${CRDOP_COMPILER} (${CMAKE_C_COMPILER_ID} ${CMAKE_C_COMPILER_VERSION})") + +function(crdop_set_compile_options target) + if(MSVC) + target_compile_options(${target} PRIVATE /W3 /utf-8) + else() + target_compile_options(${target} PRIVATE -Wall -Wextra -Wno-unused-parameter) + if(CRDOP_COMPILER STREQUAL "clang") + target_compile_options(${target} PRIVATE + -Wno-gnu-zero-variadic-macro-arguments + -Wno-deprecated-non-prototype + ) + endif() + if(CMAKE_BUILD_TYPE STREQUAL "Release") + target_compile_options(${target} PRIVATE -O2) + endif() + endif() +endfunction() + +include(CheckLinkerFlag) +if(NOT WIN32 AND NOT APPLE) + check_linker_flag(C "-Wl,--wrap=malloc" CRDOP_LINKER_SUPPORTS_WRAP) +else() + set(CRDOP_LINKER_SUPPORTS_WRAP FALSE) +endif() diff --git a/stacks/crdop/cmake/CRDOPPlatform.cmake b/stacks/crdop/cmake/CRDOPPlatform.cmake new file mode 100644 index 0000000..9d78683 --- /dev/null +++ b/stacks/crdop/cmake/CRDOPPlatform.cmake @@ -0,0 +1,71 @@ +# CRDOP platform / architecture detection (standalone project) +# +# Tested: Linux, *BSD, Windows, macOS — GCC and Clang/LLVM + +if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(aarch64|arm64)$") + set(CRDOP_ARCH "aarch64") +elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "^(armv7|armv7l|arm)$") + set(CRDOP_ARCH "arm32") +elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "^(i[3-6]86)$") + set(CRDOP_ARCH "x86") +elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|AMD64)$") + set(CRDOP_ARCH "x86_64") +else() + set(CRDOP_ARCH "${CMAKE_SYSTEM_PROCESSOR}") +endif() + +set(CRDOP_PLATFORM_INCLUDES "") +set(CRDOP_PLATFORM_LIBS "") +set(CRDOP_PLATFORM_SOURCES "") + +if(WIN32) + set(CRDOP_PLATFORM "windows") + set(CRDOP_PLATFORM_SOURCES + src/windows/Waveout.c + lib/hid/hid.c + ) + set(CRDOP_PLATFORM_LIBS wsock32 winmm setupapi ws2_32) +elseif(APPLE) + set(CRDOP_PLATFORM "darwin") + set(CRDOP_PLATFORM_SOURCES + src/linux/ALSASound.c + src/linux/LinSerial.c + src/darwin/alsa_shim.c + ) + list(APPEND CRDOP_PLATFORM_INCLUDES "${VENDOR_DIR}/src") + find_library(CRDOP_COREAUDIO_LIB CoreAudio REQUIRED) + find_library(CRDOP_AUDIOTOOLBOX_LIB AudioToolbox REQUIRED) + find_library(CRDOP_COREFOUNDATION_LIB CoreFoundation REQUIRED) + set(CRDOP_PLATFORM_LIBS + ${CRDOP_COREAUDIO_LIB} + ${CRDOP_AUDIOTOOLBOX_LIB} + ${CRDOP_COREFOUNDATION_LIB} + pthread m + ) +elseif(CMAKE_SYSTEM_NAME STREQUAL "FreeBSD") + set(CRDOP_PLATFORM "freebsd-oss") + set(CRDOP_PLATFORM_SOURCES + src/linux/LinSerial.c + ) + set(CRDOP_PLATFORM_LIBS pthread m) +elseif(CMAKE_SYSTEM_NAME MATCHES "OpenBSD|NetBSD|DragonFly") + set(CRDOP_PLATFORM "bsd-oss") + set(CRDOP_PLATFORM_SOURCES + src/linux/LinSerial.c + ) + set(CRDOP_PLATFORM_LIBS pthread m) +elseif(UNIX) + set(CRDOP_PLATFORM "linux") + set(CRDOP_PLATFORM_SOURCES + src/linux/ALSASound.c + src/linux/LinSerial.c + ) + find_package(PkgConfig REQUIRED) + pkg_check_modules(ALSA REQUIRED alsa) + set(CRDOP_PLATFORM_INCLUDES ${ALSA_INCLUDE_DIRS}) + set(CRDOP_PLATFORM_LIBS ${ALSA_LIBRARIES} rt pthread m) +else() + message(FATAL_ERROR "CRDOP: unsupported system ${CMAKE_SYSTEM_NAME}") +endif() + +message(STATUS "CRDOP platform=${CRDOP_PLATFORM} arch=${CRDOP_ARCH} system=${CMAKE_SYSTEM_NAME}") diff --git a/stacks/crdop/cmake/CRDOPTests.cmake b/stacks/crdop/cmake/CRDOPTests.cmake new file mode 100644 index 0000000..64b6b48 --- /dev/null +++ b/stacks/crdop/cmake/CRDOPTests.cmake @@ -0,0 +1,104 @@ +# Upstream ardopcf cmocka unit tests (Unix + GNU/Clang ld --wrap) + +option(CRDOP_BUILD_TESTS "Build and register upstream ardopcf unit tests" OFF) + +if(NOT CRDOP_BUILD_TESTS) + return() +endif() + +if(WIN32 OR APPLE) + message(STATUS "CRDOP_BUILD_TESTS: skipped on ${CRDOP_PLATFORM} (cmocka/--wrap needs Unix GNU or lld linker)") + return() +endif() + +find_package(PkgConfig QUIET) +if(PkgConfig_FOUND) + pkg_check_modules(CMOCKA QUIET cmocka) +endif() + +if(NOT CMOCKA_FOUND) + find_path(CMOCKA_INCLUDE_DIR cmocka.h) + find_library(CMOCKA_LIBRARY cmocka) + if(CMOCKA_INCLUDE_DIR AND CMOCKA_LIBRARY) + set(CMOCKA_FOUND TRUE) + set(CMOCKA_INCLUDE_DIRS "${CMOCKA_INCLUDE_DIR}") + set(CMOCKA_LIBRARIES "${CMOCKA_LIBRARY}") + endif() +endif() + +if(NOT CMOCKA_FOUND) + message(WARNING "CRDOP_BUILD_TESTS: libcmocka not found — install cmocka dev package (see docs/BUILD.md)") + return() +endif() + +if(NOT CRDOP_LINKER_SUPPORTS_WRAP) + message(WARNING "CRDOP_BUILD_TESTS: linker does not support --wrap; unit tests disabled") + return() +endif() + +enable_testing() + +set(CRDOP_VENDOR_TEST_DIR "${VENDOR_DIR}/test/ardop") +set(CRDOP_VENDOR_TEST_COMMON + "${CRDOP_VENDOR_TEST_DIR}/setup.c" +) + +function(crdop_add_vendor_test name) + cmake_parse_arguments(ARG "" "" "SOURCES;WRAP" ${ARGN}) + set(test_src "${CRDOP_VENDOR_TEST_DIR}/test_${name}.c") + if(NOT EXISTS "${test_src}") + message(FATAL_ERROR "CRDOP test source missing: ${test_src}") + endif() + + set(sources "${test_src}" ${CRDOP_VENDOR_TEST_COMMON}) + if(ARG_SOURCES) + foreach(rel IN LISTS ARG_SOURCES) + list(APPEND sources "${VENDOR_DIR}/${rel}") + endforeach() + endif() + + add_executable(crdop_test_${name} ${sources}) + crdop_set_compile_options(crdop_test_${name}) + + target_include_directories(crdop_test_${name} PRIVATE + "${CRDOP_ROOT}/include" + "${VENDOR_DIR}/src" + "${VENDOR_DIR}/lib" + ${CRDOP_PLATFORM_INCLUDES} + ) + + if(ARG_SOURCES) + target_link_libraries(crdop_test_${name} PRIVATE m pthread) + if(CRDOP_PLATFORM STREQUAL "linux") + target_link_libraries(crdop_test_${name} PRIVATE rt) + endif() + else() + target_link_libraries(crdop_test_${name} PRIVATE ardopcf_vendor) + endif() + + target_link_libraries(crdop_test_${name} PRIVATE ${CMOCKA_LIBRARIES}) + target_include_directories(crdop_test_${name} PRIVATE ${CMOCKA_INCLUDE_DIRS}) + + if(ARG_WRAP) + foreach(sym IN LISTS ARG_WRAP) + target_link_options(crdop_test_${name} PRIVATE "-Wl,--wrap=${sym}") + endforeach() + endif() + + add_test(NAME crdop_test_${name} COMMAND crdop_test_${name}) +endfunction() + +crdop_add_vendor_test(ARDOPCommon) +crdop_add_vendor_test(HostInterface) +crdop_add_vendor_test(Locator) +crdop_add_vendor_test(Packed6) +crdop_add_vendor_test(StationId) +crdop_add_vendor_test(log + SOURCES + src/common/log_file.c + src/common/log.c + WRAP + fopen fclose fwrite fflush freopen +) + +message(STATUS "CRDOP_BUILD_TESTS: registered upstream ardopcf cmocka tests") diff --git a/stacks/crdop/cmake/CRDOPVendorArdopcf.cmake b/stacks/crdop/cmake/CRDOPVendorArdopcf.cmake new file mode 100644 index 0000000..e82c605 --- /dev/null +++ b/stacks/crdop/cmake/CRDOPVendorArdopcf.cmake @@ -0,0 +1,127 @@ +# Legacy vendor/ardopcf build (opt-in only; not shipped in MAX25 v1). + +include("${CRDOP_ROOT}/cmake/CRDOPCompiler.cmake") +include("${CRDOP_ROOT}/cmake/CRDOPPlatform.cmake") + +option(CRDOP_BUILD_TESTS "Build upstream ardopcf cmocka unit tests" OFF) + +set(CRDOP_VENDOR_COMMON + lib/rawhid/rawhid.c + lib/rockliff/rrs.c + lib/ws_server/ws_server.c + src/common/ARDOPC.c + src/common/ARDOPCommon.c + src/common/ardopSampleArrays.c + src/common/ARQ.c + src/common/BusyDetect.c + src/common/FEC.c + src/common/FFT.c + src/common/HostInterface.c + src/common/Locator.c + src/common/log_file.c + src/common/log.c + src/common/Modulate.c + src/common/Packed6.c + src/common/RXO.c + src/common/sdft.c + src/common/SoundInput.c + src/common/StationId.c + src/common/TCPHostInterface.c + src/common/txframe.c + src/common/wav.c + src/common/Webgui.c + src/common/noise.c + src/common/ardopcf.c +) + +set(CRDOP_VENDOR_SRCS "") +foreach(rel IN LISTS CRDOP_VENDOR_COMMON CRDOP_PLATFORM_SOURCES) + list(APPEND CRDOP_VENDOR_SRCS "${VENDOR_DIR}/${rel}") +endforeach() + +set(TXT2C "${CMAKE_BINARY_DIR}/txt2c") +add_executable(txt2c "${VENDOR_DIR}/lib/txt2c/txt2c.c") +crdop_set_compile_options(txt2c) +set_target_properties(txt2c PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}") + +set(WEBGUI_HTML_C "${CMAKE_BINARY_DIR}/gen-webgui.html.c") +set(WEBGUI_JS_C "${CMAKE_BINARY_DIR}/gen-webgui.js.c") +add_custom_command( + OUTPUT "${WEBGUI_HTML_C}" + COMMAND txt2c "${VENDOR_DIR}/webgui/webgui.html" "${WEBGUI_HTML_C}" webgui_html + DEPENDS txt2c "${VENDOR_DIR}/webgui/webgui.html" + COMMENT "Embedding webgui.html" +) +add_custom_command( + OUTPUT "${WEBGUI_JS_C}" + COMMAND txt2c "${VENDOR_DIR}/webgui/webgui.js" "${WEBGUI_JS_C}" webgui_js + DEPENDS txt2c "${VENDOR_DIR}/webgui/webgui.js" + COMMENT "Embedding webgui.js" +) + +add_library(ardopcf_vendor_objs OBJECT + ${CRDOP_VENDOR_SRCS} + "${WEBGUI_HTML_C}" + "${WEBGUI_JS_C}" +) + +target_include_directories(ardopcf_vendor_objs PRIVATE + "${CRDOP_ROOT}/include" + "${VENDOR_DIR}/src" + "${VENDOR_DIR}/lib" + ${CRDOP_PLATFORM_INCLUDES} +) + +target_compile_definitions(ardopcf_vendor_objs PRIVATE CRDOP_BUILD=1) + +if(WIN32) + target_compile_definitions(ardopcf_vendor_objs PRIVATE WIN32 _CRT_SECURE_NO_DEPRECATE) +endif() + +if(APPLE) + target_compile_definitions(ardopcf_vendor_objs PRIVATE __APPLE__) +endif() + +crdop_set_compile_options(ardopcf_vendor_objs) + +add_executable(crdopc + "${CRDOP_ROOT}/src/crdop_version.c" + $<TARGET_OBJECTS:ardopcf_vendor_objs> +) + +target_include_directories(crdopc PRIVATE + "${CRDOP_ROOT}/include" + "${VENDOR_DIR}/src" + "${VENDOR_DIR}/lib" + ${CRDOP_PLATFORM_INCLUDES} +) + +target_compile_definitions(crdopc PRIVATE CRDOP_BUILD=1) + +if(WIN32) + target_compile_definitions(crdopc PRIVATE WIN32 _CRT_SECURE_NO_DEPRECATE) +endif() + +if(APPLE) + target_compile_definitions(crdopc PRIVATE __APPLE__) +endif() + +crdop_set_compile_options(crdopc) +target_link_libraries(crdopc PRIVATE ${CRDOP_PLATFORM_LIBS}) + +add_library(ardopcf_vendor STATIC + "${CRDOP_ROOT}/src/crdop_version.c" + $<TARGET_OBJECTS:ardopcf_vendor_objs> +) +target_include_directories(ardopcf_vendor PUBLIC + "${CRDOP_ROOT}/include" + "${VENDOR_DIR}/src" + "${VENDOR_DIR}/lib" + ${CRDOP_PLATFORM_INCLUDES} +) +target_compile_definitions(ardopcf_vendor PUBLIC CRDOP_BUILD=1) +target_link_libraries(ardopcf_vendor PUBLIC ${CRDOP_PLATFORM_LIBS}) + +include("${CRDOP_ROOT}/cmake/CRDOPTests.cmake") + +install(TARGETS crdopc RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) diff --git a/stacks/crdop/cmake/toolchains/aarch64-linux-gnu.cmake b/stacks/crdop/cmake/toolchains/aarch64-linux-gnu.cmake new file mode 100644 index 0000000..59e3a37 --- /dev/null +++ b/stacks/crdop/cmake/toolchains/aarch64-linux-gnu.cmake @@ -0,0 +1,4 @@ +set(CMAKE_SYSTEM_NAME Linux) +set(CMAKE_SYSTEM_PROCESSOR aarch64) +set(CMAKE_C_COMPILER aarch64-linux-gnu-gcc) +set(CMAKE_CXX_COMPILER aarch64-linux-gnu-g++) diff --git a/stacks/crdop/cmake/toolchains/arm-linux-gnueabihf.cmake b/stacks/crdop/cmake/toolchains/arm-linux-gnueabihf.cmake new file mode 100644 index 0000000..bb70e64 --- /dev/null +++ b/stacks/crdop/cmake/toolchains/arm-linux-gnueabihf.cmake @@ -0,0 +1,4 @@ +set(CMAKE_SYSTEM_NAME Linux) +set(CMAKE_SYSTEM_PROCESSOR armv7l) +set(CMAKE_C_COMPILER arm-linux-gnueabihf-gcc) +set(CMAKE_CXX_COMPILER arm-linux-gnueabihf-g++) diff --git a/stacks/crdop/cmake/toolchains/x86_64-w64-mingw32.cmake b/stacks/crdop/cmake/toolchains/x86_64-w64-mingw32.cmake new file mode 100644 index 0000000..1b44215 --- /dev/null +++ b/stacks/crdop/cmake/toolchains/x86_64-w64-mingw32.cmake @@ -0,0 +1,4 @@ +set(CMAKE_SYSTEM_NAME Windows) +set(CMAKE_SYSTEM_PROCESSOR x86_64) +set(CMAKE_C_COMPILER x86_64-w64-mingw32-gcc) +set(CMAKE_CXX_COMPILER x86_64-w64-mingw32-g++) diff --git a/stacks/crdop/docs/ACOUSTIC-TEST-PROTOCOL.md b/stacks/crdop/docs/ACOUSTIC-TEST-PROTOCOL.md new file mode 100644 index 0000000..9656c2a --- /dev/null +++ b/stacks/crdop/docs/ACOUSTIC-TEST-PROTOCOL.md @@ -0,0 +1,84 @@ +# Acoustic bench test protocol — P0 bench acceptance + +**Purpose:** Close CRDOP P0 bench without RF. All steps runnable offline in CI or on a dev host. + +**Related:** [HOST-PROTOCOL-SPEC.md](HOST-PROTOCOL-SPEC.md) · [DEVELOPER.md](DEVELOPER.md) · [ROADMAP.md](../ROADMAP.md) P0 bench. + +--- + +## Prerequisites + +| Item | Requirement | +|------|-------------| +| Build | `MAX25_BUILD_CRDOP=ON`, `cmake --install` or source tree | +| Python | 3.10+ with repo on `PYTHONPATH` | +| ALSA | Optional for tests 3–4; loopback tests need no hardware | +| PulseAudio | **Off** or bypassed (`no_pulse=yes` in INI) | + +--- + +## Test matrix + +| ID | Name | RF | Pass criteria | +|----|------|-----|---------------| +| **T0** | Bell 202 round-trip | No | `test_bell202_line_code.py` green | +| **T1** | HDLC + CRC | No | `hdlc_codec` round-trip in unit tests | +| **T2** | Software loopback | No | `max25-signal-sniffer.py --loopback` decodes AX.25 UI | +| **T3** | `acoustic_engine` self-test | No | `AcousticEngine.loopback_self_test()` returns True | +| **T4** | `audio-dummyd` + host TCP | No | Connect :8515/:8516, `PROTOCOLMODE KISS` → `OK` | +| **T5** | `max25d` audio-dummy backend | No | `test_audio_dummy_backend.py` green | +| **T6** | `max25d` crdop-tcp backend | No | `test_crdop_backend.py` green | +| **T7** | ALSA loopback cable | No* | Sniffer decodes tone from `aplay`/`arecord` pair | +| **T8** | WAV fixture | No | Sniffer `--wav` decodes recorded Bell 202 capture | + +\*Requires `snd-aloop` or physical loopback interface. + +--- + +## Commands (copy-paste) + +```bash +cd MAX25-Stack +./scripts/build.sh + +# T0–T1 +python3 -m pytest stacks/crdop/test_bell202_line_code.py -q +python3 -m pytest stacks/daemon/test_crdop_backend.py stacks/daemon/test_audio_dummy_backend.py -q + +# T2 +python3 stacks/crdop/tools/max25-signal-sniffer.py --loopback + +# T4 (background) +python3 stacks/crdop/tools/audio-dummyd.py --ctrl-port 8515 --data-port 8516 & +sleep 1 +python3 -c " +import socket +c=socket.create_connection(('127.0.0.1',8515),timeout=2) +c.sendall(b'PROTOCOLMODE KISS\n') +print(c.recv(64)) +" +``` + +--- + +## P0 bench exit gate + +**100 % P0 bench** when: + +- [x] T0, T1, T2, T5, T6 pass in `release-check.sh` +- [ ] T3 documented green in CI log (or explicit skip reason) +- [ ] T4 manual or scripted smoke in release-check +- [ ] Host ports unified: launcher, `audio-dummyd`, `max25d` use [HOST-PROTOCOL-SPEC.md](HOST-PROTOCOL-SPEC.md) defaults + +P0 on-air starts only after P0 bench exit — see [docs/HARDWARE-ACCEPTANCE.md](../../docs/HARDWARE-ACCEPTANCE.md). + +--- + +## Failure triage + +| Symptom | Check | +|---------|-------| +| Wrong tones | Mark 1200 Hz / Space 2200 Hz, sample rate 48000 | +| CRC fail | `ax25_codec.ax25_crc` vs `hdlc_codec` | +| TCP refuse | Port 8515 free; `audio-dummyd` running for host tests | +| ALSA xrun | `period_frames`, cable levels — [SOUNDCARD-QUALIFICATION.md](SOUNDCARD-QUALIFICATION.md) | diff --git a/stacks/crdop/docs/AUDIO-ARCHITECTURE.md b/stacks/crdop/docs/AUDIO-ARCHITECTURE.md new file mode 100644 index 0000000..6320253 --- /dev/null +++ b/stacks/crdop/docs/AUDIO-ARCHITECTURE.md @@ -0,0 +1,80 @@ +# CRDOP audio architecture — kernel ALSA only + +**CRDOP** = stack acronym for **MAX25-SoftModem** (device id `soft-crdop`). + +MAX25-SoftModem (CRDOP) talks to the radio **through the sound hardware directly**. There is **no PulseAudio**, **no PipeWire default route**, and **no desktop sound server** in the path. + +## Stack layers + +``` +┌─────────────┐ M25/1 TCP ┌──────────────┐ host TCP ┌─────────────┐ +│ max25d │ ◄────────────► │ CRDOP modem │ ◄──────────► │ max25- │ +│ (stack) │ │ (DSP/AX.25) │ │ terminal │ +└─────────────┘ └──────┬───────┘ └─────────────┘ + │ + MAX25 sound-proxy + (buffer, timing, duplex) + │ + ▼ + libasound (userspace) + │ + ▼ + Linux kernel ALSA + (/dev/snd/*, drivers) + │ + ▼ + Sound hardware (USB/PCI codec) + │ + ▼ + Radio interface (line / acoustic) +``` + +| Layer | Role | Allowed | +|-------|------|---------| +| **max25d** | Device lifecycle, `CrdopTcpBackend`, no audio I/O | — | +| **CRDOP modem** | AFSK encode/decode, AX.25 framing, duplex policy | — | +| **MAX25 sound-proxy** | **Only** audio shim between modem and ALSA — period size, xrun recovery, hw params, PTT timing hooks | MAX25-owned | +| **ALSA userspace** | `snd_pcm_*` on **`hw:` / `plughw:`** devices | Direct libasound | +| **Kernel ALSA** | Driver, DMA, card registry | Required | +| **PulseAudio / PipeWire** | — | **Forbidden** in production path | + +The sound-proxy is **not** a second network hop — it is the in-process (or co-process) module that owns ALSA opens and keeps modem timing off the desktop audio stack. + +## Why no PulseAudio + +PulseAudio and PipeWire insert mixing, resampling, and variable latency between applications and the kernel. CRDOP must: + +- Reproduce **exact mark/space frequencies** on playback +- Sample capture with **stable phase** for demodulation +- Meet **tighter deadlines** as baud rises toward 19200 + +A userspace mixer breaks those guarantees. Operators must bind **card and device explicitly** (`hw:Card,Device` or tested `plughw:`). + +## Operator setup + +1. Identify hardware: `arecord -l` and `aplay -l` (kernel cards, not `pulse` pseudo-devices). +2. Set INI `[audio] capture` and `playback` to **hardware ALSA names**. +3. Ensure no session steals the device (`pasuspender` / stop PulseAudio on dedicated hosts). +4. Verify loopback before on-air traffic. + +Example INI: + +```ini +[audio] +backend = alsa-kernel +no_pulse = yes +capture = hw:1,0 +playback = hw:1,0 +``` + +Dedicated packet-radio hosts: prefer **single-purpose Linux** without a desktop sound daemon, or `PULSE_SERVER=` / `PIPEWIRE_RUNTIME_DIR=` unset for the `crdop` service unit. + +## Development (Eigenentwicklung) + +Native CRDOP will implement the sound-proxy in-tree (`include/crdop/sound_proxy.h`). Legacy optional vendor builds used `ALSASound.c` with the same rule: **open ALSA devices directly**. + +## See also + +- [SOFTMODEM.md](SOFTMODEM.md) — product scope, baud, duplex +- [CONFIG.md](CONFIG.md) — INI keys +- [BUILD.md](BUILD.md) — `libasound2-dev` dependency diff --git a/stacks/crdop/docs/BUILD.md b/stacks/crdop/docs/BUILD.md new file mode 100644 index 0000000..e72fac0 --- /dev/null +++ b/stacks/crdop/docs/BUILD.md @@ -0,0 +1,99 @@ +# Build + +Standalone CRDOP (**MAX25-SoftModem** — MAX25-SoftModem) — embedded `vendor/ardopcf/`, no submodules. + +## Requirements + +- CMake ≥ 3.16 +- **GCC** or **Clang / LLVM** +- Platform libraries (see table below) +- Optional tests: **cmocka** (Linux, *BSD) + +## Platform matrix (tested) + +| OS | Toolchain | Audio / libs | Status | +|----|-----------|----------------|--------| +| **Linux** | GCC, Clang | `libasound2-dev`, `pkg-config` | tested | +| **FreeBSD** | GCC, Clang | `alsa-lib`, `pkgconf` | tested | +| **OpenBSD / NetBSD** | GCC, Clang | `alsa-lib` if available | experimental | +| **Windows** | MinGW-w64, MSVC | WinMM (built-in) | **untested** | +| **macOS** | Clang (Xcode) | CoreAudio (built-in) | **untested** | + +Other operating systems are not tested. + +### Debian / Ubuntu + +```bash +sudo apt install build-essential cmake libasound2-dev pkg-config +sudo apt install libcmocka-dev # optional unit tests +``` + +### FreeBSD + +```bash +pkg install cmake alsa-lib pkgconf cmocka +``` + +### macOS + +```bash +xcode-select --install +# or: brew install cmake +``` + +### Windows (MinGW-w64 on Linux host) + +```bash +sudo apt install mingw-w64 +CRDOP_TOOLCHAIN=x86_64-w64-mingw32 ./scripts/build-crdop.sh +# → build/crdopc.exe +``` + +Native Windows: Visual Studio 2022+ with CMake, or MSYS2 MinGW. + +## Build + +```bash +./scripts/build-crdop.sh +CC=clang ./scripts/build-crdop.sh +./scripts/test-all.sh # smoke + cmocka (Unix) +``` + +Output: `build/crdopc` (Windows: `build/crdopc.exe`) + +## Install + +```bash +./scripts/install-crdop.sh # → /usr/local +CRDOP_PREFIX=$HOME/.local ./scripts/install-crdop.sh +``` + +Installs: + +| Path | Contents | +|------|----------| +| `bin/crdopc` | Modem binary | +| `bin/crdop` | Profile launcher (`scripts/crdopc`) | +| `share/crdop/` | INI examples, `VERSION` | + +Run: `crdop` or `CRDOP_INI=~/.config/crdop/crdop.ini crdop` + +## Cross-compile (Linux host) + +```bash +CRDOP_TOOLCHAIN=aarch64-linux-gnu ./scripts/build-crdop.sh +CRDOP_TOOLCHAIN=arm-linux-gnueabihf ./scripts/build-crdop.sh +CRDOP_TOOLCHAIN=x86_64-w64-mingw32 ./scripts/build-crdop.sh +``` + +## Troubleshooting + +| Problem | Fix | +|---------|-----| +| `vendor/ardopcf missing` | Full repo checkout | +| No ALSA (Linux/BSD) | Install dev package / `alsa-lib` port | +| macOS build fails | Xcode CLI tools; CMake ≥ 3.16 | +| Tests skipped | Install cmocka dev package | +| Stale build | `rm -rf build && ./scripts/build-crdop.sh` | + +Vendor refresh (maintainers): `./scripts/refresh-vendor-ardopcf.sh` diff --git a/stacks/crdop/docs/CHANGELOG.md b/stacks/crdop/docs/CHANGELOG.md new file mode 100644 index 0000000..2bbfbf6 --- /dev/null +++ b/stacks/crdop/docs/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +## [CUR999] — dev track (current) + +**CUR999** — internal dev track id for MAX25-SoftModem inside MAX25-Stack v1.0.0. Dev track `CUR999`; ships with MAX25-Stack v1.0.0. + +- Dev track id `CUR999` in `$SRC/stacks/crdop/VERSION` +- Native modem DSP in development; optional legacy vendor build (`-DCRDOP_VENDOR_ARDOPCF=ON`); **ARDOP-plugin** documented separately +- `max25d` `crdop-tcp` backend, INI scaffold, launcher, HyBBX attach examples + +## [0.5.0] — planned (not yet released) + +Target standalone CRDOP v0.5 after v1.0.0 stack ship. + +- Standalone CRDOP modem (`crdopc`) with native DSP +- Embedded optional `vendor/ardopcf` (MIT) for dev-only legacy builds only +- CB / dual / amateur profiles; Linux and *BSD tested + +## [0.1.0-l2-cb] — 2026-07-03 + +Experimental CB profile bootstrap. + +## [0.0.0-bootstrap] + +Repository scaffold. diff --git a/stacks/crdop/docs/CONFIG.md b/stacks/crdop/docs/CONFIG.md new file mode 100644 index 0000000..821fd1c --- /dev/null +++ b/stacks/crdop/docs/CONFIG.md @@ -0,0 +1,50 @@ +# Configuration + +`scripts/crdopc` reads INI → starts native `audio-dummyd` (M25/KISS host). + +## INI search order + +1. `$CRDOP_INI` +2. `~/.config/crdop/crdop.ini` +3. `share/crdop.ini.example` + +Templates: `share/crdop.ini.example` · `share/crdop-dual.ini.example` · `share/crdop-amateur.ini.example` + +## Keys + +### `[profile]` + +| Key | Default | Values | +|-----|---------|--------| +| `radio_profile` | `cb` | `cb` · `dual` · `amateur` | + +### `[modem]` + +| Key | Default | Notes | +|-----|---------|-------| +| `arq_bandwidth` | profile | `500MAX` (cb/dual) · `1000MAX` (amateur) | +| `duplex` | `half` | `half` · `full` (`full` → `EXTRADELAY 0`) | +| `extra_delay_ms` | _(auto)_ | Override delay; empty = profile default | + +### `[host]` · `[mycall]` · `[audio]` + +| Section | Key | Default | Notes | +|---------|-----|---------|-------| +| host | `port` | `8515` | TCP control | +| mycall | `call` | `NOCALL-0` | On-air ID | +| audio | `backend` | `alsa-kernel` | Kernel ALSA only — see [AUDIO-ARCHITECTURE.md](AUDIO-ARCHITECTURE.md) | +| audio | `no_pulse` | `yes` | Reject PulseAudio/PipeWire pseudo devices | +| audio | `capture` | _(required)_ | e.g. `hw:1,0` from `arecord -l` | +| audio | `playback` | _(required)_ | e.g. `hw:1,0` from `aplay -l` | +| audio | `sample_rate` | modem default | Hz | +| audio | `period_frames` | auto | Buffer tuning | + +**No PulseAudio** in the production path. The **MAX25 sound-proxy** opens ALSA directly; `scripts/crdopc` clears Pulse/PipeWire session env when `no_pulse=yes`. + +## Environment + +| Variable | Purpose | +|----------|---------| +| `CRDOP_INI` | Config file path | + +Examples: [EXAMPLES.md](EXAMPLES.md) diff --git a/stacks/crdop/docs/DEVELOPER.md b/stacks/crdop/docs/DEVELOPER.md new file mode 100644 index 0000000..af43252 --- /dev/null +++ b/stacks/crdop/docs/DEVELOPER.md @@ -0,0 +1,188 @@ +# CRDOP developer guide + +**CRDOP** = **MAX25-SoftModem**. In-house GPLv3 sound-card modem (`stacks/crdop/`). Project rule: [docs/CRDOP.md](../../../docs/CRDOP.md). + +--- + +## Source tree + +``` +stacks/crdop/ +├── lib/ # Native DSP (Python — leads validation) +│ ├── bell202_line_code.py +│ ├── afsk_modulator.py +│ ├── afsk_demodulator.py +│ ├── hdlc_codec.py +│ ├── sound_proxy.py +│ ├── m25_host_protocol.py +│ ├── acoustic_engine.py +│ └── test_bell202_line_code.py +├── include/crdop/ # C API scaffold (future native binary) +│ ├── sound_proxy.h +│ └── version.h +├── tools/ +│ ├── audio-dummyd.py # M25 host TCP daemon +│ └── max25-signal-sniffer.py +├── scripts/ +│ ├── crdopc # Launcher → audio-dummyd by default +│ ├── build-crdop.sh +│ └── install-crdop.sh +├── share/ # crdop.ini.example (+ dual, amateur) +├── cmake/ # Platform, vendor (dev-only), tests +├── docs/ # This tree +├── ROADMAP.md +└── CMakeLists.txt +``` + +**Daemon integration:** `stacks/daemon/device_backends.py` → `CrdopTcpBackend` (`soft-crdop`), `AudioDummyBackend` (`audio-dummy`). + +**AX.25 codec:** `stacks/daemon/ax25_codec.py` — shared with on-air framing in `acoustic_engine.py`. + +--- + +## Module map + +| Module | Layer | Responsibility | +|--------|-------|----------------| +| `bell202_line_code.py` | Line code | Bell 202 frequency-toggle: bit 0 → tone change; mark 1200 Hz / space 2200 Hz | +| `afsk_modulator.py` | PHY TX | Continuous-phase AFSK PCM @ configurable sample rate (default 48 kHz) | +| `afsk_demodulator.py` | PHY RX | Per-symbol Goertzel demod (1200 baud class) | +| `hdlc_codec.py` | Framing | HDLC flags `0x7E`, bit-stuffing, CRC-16-CCITT | +| `sound_proxy.py` | Audio I/O | ALSA capture/playback via `arecord`/`aplay`; buffer config | +| `m25_host_protocol.py` | Host | TCP ctrl (:8515) + data (:8516); M25-family + KISS-semantics | +| `acoustic_engine.py` | Bench | Full path: AX.25 UI → HDLC → AFSK → demod → parse; loopback self-test | + +**Host vs on-air:** `m25_host_protocol.py` carries AX.25 UI on the data port **without** HDLC wrapping. `acoustic_engine.encode_ax25_ui()` builds full on-air HDLC + AFSK for RF/audio output. + +**Not yet in `lib/`:** G3RUH scrambler/FSK (P1), 300 baud HF AFSK (P2), optional short-block FEC. + +--- + +## Running tests + +### MAX25-Stack (recommended) + +```bash +./scripts/build.sh +cmake --build build --target max25_test # all offline tests +cmake --build build --target max25_daemon_smoke # daemon + CRDOP subset +``` + +CRDOP-related tests in `max25_daemon_smoke`: + +| Test file | What it checks | +|-----------|----------------| +| `stacks/crdop/lib/test_bell202_line_code.py` | Bell 202 encode/decode round-trip | +| `stacks/daemon/test_crdop_backend.py` | `CrdopTcpBackend` attach, TX (native M25/KISS) | +| `stacks/daemon/test_audio_dummy_backend.py` | `audio-dummy` loopback backend | +| `stacks/daemon/test_multi_device.py` | Multi-device registry incl. `soft-crdop` | + +### Direct (no CMake) + +```bash +python3 stacks/crdop/lib/test_bell202_line_code.py +python3 stacks/daemon/test_crdop_backend.py +python3 stacks/daemon/test_audio_dummy_backend.py +``` + +### Sniffer loopback (no audio hardware) + +```bash +python3 stacks/crdop/tools/max25-signal-sniffer.py --loopback +# or after install: +max25-signal-sniffer --loopback +``` + +Exit 0 when loopback decode produces AX.25 UI lines. + +### audio-dummyd bench + +```bash +python3 stacks/crdop/tools/audio-dummyd.py --ctrl-port 8515 --data-port 8516 +# Terminal probe (separate shell): +max25-terminal -H 127.0.0.1 -P 8515 +``` + +### Standalone CRDOP vendor tests (dev-only) + +When built with `-DCRDOP_VENDOR_ARDOPCF=ON` and cmocka installed: + +```bash +./scripts/build-crdop.sh +CRDOP_BUILD_TESTS=ON ./scripts/test-all.sh +``` + +Vendor tree is **never** installed in MAX25 releases. + +--- + +## Extending the modem + +### Add a modulation mode + +1. Add PHY module under `lib/` (e.g. `g3ruh_fsk.py`). +2. Wire into `acoustic_engine.py` — select by `baud` / `modulation` parameter. +3. Extend `crdop.ini` `[modem]` keys in `share/crdop.ini.example` and [CONFIG.md](CONFIG.md). +4. Add unit tests beside the module (`test_*.py`). +5. Update [ROADMAP.md](../ROADMAP.md) implementation status table. + +### Extend host protocol + +1. Edit `m25_host_protocol.py` — keep ctrl/data port split. +2. Mirror changes in `stacks/daemon/device_backends.py` (`CrdopTcpBackend`). +3. Document in [PROTOCOL.md](PROTOCOL.md). +4. Add cases to `test_crdop_backend.py`. + +### C port path + +`include/crdop/sound_proxy.h` is the C ABI target. Python `lib/` leads until acoustic bench gates close; then port validated algorithms to native `crdopc` binary. + +--- + +## Build modes + +| Mode | CMake flag | Output | +|------|------------|--------| +| **MAX25-Stack (default)** | `MAX25_BUILD_CRDOP=ON` | `bin/crdop`, `bin/audio-dummyd`, `bin/max25-signal-sniffer`, `share/crdop/` | +| **MAX25 without CRDOP** | `-DMAX25_BUILD_CRDOP=OFF` | No CRDOP install artifacts | +| **Standalone CRDOP** | `stacks/crdop/scripts/build-crdop.sh` | Same scaffold; optional vendor `crdopc` with `-DCRDOP_VENDOR_ARDOPCF=ON` | +| **Dev vendor ARDOP** | `-DCRDOP_VENDOR_ARDOPCF=ON` | Legacy ardopcf binary — local only, never released | + +### Install paths (MAX25 `cmake --install`) + +| Artifact | Destination | +|----------|-------------| +| `crdop` (launcher) | `${CMAKE_INSTALL_PREFIX}/bin/` | +| `audio-dummyd`, `max25-signal-sniffer` | `${CMAKE_INSTALL_PREFIX}/bin/` | +| `crdop.ini.example`, `lib/*.py`, `VERSION` | `${CMAKE_INSTALL_PREFIX}/share/crdop/` | + +--- + +## Dependencies + +| Component | Requirement | +|-----------|-------------| +| Runtime | Python 3, ALSA utils (`arecord`/`aplay`) on Linux | +| Build | CMake ≥ 3.16 | +| Audio | `libasound` — kernel ALSA direct opens only | +| Tests | None beyond Python 3 (cmocka optional for vendor C tests) | + +--- + +## Conventions + +- **English** for all shipped docs and user-facing strings. +- **No PulseAudio / PipeWire** in production audio path. +- **ARDOP** is a separate optional plugin — not part of CRDOP — see [plugins/external/ardop/README.md](../../../plugins/external/ardop/README.md). +- Baud ceiling: **19200** (P1 G3RUH direct FSK). Above that: out of scope. + +--- + +## Related + +| Doc | Topic | +|-----|--------| +| [MAX25-USAGE.md](MAX25-USAGE.md) | Operator workflow in MAX25-Stack | +| [HARDWARE-INTERFACE.md](HARDWARE-INTERFACE.md) | Radio interface spec for builders | +| [AUDIO-ARCHITECTURE.md](AUDIO-ARCHITECTURE.md) | Sound-proxy design | +| [ROADMAP.md](../ROADMAP.md) | P0/P1/P2 milestones and module status | diff --git a/stacks/crdop/docs/EXAMPLES.md b/stacks/crdop/docs/EXAMPLES.md new file mode 100644 index 0000000..b6d32c5 --- /dev/null +++ b/stacks/crdop/docs/EXAMPLES.md @@ -0,0 +1,91 @@ +# Examples + +MAX25-Stack integration (plugins, `max25d`, terminal): [MAX25-USAGE.md](MAX25-USAGE.md). + +## Build + +```bash +./scripts/build-crdop.sh +CC=clang ./scripts/build-crdop.sh +CRDOP_BUILD_TESTS=ON ./scripts/test-all.sh # needs libcmocka-dev +``` + +Cross-build (Linux host): see [BUILD.md](BUILD.md). + +## Run — CB (default, native M25 host) + +```bash +./scripts/crdopc +# or explicit example INI: +CRDOP_INI=share/crdop.ini.example ./scripts/crdopc +``` + +This starts `audio-dummyd` with native M25/KISS host on TCP :8515/:8516. + +Copy config for daily use: + +```bash +mkdir -p ~/.config/crdop +cp share/crdop.ini.example ~/.config/crdop/crdop.ini +$EDITOR ~/.config/crdop/crdop.ini +./scripts/crdopc +``` + +## Run — dual (CB ↔ amateur) + +```bash +CRDOP_INI=share/crdop-dual.ini.example ./scripts/crdopc +``` + +## Run — amateur (secondary) + +```bash +CRDOP_INI=share/crdop-amateur.ini.example ./scripts/crdopc +``` + +## Full-duplex CB + +`share/crdop.ini.example` with: + +```ini +[modem] +duplex = full +arq_bandwidth = 500MAX +``` + +## Custom call / ALSA devices + +```ini +[mycall] +call = CB01-0 + +[audio] +capture = plughw:1,0 +playback = plughw:1,0 +``` + +Pass extra args after port (reserved for future native modem flags): + +```bash +./scripts/crdopc 8515 +``` + +## Direct audio-dummyd (no launcher) + +```bash +./build/bin/audio-dummyd --ctrl-port 8515 --data-port 8516 +``` + +## max25d integration + +```ini +[devices] +soft-crdop = crdop:default + +[device.soft-crdop] +host = 127.0.0.1 +port = 8515 +listen = yes +``` + +See [../../docs/PLUGINS-DEVICE-MODEL.md](../../docs/PLUGINS-DEVICE-MODEL.md). diff --git a/stacks/crdop/docs/FEC-SPEC.md b/stacks/crdop/docs/FEC-SPEC.md new file mode 100644 index 0000000..53b1f93 --- /dev/null +++ b/stacks/crdop/docs/FEC-SPEC.md @@ -0,0 +1,99 @@ +# CRDOP FEC and duplex — implementation spec + +**Principle:** *Send more often with shorter frames rather than one long payload.* + +**Related:** [ROADMAP.md](../ROADMAP.md) FEC section · [docs/CRDOP.md](../../../docs/CRDOP.md) + +**Status:** MAX25-Stack **v1.0.0** / dev track `CUR999` — L1 optional FEC **planned**; operator-layer strategy **active now**. + +--- + +## Layer model + +| Layer | Mechanism | v1.0.0 | +|-------|-----------|--------| +| **L3 Application** | Short beacons, staggered intervals | HyBBX / `max25d.ini` | +| **L2 AX.25** | UI = no ARQ; I-frames optional ARQ via host | `ax25_codec.py` | +| **L1 Modem** | CRC-16-CCITT mandatory; optional repeat/FEC blocks | `hdlc_codec.py` — CRC done | +| **PHY** | Bell 202 AFSK (P0) | `afsk_*` | + +--- + +## Half-duplex (CB default) + +| Parameter | Value | INI / host | +|-----------|-------|------------| +| Duplex | half | `[modem] duplex = half` | +| CSMA persist | 255 | KISS `0x02` / CB profile | +| Extra TX delay | 150 ms class | `extra_delay_ms` / `EXTRADELAY` | +| Max UI info | **≤128 bytes** recommended | Application | +| PTT | explicit | No VOX on CB | + +### Retransmission strategy (no L2 ARQ on UI) + +1. Prefer **N small identical beacons** over one large burst. +2. Stagger dual-radio sites (`ax25_auto_interval` + offset). +3. Optional L1: **2–3 repeats** of same short codeword vs one RS block >256 B. + +--- + +## Full-duplex + +| Parameter | Value | +|-----------|-------| +| `duplex` | `full` | +| `EXTRADELAY` | `0` | +| FEC | Stronger codes allowed when bandwidth permits | +| Audio | Full-duplex sound card; echo control required | + +--- + +## Reference protocols (not on-air targets) + +| Protocol | Lesson for CRDOP | +|----------|------------------| +| **FX.25** | RS wrapper around AX.25; bit-stuffing hurts naive FEC | +| **IL2P** | No bit-stuffing; packet-sync scramble — reference for future L2 | +| **VARA DSP** | Multi-stage FEC — PHY reference only | + +--- + +## Planned L1 FEC (post P0 on-air) + +| Requirement | Target | +|-------------|--------| +| Block size | ≤64 B payload + CRC per repeat unit | +| Latency | <300 ms added on 1200 bd half-duplex | +| Compatibility | Plain AX.25 receivers must not break (FX.25-style optional path) | +| Test | AWGN + FM clip simulator before on-air | + +### INI keys (future) + +```ini +[fec] +enabled = no # default off v1.0.0 +repeat_count = 0 # 0 = no repeat; 2–3 for CB marginal +profile = half # half | full +``` + +--- + +## CB 1200 parameters (normative operator) + +| Parameter | Value | +|-----------|-------| +| Baud | 1200 | +| Modulation | Bell 202 (1200/2200 Hz) | +| Frame | AX.25 UI | +| persist | 255 | +| Beacon | 300 s + stagger (site INI) | + +--- + +## Implementation checklist + +- [x] CRC-16 in `hdlc_codec.py` +- [x] Duplex INI keys in `crdop.ini.example` +- [ ] `fec.enabled` INI + launcher +- [ ] Offline BER vs frame-length simulator +- [ ] Document measured CB SNR thresholds (field) diff --git a/stacks/crdop/docs/G3RUH-DESIGN.md b/stacks/crdop/docs/G3RUH-DESIGN.md new file mode 100644 index 0000000..ae1a164 --- /dev/null +++ b/stacks/crdop/docs/G3RUH-DESIGN.md @@ -0,0 +1,72 @@ +# G3RUH FSK — CRDOP P1 design spec + +**Scope:** 9600–**19200** baud direct FSK. **Hard maximum 19200** — nothing above. + +**Prerequisite:** P0 on-air (1200 AFSK) stable. **Not** CB FM mic path. + +See [ROADMAP.md](../ROADMAP.md) P1 milestone (stack scheduling: **DEV-Level 4**). + +--- + +## PHY difference from P0 + +| | 1200 AFSK (P0) | G3RUH FSK (P1) | +|---|----------------|----------------| +| Modulation | Audio tones 1200/2200 Hz | Scrambled baseband FSK | +| RF path | Mic / speaker / line | **Direct** varactor + discriminator | +| CB FM voice channel | ✅ | ❌ | +| VHF backbone | Access only at 1200 | ✅ at 9600–19200 | + +--- + +## Parameters (normative) + +| Baud | Peak deviation | RF bandwidth (~-60 dB) | +|------|----------------|------------------------| +| 4800 | ±1.5 kHz | ~8 kHz | +| **9600** | **±3 kHz** | **~20 kHz** | +| **19200** | **±6 kHz** | **~30 kHz** | + +CRDOP implements **up to 19200** only. + +--- + +## Radio interface requirements + +| Requirement | Detail | +|-------------|--------| +| TX | Direct modulation input (varactor), not acoustic | +| RX | Discriminator / data output, wide IF | +| Filter | Flat amplitude 10 Hz–5 kHz, constant group delay | +| Scrambler | G3RUH compatible — interoperate with standard 9600 peers | + +Standard narrow FM voice audio **fails** eye-diagram tests at 9600+. + +--- + +## Software modules (planned) + +| Module | Path | Status | +|--------|------|--------| +| Scrambler | `stacks/crdop/lib/g3ruh_scrambler.py` | Not started | +| FSK modulator | `stacks/crdop/lib/g3ruh_modulator.py` | Not started | +| FSK demodulator | `stacks/crdop/lib/g3ruh_demodulator.py` | Not started | +| HDLC/framing | Reuse `hdlc_codec.py` | Done | + +Host protocol unchanged — same M25/KISS TCP; baud selected via INI `speed_baud` (future). + +--- + +## P1 exit criteria + +1. Loopback with recorded baseband fixtures at 9600. +2. Over-the-air exchange with reference 9600 FSK peer. +3. 19200 optional stretch after 9600 stable. + +--- + +## Out of scope + +- 38400+ satellite/backbone rates +- Acoustic coupling at 9600+ +- CB 27 MHz deployment diff --git a/stacks/crdop/docs/HARDWARE-INTERFACE.md b/stacks/crdop/docs/HARDWARE-INTERFACE.md new file mode 100644 index 0000000..9c36e21 --- /dev/null +++ b/stacks/crdop/docs/HARDWARE-INTERFACE.md @@ -0,0 +1,194 @@ +# CRDOP hardware interface specification + +Generic specification for builders who manufacture **radio ↔ sound-card interfaces** for CRDOP (MAX25-SoftModem). **No product-specific wiring examples** — apply these rules to your own design. + +**CRDOP** = **MAX25-SoftModem**. Project rule: [docs/CRDOP.md](../../../docs/CRDOP.md). + +--- + +## Scope + +CRDOP connects a host sound card to a transceiver via one of: + +| Path | Typical use | Max practical baud | +|------|-------------|-------------------| +| **Audio AFSK** | Mic IN / speaker OUT / line level | **1200** (P0) | +| **Direct FSK** | Varactor TX + FM discriminator RX | **9600–19200** (P1) | +| **SSB audio** | USB/LSB suppressed carrier | **300** (P2 optional) | +| **Acoustic coupling** | Speaker ↔ microphone (test / field) | **1200** | + +RF path matrix and delivery milestones: [ROADMAP.md](../ROADMAP.md). + +--- + +## Audio levels + +### Line level (preferred) + +| Signal | Typical level | Notes | +|--------|---------------|-------| +| **Playback → radio mic IN** | −20 to −10 dBV (adjust per rig sensitivity) | Use potentiometer or fixed attenuator; verify with sniffer or oscilloscope | +| **Radio speaker/line → capture** | 0.1–1.0 V RMS typical | Attenuate hot speaker outputs before codec input | +| **Full-scale digital** | Avoid clipping on TX and RX | Leave 6–12 dB headroom | + +### Mic level + +Some transceivers expose mic-level inputs only. Attenuate line output to mic sensitivity (often 5–20 mV). Use coupling capacitor if DC bias is present on the mic jack. + +### Isolation + +| Method | Purpose | +|--------|---------| +| **Audio transformer** (1:1 or step-down) | Galvanic isolation; breaks ground loops | +| **Opto-isolator** (digital PTT path) | Isolate PTT from radio ground | +| **Separate ground reference** | Star ground at interface PCB; one tie point to radio chassis if required | + +**Ground loops** cause hum, false transitions, and decode failures. Always isolate PC USB ground from radio chassis when using direct cable coupling. + +--- + +## PTT control + +CRDOP is **half-duplex by default**. The host must key the transmitter before playback and unkey after tail. + +| Method | Interface | Caveats | +|--------|-----------|---------| +| **GPIO** | USB-serial RTS/DTR, parallel port, dedicated GPIO | Cleanest; software-controlled timing | +| **Serial CAT** | Radio command port | Latency depends on radio firmware | +| **VOX** | Audio-derived keying | **Not recommended** for packet — tail timing unpredictable; false keys from noise | +| **Manual** | Operator PTT | Acceptable for bench only | + +### Timing parameters + +Configure in `crdop.ini` / `max25d.ini`: + +| Parameter | Typical half-duplex | Full-duplex | +|-----------|--------------------|-------------| +| Pre-TX delay (`extra_delay_ms`) | 80–200 ms | 0 | +| TXTAIL | Match radio requirement | N/A | +| PTT release | After last space tone + margin | N/A | + +**Rule:** explicit GPIO/serial PTT beats VOX for AX.25 UI bursts. + +--- + +## Acoustic coupling + +Valid for **bench validation** and some field setups: + +``` +[Host speaker] ──air gap──► [Radio mic] +[Radio speaker] ──air gap──► [Host mic] +``` + +| Principle | Detail | +|-----------|--------| +| **Distance** | Minimize path loss; foam gasket reduces room noise | +| **Frequency response** | FM voice channel ≈ 3 kHz — sufficient for 1200/2200 Hz Bell 202 | +| **Level** | Low volume on playback; avoid speaker distortion | +| **Duplex** | Half-duplex only — acoustic feedback if TX and RX paths overlap | +| **Test** | Run `max25-signal-sniffer --loopback` on host before acoustic trial | + +Acoustic coupling does **not** scale to G3RUH direct FSK (P1). + +--- + +## Direct FSK path (P1 — G3RUH) + +When audio AFSK is insufficient (9600–19200 baud): + +| Element | Requirement | +|---------|-------------| +| **TX** | Discriminator input bypassed; varactor or dedicated FSK input on data port | +| **RX** | Flat FM discriminator output (de-emphasis off or compensated) | +| **Bandwidth** | ~20 kHz IF for 9600; wider for 19200 | +| **Scrambler** | G3RUH polynomial — implemented in future `lib/` module | +| **Radio** | Data-port capable; standard FM mic/speaker path **not** sufficient | + +CB FM rigs at 27 MHz are generally **not** direct-FSK capable at 9600+ — P1 targets VHF/UHF amateur backbone paths. + +--- + +## Sound card requirements + +| Requirement | 1200 baud (P0) | 9600–19200 (P1) | +|-------------|----------------|-----------------| +| **Sample rate** | ≥ 48 kHz recommended | ≥ 48 kHz; 96 kHz preferred | +| **Bit depth** | 16-bit mono | 16-bit mono | +| **Duplex** | Full-duplex USB codec or half-duplex with PTT | Full-duplex, low jitter | +| **Clock** | Stable crystal; avoid drift during TX | Jitter < 50 ppm class | +| **Driver** | Kernel ALSA (`hw:` / `plughw:`) | Same | +| **Isolation** | Transformer on radio side | Same + verified loopback | + +### Unsuitable interfaces + +- Onboard laptop mic/speaker without external interface +- Bluetooth audio bridges +- PulseAudio / PipeWire virtual devices +- Consumer "USB sound sticks" without galvanic isolation + +Configure devices in `crdop.ini`: + +```ini +[audio] +backend = alsa-kernel +no_pulse = yes +capture = plughw:1,0 +playback = plughw:1,0 +sample_rate = 48000 +``` + +Verify with: + +```bash +max25-signal-sniffer --loopback +max25-signal-sniffer -D plughw:1,0 -t 2.0 +``` + +--- + +## Compliance and operator responsibility + +| Topic | Policy | +|-------|--------| +| **Transmit licence** | Operator must hold valid authorization for the band and mode | +| **CB (27 MHz EU)** | EN 300 433 / national rules — voice primary; data may be restricted or channel-specific | +| **Amateur bands** | Band plan, power limits, identification | +| **EMC** | Builder responsible for conducted/radiated emissions from interface hardware | +| **CRDOP software** | GPLv3 — no warranty; see [LICENSE-USAGE.md](LICENSE-USAGE.md) | + +CRDOP documentation does **not** constitute legal advice. Check local regulations before on-air operation. + +--- + +## Acceptance checklist (builder) + +| Step | Pass criterion | +|------|----------------| +| Loopback | `max25-signal-sniffer --loopback` decodes test UI | +| Level | Mark/space tones within radio spec; no clipping | +| Isolation | No hum on decode when PC and radio share mains | +| PTT | Key before TX audio; clean tail; no truncation | +| On-air | AX.25 UI exchange with known-good peer at 1200 baud | +| Duplex | Half-duplex CSMA behaviour matches `persist` / `extra_delay` INI | + +--- + +## Related in-repo docs + +| Topic | Document | +|-------|----------| +| Sound-card requirements | [SOUNDCARD-QUALIFICATION.md](SOUNDCARD-QUALIFICATION.md) | +| Bell 202 / 1200 AFSK (P0) | [ROADMAP.md](../ROADMAP.md) · [SOFTMODEM.md](SOFTMODEM.md) | +| G3RUH 9600–19200 (P1) | [G3RUH-DESIGN.md](G3RUH-DESIGN.md) | +| AX.25 / packet context | [docs/PACKET-RADIO.md](../../../docs/PACKET-RADIO.md) | + +--- + +## Related + +| Doc | Topic | +|-----|--------| +| [AUDIO-ARCHITECTURE.md](AUDIO-ARCHITECTURE.md) | Kernel ALSA, sound-proxy | +| [MAX25-USAGE.md](MAX25-USAGE.md) | Stack integration and bench modes | +| [ROADMAP.md](../ROADMAP.md) | P0/P1/P2 phases | diff --git a/stacks/crdop/docs/HOST-PROTOCOL-SPEC.md b/stacks/crdop/docs/HOST-PROTOCOL-SPEC.md new file mode 100644 index 0000000..d45c7e5 --- /dev/null +++ b/stacks/crdop/docs/HOST-PROTOCOL-SPEC.md @@ -0,0 +1,103 @@ +# M25 SoftModem host protocol — implementation spec (frozen for v1.0.0) + +**CRDOP** = **MAX25-SoftModem** · Native host wire for MAX25-SoftModem · **Not** ARDOP FEC/ARQ. + +**Normative code:** `stacks/crdop/lib/m25_host_protocol.py` · **Consumer:** `max25d` `CrdopTcpBackend`, `audio-dummyd`, `crdopc` launcher. + +--- + +## Transport + +| Channel | Default port | Wire | +|---------|--------------|------| +| **Control** | **8515** | Line-oriented ASCII, **`\n`** terminated (native) | +| **Data** | **8516** (= ctrl + 1) | Binary AX.25 UI body **without** HDLC flags or FCS | + +| Mode | Control terminator | Data semantics | +|------|-------------------|----------------| +| **Native M25/KISS** | `\n` | KISS DATA equivalent — 16+ byte UI body | + +### Bench alternate ports + +When `soft-crdop` and `audio-dummyd` run on one host, bench daemon may use **8520/8521** via CLI. Production `crdopc` and INI default **8515/8516**. + +--- + +## Control commands (native) + +All commands are single lines. Reply is one line ending `\n`. + +| Command | Args | Reply | Behaviour | +|---------|------|-------|-----------| +| `INITIALIZE` | — | `OK` | Reset session state | +| `PROTOCOLMODE` | `KISS` | `OK` | Select KISS-semantics data channel | +| `MYCALL` | `CALL-SSID` | `OK` | Store station ID (uppercase) | +| `LISTEN` | `TRUE`/`FALSE` | `OK` | RX enable (default true) | +| `PING` | — | `OK` | Liveness | +| `STATUS` | — | `STATUS ready mycall=…` | State snapshot | +| *(unknown)* | — | `ERR unknown command` | | + +**Not supported on native path:** `PROTOCOLMODE FEC`, `FECMODE`, ARQ bandwidth commands — ARDOP is a separate optional plugin, not part of CRDOP. + +--- + +## Data channel + +### TX (host → modem) + +1. TCP connect to data port. +2. Send **one** AX.25 UI frame body: `dest(7) + src(7) + ctrl(1) + pid(1) + info(0..256)`. +3. Modem responds `OK` or `ERR …` (ASCII line). + +Modem builds on-air: HDLC flags, bit-stuffing, frequency-toggle line code, AFSK PHY — see `acoustic_engine.encode_ax25_ui()`. + +### RX (modem → host) + +Native bench: optional `STATUS` lines on control channel. Production: `max25d` parses demodulated UI and displays `[CRDOP AX25 UI src>dst] text`. + +--- + +## max25d integration + +```ini +[devices] +soft-crdop = crdop:default + +[device.soft-crdop] +host = 127.0.0.1 +port = 8515 +listen = yes +``` + +`CrdopTcpBackend` on `open()`: + +1. TCP connect `host:port` (ctrl), `host:port+1` (data). +2. Send `INITIALIZE`, `PROTOCOLMODE KISS`, `MYCALL …`, `LISTEN TRUE` (each `\n`). + +--- + +## Layer separation + +| Layer | On data TCP | On air | +|-------|-------------|--------| +| AX.25 UI body | ✅ | Inside HDLC | +| HDLC + CRC-16 | ❌ | ✅ | +| Bell 202 AFSK | ❌ | ✅ | + +Same rule as KISS `0x00` DATA frames in `kiss_bridge.py`. + +--- + +## Compliance tests + +| Test | Command | +|------|---------| +| Unit | `pytest stacks/daemon/test_crdop_backend.py` | +| Bench host | `python3 stacks/crdop/tools/audio-dummyd.py --ctrl-port 8515 --data-port 8516` | +| Loopback DSP | `pytest stacks/crdop/test_bell202_line_code.py` | + +--- + +## Version + +Frozen for **MAX25-Stack v1.0.0** (dev track `CUR999`). Changes require bump in this file + `PROTOCOL.md` + `test_crdop_backend.py`. diff --git a/stacks/crdop/docs/INDEX.md b/stacks/crdop/docs/INDEX.md new file mode 100644 index 0000000..91a75b4 --- /dev/null +++ b/stacks/crdop/docs/INDEX.md @@ -0,0 +1,77 @@ +# CRDOP documentation index + +**CRDOP** = stack acronym for **MAX25-SoftModem** (device id `soft-crdop`). Product: **MAX25-SoftModem** (`stacks/crdop/`). Version: MAX25-Stack **v1.0.0**; dev track `CUR999` in `$SRC/stacks/crdop/VERSION`. + +**Project rule (authoritative):** [docs/CRDOP.md](../../../docs/CRDOP.md) +**AI agent map:** [DEVELOPMENT.md](../../../docs/DEVELOPMENT.md) (repo root) + +--- + +## Start here + +| Audience | Doc | One-line purpose | +|----------|-----|------------------| +| Everyone | [SOFTMODEM.md](SOFTMODEM.md) | What CRDOP is — baud scope, duplex, ALSA path | +| MAX25 operator | [MAX25-USAGE.md](MAX25-USAGE.md) | Build, `max25d`, plugins, terminal, HyBBX attach | +| Developer | [DEVELOPER.md](DEVELOPER.md) | Source tree, modules, tests, extending the modem | +| Hardware builder | [HARDWARE-INTERFACE.md](HARDWARE-INTERFACE.md) | Generic radio/audio interface specification | +| Legal / distribution | [LICENSE-USAGE.md](LICENSE-USAGE.md) | GPLv3 — private and commercial use rights | + +## Implementation specs (v1.0.0) + +| Doc | One-line purpose | +|-----|------------------| +| [HOST-PROTOCOL-SPEC.md](HOST-PROTOCOL-SPEC.md) | **Frozen** M25/KISS TCP :8515/:8516 — normative host wire | +| [ACOUSTIC-TEST-PROTOCOL.md](ACOUSTIC-TEST-PROTOCOL.md) | P0 bench acceptance matrix | +| [FEC-SPEC.md](FEC-SPEC.md) | Duplex + FEC strategy — implementable parameters | +| [SOUNDCARD-QUALIFICATION.md](SOUNDCARD-QUALIFICATION.md) | Interface qualification tests (class A/B/C) | +| [G3RUH-DESIGN.md](G3RUH-DESIGN.md) | P1 — 9600–19200 direct FSK (DEV-Level 4) | + +--- + +## Technical reference + +| Doc | One-line purpose | +|-----|------------------| +| [AUDIO-ARCHITECTURE.md](AUDIO-ARCHITECTURE.md) | Kernel ALSA only — sound-proxy, no PulseAudio | +| [PROTOCOL.md](PROTOCOL.md) | M25 host summary — **normative:** [HOST-PROTOCOL-SPEC.md](HOST-PROTOCOL-SPEC.md) | +| [CONFIG.md](CONFIG.md) | `crdop.ini` keys and launcher behaviour | +| [BUILD.md](BUILD.md) | Standalone CMake build and cross-compile | +| [EXAMPLES.md](EXAMPLES.md) | INI profiles, launcher, `max25d` snippets | +| [CHANGELOG.md](CHANGELOG.md) | Release notes | + +--- + +## Planning + +| Doc | One-line purpose | +|-----|------------------| +| [ROADMAP.md](../ROADMAP.md) | P0/P1/P2 modulation, RF paths, FEC, delivery milestones | + +--- + +## Plugin registry + +| Id | Type | Role | +|----|------|------| +| `soft-crdop` | device | Production sound-card modem (`soft-modems` hardware) | +| `audio-dummy` | device | Acoustic bench — loopback / ALSA / host (`acoustic-bench`) | +| `soft-modems` | hardware | CRDOP device family | +| `acoustic-bench` | hardware | Dev/test bench — no RF required | +| `ardop-plugin` | optional | Third-party ARDOP host registry (separate from CRDOP) | + +--- + +## Install tree (MAX25-Stack build) + +| Path | Contents | +|------|----------| +| `bin/crdop` | Launcher (`scripts/crdopc`) | +| `bin/audio-dummyd` | M25 host + acoustic engine | +| `bin/max25-signal-sniffer` | Bell 202 analysis tool | +| `share/crdop/` | INI examples, `lib/*.py`, `VERSION` | +| `share/hybbx/crdop-host.ini.example` | HyBBX Secondary attach | + +--- + +*Compact index — depth in linked docs. Roadmap: [ROADMAP.md](../ROADMAP.md).* diff --git a/stacks/crdop/docs/LICENSE-USAGE.md b/stacks/crdop/docs/LICENSE-USAGE.md new file mode 100644 index 0000000..84ad5ff --- /dev/null +++ b/stacks/crdop/docs/LICENSE-USAGE.md @@ -0,0 +1,116 @@ +# CRDOP license and usage rights + +**CRDOP** (MAX25-SoftModem) is **free software** under the **GNU General Public License version 3** (GPL-3.0-or-later). + +License files: + +| File | Scope | +|------|-------| +| [stacks/crdop/LICENSE](../LICENSE) | CRDOP subproject | +| [LICENSE](../../../LICENSE) | MainAX25-Stack repository root | + +--- + +## Summary (not legal advice) + +GPLv3 grants everyone — **private individuals and commercial entities** — the right to: + +| Right | Meaning | +|-------|---------| +| **Use** | Run CRDOP for any purpose (personal, club, business, government) | +| **Study** | Read and learn from source code | +| **Modify** | Change code to suit your needs | +| **Distribute** | Share originals or modified versions | +| **Commercial use** | Sell services, products, or support built on CRDOP | + +**Copyleft obligation:** When you **distribute** CRDOP (or a combined work based on it), you must: + +1. Provide **corresponding source** (or a written offer valid ≥ 3 years). +2. License derivatives under **GPLv3** (or later, per "or later" clause). +3. Preserve copyright notices and license text. +4. Document changes if you modify files. +5. Pass through patent retaliation and anti-tivoization terms as GPLv3 requires. + +**No warranty:** Software is provided **AS IS** — no implied fitness for a particular purpose. + +--- + +## Private use + +| Scenario | Obligation | +|----------|------------| +| Run CRDOP on your own computer | None beyond GPLv3 acceptance | +| Modify for personal use only | No distribution → no source-offer obligation | +| Internal company use (no binary redistribution) | Same as private — no copyleft trigger until you distribute | + +--- + +## Commercial use + +| Scenario | Obligation | +|----------|------------| +| Sell pre-built PCs with CRDOP installed | Provide source (or written offer) to recipients | +| Ship hardware interface **with** CRDOP binaries | GPL applies to distributed software; hardware schematics you create separately may use another license if they are not a derivative of the GPL code | +| SaaS / hosted service (no binary to users) | AGPL would impose network copyleft — **CRDOP is GPL, not AGPL**; consult counsel for your deployment model | +| OEM integration in a product | Ensure GPL compliance for all distributed GPL components; document how users obtain source | + +**Practical path:** Include `LICENSE`, offer source at your support URL or ship `stacks/crdop/` tree, and state "based on CRDOP (GPLv3)". + +--- + +## Combined works (MAX25-Stack) + +MAX25-Stack bundles CRDOP with daemon, terminal, and plugins. Distribution of the **combined stack** triggers GPLv3 obligations for the GPL-licensed portions. Other components may carry their own licenses — check root `LICENSE` and per-directory notices. + +| Component | Typical license | +|-----------|-----------------| +| `stacks/crdop/` | GPL-3.0-or-later | +| MAX25 daemon / terminal | GPL-3.0-or-later (repository default) | + +--- + +## What you may not do + +| Restriction | Reason | +|-------------|--------| +| Relicense CRDOP under proprietary terms | GPLv3 copyleft | +| Remove copyright / license notices | GPLv3 §4 | +| Imply warranty or liability from authors | GPLv3 §15–16 | +| Distribute without source (when distributing binaries) | GPLv3 §6 | + +Patent suits against GPL users can terminate your license (GPLv3 §8). + +--- + +## ARDOP-plugin (separate) + +**ARDOP-plugin** is an optional MAX25-Stack registry entry — **not** part of the CRDOP GPLv3 tree. + +| Item | Policy | +|------|--------| +| CRDOP coupling | **None** — third-party ARDOP host software | +| Registry | [plugins/external/ardop/README.md](../../../plugins/external/ardop/README.md) | + +CRDOP GPLv3 obligations apply to **CRDOP code**, not to operator ARDOP host software. + +--- + +## Contributor grants + +By contributing to `stacks/crdop/`, you agree that contributions are licensed under **GPL-3.0-or-later**, consistent with the repository [CONTRIBUTING.md](../../../CONTRIBUTING.md). + +--- + +## Full license text + +- Online: <https://www.gnu.org/licenses/gpl-3.0.html> +- In tree: [stacks/crdop/LICENSE](../LICENSE) · [LICENSE](../../../LICENSE) + +--- + +## Related + +| Doc | Topic | +|-----|--------| +| [docs/CRDOP.md](../../../docs/CRDOP.md) | Project rule §5 License | +| [INDEX.md](INDEX.md) | Documentation index | diff --git a/stacks/crdop/docs/MAX25-USAGE.md b/stacks/crdop/docs/MAX25-USAGE.md new file mode 100644 index 0000000..616093c --- /dev/null +++ b/stacks/crdop/docs/MAX25-USAGE.md @@ -0,0 +1,292 @@ +# CRDOP in MAX25-Stack — usage guide + +Complete operator and integrator workflow for **MAX25-SoftModem (CRDOP)** inside MainAX25-Stack. + +**CRDOP** = stack acronym for **MAX25-SoftModem** (device id `soft-crdop`). Project rule: [docs/CRDOP.md](../../../docs/CRDOP.md). + +**Versions:** MAX25-Stack **v1.0.0** · CRDOP dev track **CUR999** (`$SRC/stacks/crdop/VERSION`). + +**Dependency:** CRDOP sources are standalone-capable in principle, but **MAX25-Stack is required** for build, `max25d`, INI, operator tooling, and plugin registry until **CRDOP-v1.0.0** marks a mature standalone release. + +--- + +## Quick start + +```bash +# 1. Build (CRDOP ON by default) +./scripts/build.sh + +# 2. Configure daemon +sudo cp share/max25/max25d.ini.example /etc/max25/max25d.ini +# Edit: enable soft-crdop block (see below) + +# 3. Start stack +./scripts/max25-ctl start --hardware soft-modems --device soft-crdop + +# 4. Terminal session +max25-terminal -U /run/max25/modem.sock +# or TCP: max25-terminal -H 127.0.0.1 -P 7325 +``` + +Inside terminal: `SET DEVICE soft-crdop` → `CONNECT` → `SEND …` + +--- + +## Build and install + +### CMake options + +| Option | Default | Effect | +|--------|---------|--------| +| `MAX25_BUILD_CRDOP` | **ON** | Build/install CRDOP scaffold + tools | +| `MAX25_BUILD_DAEMON` | ON | `max25d` with `CrdopTcpBackend` | +| `MAX25_BUILD_TERMINAL` | ON | `max25-terminal` / `max25-client` | +| `CRDOP_VENDOR_ARDOPCF` | OFF | Dev-only legacy ARDOP — **never** in releases | + +Disable CRDOP only when not needed: + +```bash +cmake -B build -DMAX25_BUILD_CRDOP=OFF +cmake --build build -j$(nproc) +``` + +### Install tree + +After `cmake --install` (or `./scripts/build.sh` + local prefix): + +| Path | Contents | +|------|----------| +| `bin/crdop` | Launcher — starts `audio-dummyd` with INI | +| `bin/audio-dummyd` | M25 host TCP daemon + acoustic engine | +| `bin/max25-signal-sniffer` | Bell 202 analysis | +| `share/crdop/crdop.ini.example` | CB profile template | +| `share/crdop/crdop-dual.ini.example` | CB ↔ amateur preset | +| `share/crdop/crdop-amateur.ini.example` | Amateur secondary preset | +| `share/crdop/lib/*.py` | DSP modules (installed for tooling) | +| `share/crdop/VERSION` | `CUR999` | +| `share/hybbx/crdop-host.ini.example` | HyBBX Secondary attach | +| `share/clients/soft-crdop.yaml` | Terminal device profile | + +--- + +## Plugin model + +| Plugin id | Type | Hardware family | Role | +|-----------|------|-----------------|------| +| `soft-crdop` | device | `soft-modems` | Production sound-card modem | +| `audio-dummy` | device | `acoustic-bench` | Bench — loopback / ALSA / host | +| `soft-modems` | hardware | — | CRDOP device family | +| `acoustic-bench` | hardware | — | Dev/test without RF | + +Registry: `plugins/manifest.yaml`. Discovery: + +```bash +./scripts/discover-plugins.sh --json | jq '.devices[] | select(.id|test("crdop|audio-dummy"))' +``` + +--- + +## max25d configuration + +### Production path — `soft-crdop` + +```ini +[daemon] +hardware = soft-modems +device = soft-crdop + +[devices] +default = soft-crdop +soft-crdop = crdop:default + +[device.soft-crdop] +host = 127.0.0.1 +port = 8515 +listen = yes +``` + +`max25-ctl start` launches `crdop` (→ `audio-dummyd`) when `auto_start = yes` in `[stack]`. + +### Bench path — `audio-dummy` + +No RF; validates DSP loopback or ALSA capture: + +```ini +[daemon] +hardware = acoustic-bench +device = audio-dummy + +[devices] +default = audio-dummy +audio-dummy = audio:loopback +``` + +Modes for `audio-dummy` spec: + +| Spec | Behaviour | +|------|-----------| +| `audio:loopback` | Internal DSP encode/decode | +| `audio:alsa:plughw:N,M` | Sniff live ALSA capture | +| `audio:host` | Attach to running `audio-dummyd` on :8515 | + +Start bench: + +```bash +./scripts/max25-ctl start --hardware acoustic-bench --device audio-dummy +max25-terminal -U /run/max25/modem.sock +``` + +### Sniffer (no daemon) + +```bash +max25-signal-sniffer --loopback +max25-signal-sniffer -D plughw:1,0 -t 3.0 +max25-signal-sniffer --mark # 1200 Hz calibration tone +max25-signal-sniffer --space # 2200 Hz calibration tone +``` + +--- + +## crdop.ini (modem side) + +Copy and edit: + +```bash +mkdir -p ~/.config/crdop +cp share/crdop/crdop.ini.example ~/.config/crdop/crdop.ini +``` + +Key sections — full reference: [CONFIG.md](CONFIG.md). + +```ini +[profile] +radio_profile = cb ; cb | dual | amateur + +[modem] +duplex = half ; half | full +arq_bandwidth = 500MAX + +[mycall] +call = CB01-0 + +[audio] +backend = alsa-kernel +no_pulse = yes +capture = plughw:1,0 +playback = plughw:1,0 + +[host] +port = 8515 +``` + +Launch: + +```bash +CRDOP_INI=~/.config/crdop/crdop.ini ./scripts/crdopc +# or after install: +crdop +``` + +--- + +## max25-terminal / M25/1 + +CRDOP uses the same M25/1 session flow as other `max25d` device backends: + +``` +SET DEVICE soft-crdop +CONNECT +SEND Hello packet world +``` + +| Item | Value | +|------|-------| +| Daemon socket | `/run/max25/modem.sock` (Linux) | +| Daemon TCP | `7325` (configurable in `max25d.ini`) | +| Modem host TCP | `8515` ctrl, `8516` data (CRDOP native) | +| Client profile | `share/clients/soft-crdop.yaml` | + +Protocol reference: [PROTOCOL.md](PROTOCOL.md) · [include/max25/protocol.md](../../../include/max25/protocol.md). + +When `ax25_ui = yes` in `max25d.ini`, terminal `SEND` lines encode as AX.25 UI frames. + +--- + +## HyBBX attach + +HyBBX is **external** — attach after MAX25 stack is up. + +1. Start CRDOP path: + + ```bash + ./scripts/max25-ctl start --hardware soft-modems --device soft-crdop + ``` + +2. Merge `share/hybbx/crdop-host.ini.example` into HyBBX `hybbx.ini` on Secondary: + + ```ini + [networks] + crdop = yes + + [transport.crdop1] + enabled = yes + modem_host = 127.0.0.1 + modem_port = 8515 + mycall = CB-0 + listen = yes + circuit_host = main.example.com + circuit_port = 7323 + ``` + +MAX25 owns modem lifecycle; HyBBX owns sessions. See [docs/HYBBX.md](../../../docs/HYBBX.md). + +--- + +## Operator profiles + +| INI template | Profile | Use | +|--------------|---------|-----| +| `crdop.ini.example` | CB | Primary — K24/K25 class channels | +| `crdop-dual.ini.example` | dual | CB ↔ amateur switching | +| `crdop-amateur.ini.example` | amateur | Amateur secondary | + +```bash +CRDOP_INI=share/crdop-dual.ini.example ./scripts/crdopc +``` + +--- + +## Testing in MAX25-Stack + +```bash +./scripts/test.sh # cmake --build build --target max25_test +cmake --build build --target max25_daemon_smoke +./scripts/release-check.sh # install + policy checks +``` + +CRDOP-specific offline tests: `test_crdop_backend.py`, `test_audio_dummy_backend.py`, `test_bell202_line_code.py`. + +--- + +## Troubleshooting + +| Symptom | Check | +|---------|-------| +| `crdopc not found` | Run `./scripts/build.sh`; launcher uses `audio-dummyd` from install or source tree | +| No decode | `max25-signal-sniffer --loopback`; verify ALSA devices in INI | +| PulseAudio hijack | `no_pulse = yes`; use `hw:` / `plughw:` explicitly | +| Port conflict | Default :8515/:8516 — change `[host] port` + `[device.soft-crdop] port` | +| Terminal no device | `SET DEVICE soft-crdop`; check `[devices]` in `max25d.ini` | + +--- + +## Related + +| Doc | Topic | +|-----|--------| +| [INDEX.md](INDEX.md) | Full doc table | +| [DEVELOPER.md](DEVELOPER.md) | Source, modules, extending | +| [HARDWARE-INTERFACE.md](HARDWARE-INTERFACE.md) | Radio interface spec | +| [docs/PLUGINS-DEVICE-MODEL.md](../../../docs/PLUGINS-DEVICE-MODEL.md) | Unified device workflow | +| [docs/LINUX-HOST-SETUP.md](../../../docs/LINUX-HOST-SETUP.md) | Host prerequisites | +| [ROADMAP.md](../ROADMAP.md) | P0/P1/P2 milestones and modulation priorities | diff --git a/stacks/crdop/docs/PROTOCOL.md b/stacks/crdop/docs/PROTOCOL.md new file mode 100644 index 0000000..2d28bd4 --- /dev/null +++ b/stacks/crdop/docs/PROTOCOL.md @@ -0,0 +1,36 @@ +# Protocol + +Normative wire: **[HOST-PROTOCOL-SPEC.md](HOST-PROTOCOL-SPEC.md)** (frozen v1.0.0). + +CRDOP (**MAX25-SoftModem**) uses the **native MAX25 M25/KISS host protocol** on TCP :8515 (control) / :8516 (data). + +## Stack (default) + +``` +[Radio] ↔ [audio/ALSA] ↔ audio-dummyd / native modem ↔ TCP :8515 ctrl, :8516 data ↔ max25d +``` + +## Over-the-air + +AX.25-compatible AFSK tones (1200 baud primary, up to 19200 baud design range). See [SOFTMODEM.md](SOFTMODEM.md). + +## Host TCP (native M25) + +| Port | Role | +|------|------| +| 8515 | Control (line-oriented ASCII, `\n` terminated) | +| 8516 | Data (AX.25 UI body without HDLC/FCS) | + +Common commands: `INITIALIZE` · `PROTOCOLMODE KISS` · `MYCALL` · `LISTEN` · `STATUS` · `PING` + +Implementation reference: `stacks/crdop/lib/m25_host_protocol.py` + +## CRDOP launch presets + +| Profile | Role | +|---------|------| +| `cb` | Default — CB / 500MAX class | +| `dual` | CB ↔ amateur turnaround | +| `amateur` | Amateur bandwidth class | + +Runtime overrides via host TCP or `crdop.ini` always apply. diff --git a/stacks/crdop/docs/README.md b/stacks/crdop/docs/README.md new file mode 100644 index 0000000..4a763a2 --- /dev/null +++ b/stacks/crdop/docs/README.md @@ -0,0 +1,31 @@ +# CRDOP documentation + +**CRDOP** = stack acronym for **MAX25-SoftModem** (device id `soft-crdop`). + +**MAX25-SoftModem** — in-house sound-card modem subproject (`stacks/crdop/`). + +## Entry point + +**[INDEX.md](INDEX.md)** — complete table of all CRDOP docs with one-line purpose. + +Project rule: [docs/CRDOP.md](../../../docs/CRDOP.md) + +## Policy + +| Rule | | +|------|--| +| Standard | Native MAX25 M25/KISS host on TCP :8515/:8516 | +| Focus | CB-first; `dual` and `amateur` are presets | +| Scope | Modem only — no host application | + +## Dev-only vendor tree + +Legacy **[pflarue/ardop](https://github.com/pflarue/ardop)** (ardopcf, MIT) may exist under `vendor/ardopcf/` for local dev builds (`-DCRDOP_VENDOR_ARDOPCF=ON`). **Never installed in MAX25 releases.** + +Pin: `a7c9228` · record: `vendor/ardopcf.ref` + +## Platforms (tested) + +Linux · FreeBSD — **tested** (GCC, Clang) + +Windows · macOS — build supported, **not yet tested** diff --git a/stacks/crdop/docs/SOFTMODEM.md b/stacks/crdop/docs/SOFTMODEM.md new file mode 100644 index 0000000..fef0a0e --- /dev/null +++ b/stacks/crdop/docs/SOFTMODEM.md @@ -0,0 +1,127 @@ +# MAX25-SoftModem — product definition + +> **Authoritative rule:** [docs/CRDOP.md](../../../docs/CRDOP.md) · **Doc index:** [INDEX.md](INDEX.md) + +## Name and role + +- **Product:** MAX25-SoftModem (stack acronym **CRDOP**; plugin id `soft-crdop`) +- **Stack version:** MAX25-Stack **v1.0.0** · **Dev track:** `CUR999` in `$SRC/stacks/crdop/VERSION` +- **Stack:** MainAX25-Stack — **standard** component (built/installed unless `MAX25_BUILD_CRDOP=OFF`) +- **Nature:** Pure MAX25 in-house development — in **development and test phase**, openly documented + +## Use like a hardware modem + +CRDOP uses the host **sound card IN and OUT** plus a **suitable radio** (or acoustic coupling to existing RF hardware). In `max25d` it registers like any other device backend — operators use the same M25/1 flow (`SET DEVICE`, `CONNECT`, `SEND`). + +``` +┌──────────┐ line or acoustic ┌─────────────┐ TCP ┌─────────┐ +│ Radio │ ◄──────────────────► │ CRDOP/ALSA │ ◄──────► │ max25d │ +└──────────┘ └─────────────┘ └─────────┘ + │ + 1200–19200 baud + half or full duplex +``` + +### Acoustic AX.25 compatibility + +On the **acoustic / RF layer**, CRDOP targets **AX.25 Packet Radio** interoperability at the PHY level: + +- **1200 baud Bell 202 AFSK** (1200/2200 Hz) — standard VHF/UHF and CB packet class +- **Half- and full-duplex** per radio and audio capability +- **Acoustic or line-level** coupling — valid test and field configuration + +CRDOP is an **alternative modem** when the sound card is the RF interface. It does not replace an existing UART/KISS path when that path is already in use. + +Interface specification for builders: [HARDWARE-INTERFACE.md](HARDWARE-INTERFACE.md). MAX25-Stack usage: [MAX25-USAGE.md](MAX25-USAGE.md). + +## Protocol stack + +| Layer | Policy | +|-------|--------| +| On-air / acoustic | AX.25-compatible AFSK tones (1200 baud primary) | +| Host to max25d | MAX25-native TCP control + data (see [PROTOCOL.md](PROTOCOL.md)) | + +We study legacy stacks; we implement our own modem and document it for **reproducible hardware builds**. + +## Sound path — kernel ALSA only + +CRDOP does **not** use PulseAudio, PipeWire-as-default, or any desktop sound server. + +| Layer | Function | +|-------|----------| +| **MAX25 sound-proxy** | Sole shim between modem DSP and ALSA — buffers, timing, duplex, xrun recovery | +| **libasound** | Direct `hw:` / `plughw:` opens | +| **Kernel ALSA** | Driver + `/dev/snd/*` | +| **Hardware** | Sound card → radio interface | + +Configure explicit devices in `crdop.ini` `[audio]` (`capture`, `playback`). See [AUDIO-ARCHITECTURE.md](AUDIO-ARCHITECTURE.md). + +## Baud scope + +| Priority | Range | Status | +|----------|-------|--------| +| **P0** | **1200 baud** Bell 202 AFSK | Primary — CB + VHF on-air | +| **P1** | **9600–19200** G3RUH direct FSK | Planned after 1200 stable (VHF backbone) | +| **P2** | **300** HF AFSK | Optional | +| — | **>19200 baud** | **Not implemented** — do not configure or expect | + +Full modulation matrix, RF paths, band context, delivery milestones: **[ROADMAP.md](../ROADMAP.md)**. + +Higher baud rates demand **proportionally better** audio hardware (sample rate, jitter, SNR, isolation). G3RUH requires a **direct FSK radio path** — not the CB FM mic/speaker route. + +## Duplex + +| Mode | Status | +|------|--------| +| **Half-duplex** | Standard CB / packet-radio (PTT or VOX-style timing) | +| **Full-duplex** | Supported when radio and audio hardware allow simultaneous TX/RX paths | + +Configure in `crdop.ini`: `[modem] duplex = half|full`. + +## Sound card — required (not optional) + +CRDOP must: + +1. **Generate** precise mark/space frequencies on **playback**. +2. **Decode** those tones reliably on **capture**. + +Requirements **scale with baud**: + +| Baud class | Audio expectation | +|------------|-------------------| +| 1200 | Stable full-duplex or half-duplex USB codec or PCI interface; galvanic isolation to radio | +| 9600–19200 | Low-jitter interface, adequate bandwidth, verified loopback before on-air | + +**Unsuitable:** onboard laptop mic/speaker shortcuts, anonymous USB “sound sticks”, Bluetooth bridges, unisolated consumer line inputs. + +Document your ALSA device in INI (`[audio] capture` / `playback`) and verify with loopback or sidetone before traffic. + +## Open hardware + +Documentation targets builders who want to **manufacture radio ↔ sound-card interfaces**: + +- Level matching (radio mic/speaker ↔ line level) +- Galvanic isolation (transformers / opto) +- PTT integration (GPIO, serial — VOX not recommended for packet) +- Acoustic coupler fixtures for bench validation + +Generic interface specification: [HARDWARE-INTERFACE.md](HARDWARE-INTERFACE.md). Delivery milestones: [ROADMAP.md](../ROADMAP.md). + +## Build / release (MAX25-Stack-v1.0.0) + +**CRDOP dev track:** `CUR999` in `$SRC/stacks/crdop/VERSION` (ships with MAX25-Stack v1.0.0). + +| Item | Shipped | +|------|---------| +| CRDOP scaffold (INI, launcher, install) | **Yes** — default CMake | +| `max25d` `crdop-tcp` backend | **Yes** — offline-tested | +| Native `crdopc` DSP binary | **In development** — in-house modem DSP | +| ARDOP / ardopcf vendor tree | **Never** in release tarball | + +## HyBBX + +When CRDOP is active, HyBBX Secondary attaches via `share/hybbx/crdop-host.ini.example`. HyBBX remains an external project. + +## Testing phase + +Current work: loopback audio, acoustic bench validation, field on-air trials, acceptable sound-card criteria. Report results via project issues — RF acceptance remains manual until automated gates exist. Developer tests: [DEVELOPER.md](DEVELOPER.md). diff --git a/stacks/crdop/docs/SOUNDCARD-QUALIFICATION.md b/stacks/crdop/docs/SOUNDCARD-QUALIFICATION.md new file mode 100644 index 0000000..b35d55b --- /dev/null +++ b/stacks/crdop/docs/SOUNDCARD-QUALIFICATION.md @@ -0,0 +1,95 @@ +# Sound card qualification — CRDOP / MAX25-SoftModem + +**Purpose:** Measurable criteria for interface hardware used with CRDOP. No product endorsements — class-based requirements. + +See also [ROADMAP.md](../ROADMAP.md) (P0/P1 audio requirements). + +--- + +## Mandatory architecture + +``` +CRDOP DSP → sound_proxy → libasound (hw:) → codec → radio interface +``` + +| Forbidden in production path | Reason | +|------------------------------|--------| +| PulseAudio default route | Mixing, resampling, latency | +| PipeWire default | Variable buffer | +| Bluetooth A2DP/HFP | Codec + AGC | + +INI: `[audio] backend = alsa-kernel`, `no_pulse = yes`. + +--- + +## Sample rate + +| Baud target | Minimum rate | CRDOP default | +|-------------|--------------|---------------| +| 1200 AFSK | 48000 Hz | **48000** | +| 9600 G3RUH (audio tap) | 48000–96000 | 48000 + tuning | +| 19200 | 96000 Hz | 96000 if hardware supports | + +--- + +## Qualification tests + +Run on candidate interface **before** field deployment. + +| ID | Test | Pass | +|----|------|------| +| **Q1** | Loopback cable, 1200 Hz sine 2 s | THD acceptable by ear + FFT peak at 1200 ±2 Hz | +| **Q2** | Loopback Bell 202 sniffer | `max25-signal-sniffer --loopback` or ALSA loop | +| **Q3** | Mark/space calibration tones | `--mark` / `--space` tools within ±5 Hz | +| **Q4** | 60 s full-duplex record+play | No xrun in `dmesg` / ALSA log | +| **Q5** | Clock drift | 10 min loopback: no cumulative symbol slip at 1200 bd | + +--- + +## Interface classes + +| Class | Isolation | 1200 AFSK | 19200 path | +|-------|-----------|-----------|------------| +| **A** | Galvanic line I/O, fixed gain | Required minimum | Test Q1–Q5 | +| **B** | USB codec, headphone/mic level | Acceptable with level match | Not recommended | +| **C** | Onboard mic/speaker only | Acoustic bench only | Fail | + +CRDOP production path: **Class A or B** with line-level radio interface — see [HARDWARE-INTERFACE.md](HARDWARE-INTERFACE.md). + +--- + +## Level guidelines + +| Path | Typical level | +|------|---------------| +| Line OUT → radio mic in | 100–300 mV RMS (adjust pad) | +| Radio speaker/line → Line IN | Attenuate to avoid clipping | +| DC offset | AC coupling required | + +--- + +## Baud scaling + +Requirements **rise faster than linear** above 1200 bd: + +| Baud | Clock jitter tolerance | Filter flatness | +|------|------------------------|-----------------| +| 1200 | moderate | 300–3000 Hz FM audio path OK | +| 9600 | strict | 10 Hz–5 kHz flat, constant group delay | +| 19200 | professional | Direct FSK tap — not soundcard mic path | + +--- + +## Failure actions + +| Symptom | Action | +|---------|--------| +| xruns | Increase `period_frames`; reduce system load | +| Wrong tones | Verify 48000 Hz; check resampler not inserted | +| Intermittent decode | Replace Class C with A/B; check ground loop | + +--- + +## Community list (planned) + +P0 bench exit: publish operator-submitted **class + Q1–Q2 result** table — no vendor ranking. diff --git a/stacks/crdop/include/crdop/sound_proxy.h b/stacks/crdop/include/crdop/sound_proxy.h new file mode 100644 index 0000000..ce0efcb --- /dev/null +++ b/stacks/crdop/include/crdop/sound_proxy.h @@ -0,0 +1,52 @@ +/* + * MAX25 sound-proxy — CRDOP ↔ kernel ALSA (no PulseAudio). + * + * In-process shim between modem DSP and libasound. Only this layer opens + * snd_pcm capture/playback on hw:/plughw: devices. max25d does not touch audio. + * + * Eigenentwicklung — API stable for v1 scaffold; implementation in progress. + */ +#ifndef MAX25_CRDOP_SOUND_PROXY_H +#define MAX25_CRDOP_SOUND_PROXY_H + +#include <stddef.h> +#include <stdint.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/** ALSA backend policy — kernel path only; no PulseAudio/PipeWire route. */ +#define MAX25_SOUND_BACKEND_ALSA_KERNEL "alsa-kernel" + +typedef struct max25_sound_proxy max25_sound_proxy_t; + +typedef struct max25_sound_config { + const char *capture_device; /* e.g. "hw:1,0" */ + const char *playback_device; /* e.g. "hw:1,0" */ + unsigned int sample_rate; /* Hz — modem-dependent */ + unsigned int period_frames; + unsigned int buffer_frames; + int full_duplex; /* 0 = half, 1 = full */ + int forbid_pulse; /* non-zero: reject pulse/pipewire pseudo devices */ +} max25_sound_config_t; + +/** Open ALSA via kernel path; returns NULL on failure (pulse device rejected if forbid_pulse). */ +max25_sound_proxy_t *max25_sound_proxy_open(const max25_sound_config_t *cfg); + +void max25_sound_proxy_close(max25_sound_proxy_t *sp); + +/** Capture PCM frames (RX path). Returns frames read or negative errno-style code. */ +int max25_sound_proxy_read(max25_sound_proxy_t *sp, int16_t *buf, size_t frames); + +/** Playback PCM frames (TX path). Returns frames written or negative code. */ +int max25_sound_proxy_write(max25_sound_proxy_t *sp, const int16_t *buf, size_t frames); + +/** Recover from xrun — modem may call between frames. */ +int max25_sound_proxy_recover(max25_sound_proxy_t *sp); + +#ifdef __cplusplus +} +#endif + +#endif /* MAX25_CRDOP_SOUND_PROXY_H */ diff --git a/stacks/crdop/include/crdop/version.h b/stacks/crdop/include/crdop/version.h new file mode 100644 index 0000000..249ef32 --- /dev/null +++ b/stacks/crdop/include/crdop/version.h @@ -0,0 +1,8 @@ +#ifndef CRDOP_VERSION_H +#define CRDOP_VERSION_H + +#define CRDOP_PRODUCT_NAME "CRDOP-CUR999" +#define CRDOP_VERSION_STRING "CUR999" +#define CRDOP_VENDOR_BASE "native" + +#endif diff --git a/stacks/crdop/lib/__init__.py b/stacks/crdop/lib/__init__.py new file mode 100644 index 0000000..2137436 --- /dev/null +++ b/stacks/crdop/lib/__init__.py @@ -0,0 +1 @@ +"""MAX25-SoftModem (CRDOP) — native DSP library (Bell 202 AFSK, HDLC, ALSA).""" diff --git a/stacks/crdop/lib/acoustic_engine.py b/stacks/crdop/lib/acoustic_engine.py new file mode 100644 index 0000000..47989ec --- /dev/null +++ b/stacks/crdop/lib/acoustic_engine.py @@ -0,0 +1,89 @@ +""" +Acoustic bench engine — modulate/demodulate/sniff without RF. + +Used by audio-dummy device and max25-signal-sniffer. +""" +from __future__ import annotations + +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +_LIB = Path(__file__).resolve().parent +if str(_LIB) not in sys.path: + sys.path.insert(0, str(_LIB)) + +from afsk_demodulator import AfskDemodulator # noqa: E402 +from afsk_modulator import AfskModulator # noqa: E402 +from bell202_line_code import TONE_MARK, TONE_SPACE # noqa: E402 +from hdlc_codec import build_hdlc_frame, parse_hdlc_stream # noqa: E402 +from sound_proxy import SoundConfig, create_sound_proxy # noqa: E402 + +_ROOT = Path(__file__).resolve().parents[3] +_DAEMON = _ROOT / "stacks" / "daemon" +if str(_DAEMON) not in sys.path: + sys.path.insert(0, str(_DAEMON)) +from ax25_codec import ax25_build_ui, ax25_parse_ui # noqa: E402 + + +@dataclass +class SniffReport: + samples: int = 0 + symbols: int = 0 + mark_ratio: float = 0.0 + space_ratio: float = 0.0 + transitions: int = 0 + frames: list[bytes] = field(default_factory=list) + decode_lines: list[str] = field(default_factory=list) + + +class AcousticEngine: + def __init__( + self, + sample_rate: int = 48000, + baud: int = 1200, + sound: Optional[SoundConfig] = None, + ) -> None: + self.sample_rate = sample_rate + self.baud = baud + self.mod = AfskModulator(sample_rate, baud) + self.demod = AfskDemodulator(sample_rate, baud) + self.sound = sound or SoundConfig(sample_rate=sample_rate) + + def encode_ax25_ui(self, src: str, dst: str, text: str) -> bytes: + body = ax25_build_ui(src, dst, text.encode("utf-8")) + # strip FCS for KISS-style host body, then build on-air HDLC + if len(body) >= 2: + body = body[:-2] + hdlc = build_hdlc_frame(body) + return self.mod.modulate_bits(hdlc) + + def analyze_pcm(self, pcm: bytes) -> SniffReport: + rep = SniffReport(samples=len(pcm) // 2) + tones = self.demod.demodulate_pcm(pcm) + rep.symbols = len(tones) + if not tones: + return rep + marks = sum(1 for t in tones if t == TONE_MARK) + rep.mark_ratio = marks / len(tones) + rep.space_ratio = 1.0 - rep.mark_ratio + rep.transitions = sum(1 for i in range(1, len(tones)) if tones[i] != tones[i - 1]) + raw_bits = self.demod.demodulate_to_bits(pcm) + for frame in parse_hdlc_stream(raw_bits): + rep.frames.append(frame) + parsed = ax25_parse_ui(frame) + if parsed: + src, dst, info = parsed + rep.decode_lines.append(f"[AX25 UI {src}>{dst}] {info.decode('utf-8', errors='replace')}") + return rep + + def loopback_self_test(self, src: str = "TST-0", dst: str = "QST") -> SniffReport: + pcm = self.encode_ax25_ui(src, dst, "LOOP") + return self.analyze_pcm(pcm) + + def play_mark_calibration(self, seconds: float = 1.0) -> None: + create_sound_proxy(self.sound).play_pcm(self.mod.steady_tone(TONE_MARK, seconds)) + + def play_space_calibration(self, seconds: float = 1.0) -> None: + create_sound_proxy(self.sound).play_pcm(self.mod.steady_tone(TONE_SPACE, seconds)) diff --git a/stacks/crdop/lib/afsk_demodulator.py b/stacks/crdop/lib/afsk_demodulator.py new file mode 100644 index 0000000..5ba3d8b --- /dev/null +++ b/stacks/crdop/lib/afsk_demodulator.py @@ -0,0 +1,56 @@ +""" +AFSK demodulator — Bell 202 mark/space discrimination per symbol period. + +Uses per-symbol Goertzel energy at 1200 Hz and 2200 Hz (Dire Wolf class approach). +""" +from __future__ import annotations + +import math +import struct +from array import array + +from bell202_line_code import TONE_MARK, TONE_SPACE, decode_tones_to_bits + +_TWO_PI = 2.0 * math.pi + + +def _goertzel_power(samples: array, freq: float, sample_rate: int) -> float: + n = len(samples) + if n < 8: + return 0.0 + k = int(0.5 + (n * freq) / sample_rate) + w = _TWO_PI * k / n + coeff = 2.0 * math.cos(w) + s0 = s1 = s2 = 0.0 + for x in samples: + s0 = x + coeff * s1 - s2 + s2 = s1 + s1 = s0 + return s1 * s1 + s2 * s2 - coeff * s1 * s2 + + +class AfskDemodulator: + def __init__(self, sample_rate: int = 48000, baud: int = 1200) -> None: + self.sample_rate = sample_rate + self.baud = baud + self.samples_per_symbol = sample_rate // baud + + def demodulate_pcm(self, pcm: bytes) -> list[int]: + """Return mark/space tone sequence from mono S16_LE PCM.""" + count = len(pcm) // 2 + if count < self.samples_per_symbol: + return [] + samples = array("h") + samples.frombytes(pcm[: count * 2]) + tones: list[int] = [] + pos = 0 + while pos + self.samples_per_symbol <= len(samples): + window = samples[pos : pos + self.samples_per_symbol] + pos += self.samples_per_symbol + p_mark = _goertzel_power(window, 1200.0, self.sample_rate) + p_space = _goertzel_power(window, 2200.0, self.sample_rate) + tones.append(TONE_MARK if p_mark >= p_space else TONE_SPACE) + return tones + + def demodulate_to_bits(self, pcm: bytes, start_tone: int = TONE_SPACE) -> bytes: + return decode_tones_to_bits(self.demodulate_pcm(pcm), start_tone=start_tone) diff --git a/stacks/crdop/lib/afsk_modulator.py b/stacks/crdop/lib/afsk_modulator.py new file mode 100644 index 0000000..1a6bf68 --- /dev/null +++ b/stacks/crdop/lib/afsk_modulator.py @@ -0,0 +1,51 @@ +""" +Continuous-phase AFSK modulator — Bell 202 mark 1200 Hz / space 2200 Hz. +""" +from __future__ import annotations + +import math +import struct +from array import array + +from bell202_line_code import MARK_HZ, SPACE_HZ, TONE_MARK, encode_bits_to_tones + +_TWO_PI = 2.0 * math.pi + + +class AfskModulator: + def __init__(self, sample_rate: int = 48000, baud: int = 1200) -> None: + self.sample_rate = sample_rate + self.baud = baud + self._phase = 0.0 + + def _tone_freq(self, tone: int) -> float: + return MARK_HZ if tone == TONE_MARK else SPACE_HZ + + def modulate_bits(self, bits: bytes, start_tone: int = 0) -> bytes: + """Return mono S16_LE PCM for the given bit stream.""" + tones = encode_bits_to_tones(bits, start_tone=start_tone) + samples_per_symbol = self.sample_rate // self.baud + out: array[int] = array("h") + for tone in tones: + freq = self._tone_freq(tone) + step = _TWO_PI * freq / self.sample_rate + for _ in range(samples_per_symbol): + sample = int(0.7 * 32767.0 * math.sin(self._phase)) + out.append(sample) + self._phase += step + if self._phase >= _TWO_PI: + self._phase -= _TWO_PI + return struct.pack(f"<{len(out)}h", *out) + + def steady_tone(self, tone: int, duration_s: float) -> bytes: + """Calibration tone (Dire Wolf -x style mark/space hold).""" + n = int(self.sample_rate * duration_s) + freq = self._tone_freq(tone) + step = _TWO_PI * freq / self.sample_rate + out: array[int] = array("h") + for _ in range(n): + out.append(int(0.7 * 32767.0 * math.sin(self._phase))) + self._phase += step + if self._phase >= _TWO_PI: + self._phase -= _TWO_PI + return struct.pack(f"<{len(out)}h", *out) diff --git a/stacks/crdop/lib/bell202_line_code.py b/stacks/crdop/lib/bell202_line_code.py new file mode 100644 index 0000000..cd2621f --- /dev/null +++ b/stacks/crdop/lib/bell202_line_code.py @@ -0,0 +1,50 @@ +""" +Bell 202 frequency-toggle line code (1200 baud AFSK layer). + +On-air rule: bit 0 → toggle mark/space tone; bit 1 → hold current tone. +We avoid the legacy term in API names — this is Bell 202 / amateur packet radio. +""" +from __future__ import annotations + +MARK_HZ = 1200.0 +SPACE_HZ = 2200.0 +BAUD_1200 = 1200 + +TONE_MARK = 1 +TONE_SPACE = 0 + + +def encode_bits_to_tones(bits: bytes, start_tone: int = TONE_SPACE) -> list[int]: + """Map HDLC bit stream to mark/space tone sequence (one tone per bit).""" + tone = start_tone + out: list[int] = [] + for byte in bits: + for bit_i in range(7, -1, -1): + bit = (byte >> bit_i) & 1 + if bit == 0: + tone ^= 1 + out.append(tone) + return out + + +def decode_tones_to_bits(tones: list[int], start_tone: int = TONE_SPACE) -> bytes: + """Recover bit stream from mark/space tone sequence.""" + if not tones: + return b"" + prev = start_tone + bits: list[int] = [] + for tone in tones: + if tone not in (TONE_MARK, TONE_SPACE): + continue + bits.append(0 if tone != prev else 1) + prev = tone + buf = bytearray() + for i in range(0, len(bits), 8): + chunk = bits[i : i + 8] + if len(chunk) < 8: + break + val = 0 + for b in chunk: + val = (val << 1) | b + buf.append(val) + return bytes(buf) diff --git a/stacks/crdop/lib/hdlc_codec.py b/stacks/crdop/lib/hdlc_codec.py new file mode 100644 index 0000000..942168b --- /dev/null +++ b/stacks/crdop/lib/hdlc_codec.py @@ -0,0 +1,110 @@ +""" +HDLC framing for AX.25 — flags, bit-stuffing, CRC-16-CCITT. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +_ROOT = Path(__file__).resolve().parents[3] +_DAEMON = _ROOT / "stacks" / "daemon" +if str(_DAEMON) not in sys.path: + sys.path.insert(0, str(_DAEMON)) + +from ax25_codec import ax25_crc # noqa: E402 + +HDLC_FLAG = 0x7E +HDLC_ESCAPE = 0x7D +HDLC_XOR = 0x20 + + +def bit_stuff(data: bytes) -> bytes: + out = bytearray() + ones = 0 + for b in data: + for bit_i in range(7, -1, -1): + bit = (b >> bit_i) & 1 + if bit: + ones += 1 + out.append(1) + if ones == 5: + out.append(0) + ones = 0 + else: + ones = 0 + out.append(0) + # pack bits to bytes MSB-first + buf = bytearray() + acc = 0 + n = 0 + for bit in out: + acc = (acc << 1) | bit + n += 1 + if n == 8: + buf.append(acc) + acc = 0 + n = 0 + if n: + buf.append(acc << (8 - n)) + return bytes(buf) + + +def bit_unstuff(data: bytes) -> bytes: + bits: list[int] = [] + for b in data: + for bit_i in range(7, -1, -1): + bits.append((b >> bit_i) & 1) + out: list[int] = [] + ones = 0 + i = 0 + while i < len(bits): + bit = bits[i] + if bit: + ones += 1 + out.append(1) + if ones == 5 and i + 1 < len(bits) and bits[i + 1] == 0: + i += 1 + ones = 0 + elif ones == 6: + break + else: + ones = 0 + out.append(0) + i += 1 + buf = bytearray() + for j in range(0, len(out), 8): + chunk = out[j : j + 8] + if len(chunk) < 8: + break + val = 0 + for bit in chunk: + val = (val << 1) | bit + buf.append(val) + return bytes(buf) + + +def build_hdlc_frame(ax25_body: bytes) -> bytes: + """AX.25 body (no FCS) → on-air HDLC bit stream as bytes (pre tone encoding).""" + crc = ax25_crc(ax25_body) + payload = ax25_body + bytes((crc & 0xFF, crc >> 8)) + stuffed = bit_stuff(payload) + return bytes([HDLC_FLAG]) + stuffed + bytes([HDLC_FLAG]) + + +def parse_hdlc_stream(raw_bits: bytes) -> list[bytes]: + """Extract AX.25 bodies (with FCS) from demodulated byte stream between flags.""" + frames: list[bytes] = [] + in_frame = False + cur = bytearray() + for b in raw_bits: + if b == HDLC_FLAG: + if in_frame and cur: + unstuffed = bit_unstuff(bytes(cur)) + if len(unstuffed) >= 2: + frames.append(unstuffed) + cur.clear() + in_frame = True + continue + if in_frame: + cur.append(b) + return frames diff --git a/stacks/crdop/lib/m25_host_protocol.py b/stacks/crdop/lib/m25_host_protocol.py new file mode 100644 index 0000000..9212336 --- /dev/null +++ b/stacks/crdop/lib/m25_host_protocol.py @@ -0,0 +1,142 @@ +""" +MAX25 native host protocol for SoftModem / audio-dummy (design freeze). + +Payload on data channel = AX.25 UI body **without** HDLC/FCS (same as KISS DATA). +Control channel = line-oriented ASCII commands (M25-family, not ARDOP FEC/ARQ). +""" +from __future__ import annotations + +import socket +import threading +from typing import Callable, Optional + +# KISS-compatible data semantics; control is text lines ending in \\n +DEFAULT_CTRL_PORT = 8515 +DEFAULT_DATA_PORT = 8516 + +CmdFn = Callable[[str], str] +DataRxFn = Callable[[bytes], None] + + +class M25SoftModemHost: + """Minimal TCP host for bench / audio-dummy (ctrl + data ports).""" + + def __init__( + self, + ctrl_port: int = DEFAULT_CTRL_PORT, + data_port: int = DEFAULT_DATA_PORT, + on_data_tx: Optional[Callable[[bytes], str]] = None, + ) -> None: + self.ctrl_port = ctrl_port + self.data_port = data_port + self._on_data_tx = on_data_tx or (lambda _b: "OK") + self._stop = threading.Event() + self._ctrl_srv: Optional[socket.socket] = None + self._data_srv: Optional[socket.socket] = None + self._threads: list[threading.Thread] = [] + self._mycall = "NOCALL-0" + self._listen = True + + def start(self) -> None: + self._ctrl_srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._ctrl_srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._ctrl_srv.bind(("127.0.0.1", self.ctrl_port)) + self._ctrl_srv.listen(4) + self._data_srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._data_srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._data_srv.bind(("127.0.0.1", self.data_port)) + self._data_srv.listen(4) + self._stop.clear() + for target, name in ((self._accept_ctrl, "ctrl"), (self._accept_data, "data")): + t = threading.Thread(target=target, name=f"m25-host-{name}", daemon=True) + t.start() + self._threads.append(t) + + def stop(self) -> None: + self._stop.set() + for srv in (self._ctrl_srv, self._data_srv): + if srv is not None: + try: + srv.close() + except OSError: + pass + self._ctrl_srv = None + self._data_srv = None + + def _accept_ctrl(self) -> None: + assert self._ctrl_srv is not None + while not self._stop.is_set(): + try: + self._ctrl_srv.settimeout(0.5) + conn, _ = self._ctrl_srv.accept() + except (OSError, socket.timeout): + continue + threading.Thread( + target=self._ctrl_session, + args=(conn,), + daemon=True, + ).start() + + def _ctrl_session(self, conn: socket.socket) -> None: + buf = b"" + try: + conn.settimeout(0.5) + while not self._stop.is_set(): + try: + chunk = conn.recv(4096) + except socket.timeout: + continue + if not chunk: + break + buf += chunk + while b"\n" in buf: + raw, buf = buf.split(b"\n", 1) + line = raw.decode("ascii", errors="replace").strip() + if not line: + continue + reply = self._handle_cmd(line) + conn.sendall((reply + "\n").encode("ascii")) + finally: + conn.close() + + def _handle_cmd(self, line: str) -> str: + parts = line.split() + cmd = parts[0].upper() if parts else "" + if cmd == "INITIALIZE": + return "OK" + if cmd == "PROTOCOLMODE" and len(parts) > 1 and parts[1].upper() == "KISS": + return "OK" + if cmd == "MYCALL" and len(parts) > 1: + self._mycall = parts[1].upper() + return "OK" + if cmd == "LISTEN": + self._listen = len(parts) < 2 or parts[1].upper() in ("TRUE", "1", "ON", "YES") + return "OK" + if cmd == "PING": + return "OK" + if cmd == "STATUS": + return f"STATUS ready mycall={self._mycall}" + return "ERR unknown command" + + def _accept_data(self) -> None: + assert self._data_srv is not None + while not self._stop.is_set(): + try: + self._data_srv.settimeout(0.5) + conn, _ = self._data_srv.accept() + except (OSError, socket.timeout): + continue + threading.Thread( + target=self._data_session, + args=(conn,), + daemon=True, + ).start() + + def _data_session(self, conn: socket.socket) -> None: + try: + payload = conn.recv(4096) + if payload: + reply = self._on_data_tx(payload) + conn.sendall(reply.encode("ascii", errors="replace")) + finally: + conn.close() diff --git a/stacks/crdop/lib/sound_proxy.py b/stacks/crdop/lib/sound_proxy.py new file mode 100644 index 0000000..de9b71d --- /dev/null +++ b/stacks/crdop/lib/sound_proxy.py @@ -0,0 +1,185 @@ +""" +MAX25 sound-proxy — host audio capture/playback. + +Linux/KLinux: ALSA (arecord/aplay). +FreeBSD: OSS via sound_proxy_oss (sox or /dev/dsp). +""" +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import threading +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Optional, Protocol, runtime_checkable + + +@dataclass +class SoundConfig: + capture: str = "default" + playback: str = "default" + sample_rate: int = 48000 + channels: int = 1 + period_frames: int = 256 + forbid_pulse: bool = True + backend: str = "" # alsa | oss — empty = auto from platform + + +@runtime_checkable +class SoundProxyProto(Protocol): + def start_capture(self) -> None: ... + def read_capture(self, nbytes: int) -> bytes: ... + def play_pcm(self, pcm: bytes) -> None: ... + def sniff_loop( + self, + chunk_symbols: int, + on_pcm: Callable[[bytes], None], + stop: Optional[threading.Event] = None, + ) -> None: ... + def close(self) -> None: ... + + +def _detect_backend(cfg: SoundConfig) -> str: + explicit = (cfg.backend or os.environ.get("MAX25_AUDIO_BACKEND", "")).strip().lower() + if explicit in ("alsa", "oss"): + return explicit + if sys.platform.startswith("freebsd"): + return "oss" + return "alsa" + + +def create_sound_proxy(cfg: SoundConfig) -> SoundProxyProto: + backend = _detect_backend(cfg) + if backend == "oss": + from sound_proxy_oss import OssSoundConfig, OssSoundProxy + + cap = cfg.capture if cfg.capture not in ("", "default") else "/dev/dsp" + pb = cfg.playback if cfg.playback not in ("", "default") else cap + return OssSoundProxy( + OssSoundConfig( + capture=cap, + playback=pb, + sample_rate=cfg.sample_rate, + channels=cfg.channels, + ) + ) + return AlsaSoundProxy(cfg) + + +class AlsaSoundProxy: + def __init__(self, cfg: SoundConfig) -> None: + self.cfg = cfg + self._rec_proc: Optional[subprocess.Popen[bytes]] = None + self._play_proc: Optional[subprocess.Popen[bytes]] = None + self._stop = threading.Event() + + def _alsa_env(self) -> dict[str, str]: + env = os.environ.copy() + if self.cfg.forbid_pulse: + env.pop("PULSE_SERVER", None) + env.pop("PIPEWIRE_RUNTIME_DIR", None) + env["PULSE_SERVER"] = "" + return env + + @staticmethod + def _check_device(name: str) -> None: + low = name.lower() + if low in ("default", "pulse", "pipewire") or "pulse" in low or "pipewire" in low: + raise ValueError( + f"audio device {name!r} not allowed — use hw: or plughw: (kernel ALSA)" + ) + + def start_capture(self) -> None: + if not shutil.which("arecord"): + raise RuntimeError("arecord not found — install alsa-utils") + self._check_device(self.cfg.capture) + cmd = [ + "arecord", + "-q", + "-D", + self.cfg.capture, + "-f", + "S16_LE", + "-r", + str(self.cfg.sample_rate), + "-c", + str(self.cfg.channels), + "-t", + "raw", + ] + self._rec_proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + env=self._alsa_env(), + ) + + def read_capture(self, nbytes: int) -> bytes: + if self._rec_proc is None or self._rec_proc.stdout is None: + return b"" + return self._rec_proc.stdout.read(nbytes) or b"" + + def play_pcm(self, pcm: bytes) -> None: + if not pcm: + return + if not shutil.which("aplay"): + raise RuntimeError("aplay not found — install alsa-utils") + self._check_device(self.cfg.playback) + cmd = [ + "aplay", + "-q", + "-D", + self.cfg.playback, + "-f", + "S16_LE", + "-r", + str(self.cfg.sample_rate), + "-c", + str(self.cfg.channels), + "-t", + "raw", + ] + subprocess.run( + cmd, + input=pcm, + check=False, + env=self._alsa_env(), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + def sniff_loop( + self, + chunk_symbols: int, + on_pcm: Callable[[bytes], None], + stop: Optional[threading.Event] = None, + ) -> None: + stop_ev = stop or self._stop + frame_bytes = (self.cfg.sample_rate // 1200) * 2 * chunk_symbols + self.start_capture() + try: + while not stop_ev.is_set(): + chunk = self.read_capture(frame_bytes) + if not chunk: + break + on_pcm(chunk) + finally: + self.close() + + def close(self) -> None: + self._stop.set() + for proc in (self._rec_proc, self._play_proc): + if proc is not None and proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=1.0) + except subprocess.TimeoutExpired: + proc.kill() + self._rec_proc = None + self._play_proc = None + + +# Backward-compatible alias +SoundProxy = AlsaSoundProxy diff --git a/stacks/crdop/lib/sound_proxy_oss.py b/stacks/crdop/lib/sound_proxy_oss.py new file mode 100644 index 0000000..66cf765 --- /dev/null +++ b/stacks/crdop/lib/sound_proxy_oss.py @@ -0,0 +1,151 @@ +""" +MAX25 sound-proxy — FreeBSD/OSS capture and playback. + +Uses `sox` with OSS devices when available; falls back to raw /dev/dsp read/write. +""" +from __future__ import annotations + +import os +import shutil +import subprocess +import threading +from dataclasses import dataclass +from typing import Callable, Optional + + +@dataclass +class OssSoundConfig: + capture: str = "/dev/dsp" + playback: str = "/dev/dsp" + sample_rate: int = 48000 + channels: int = 1 + + +class OssSoundProxy: + def __init__(self, cfg: OssSoundConfig) -> None: + self.cfg = cfg + self._rec_proc: Optional[subprocess.Popen[bytes]] = None + self._dsp_fd: Optional[int] = None + self._stop = threading.Event() + self._use_sox = shutil.which("sox") is not None + + def _sox_capture_cmd(self) -> list[str]: + dev = self.cfg.capture + return [ + "sox", + "-q", + "-t", + "oss", + dev, + "-r", + str(self.cfg.sample_rate), + "-c", + str(self.cfg.channels), + "-b", + "16", + "-e", + "signed-integer", + "-t", + "raw", + "-", + ] + + def _sox_play_cmd(self) -> list[str]: + dev = self.cfg.playback + return [ + "sox", + "-q", + "-t", + "raw", + "-r", + str(self.cfg.sample_rate), + "-c", + str(self.cfg.channels), + "-b", + "16", + "-e", + "signed-integer", + "-", + "-t", + "oss", + dev, + ] + + def start_capture(self) -> None: + if self._use_sox: + self._rec_proc = subprocess.Popen( + self._sox_capture_cmd(), + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + return + flags = os.O_RDONLY + try: + self._dsp_fd = os.open(self.cfg.capture, flags) + except OSError as exc: + raise RuntimeError(f"OSS open {self.cfg.capture}: {exc}") from exc + + def read_capture(self, nbytes: int) -> bytes: + if self._rec_proc is not None and self._rec_proc.stdout is not None: + return self._rec_proc.stdout.read(nbytes) or b"" + if self._dsp_fd is not None: + try: + return os.read(self._dsp_fd, nbytes) or b"" + except OSError: + return b"" + return b"" + + def play_pcm(self, pcm: bytes) -> None: + if not pcm: + return + if self._use_sox: + subprocess.run( + self._sox_play_cmd(), + input=pcm, + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return + try: + fd = os.open(self.cfg.playback, os.O_WRONLY) + except OSError: + return + try: + os.write(fd, pcm) + finally: + os.close(fd) + + def sniff_loop( + self, + chunk_symbols: int, + on_pcm: Callable[[bytes], None], + stop: Optional[threading.Event] = None, + ) -> None: + stop_ev = stop or self._stop + frame_bytes = max(256, (self.cfg.sample_rate // 1200) * 2 * chunk_symbols) + self.start_capture() + try: + while not stop_ev.is_set(): + chunk = self.read_capture(frame_bytes) + if not chunk: + break + on_pcm(chunk) + finally: + self.close() + + def close(self) -> None: + self._stop.set() + if self._rec_proc is not None and self._rec_proc.poll() is None: + self._rec_proc.terminate() + try: + self._rec_proc.wait(timeout=1.0) + except subprocess.TimeoutExpired: + self._rec_proc.kill() + self._rec_proc = None + if self._dsp_fd is not None: + try: + os.close(self._dsp_fd) + except OSError: + pass + self._dsp_fd = None diff --git a/stacks/crdop/lib/test_bell202_line_code.py b/stacks/crdop/lib/test_bell202_line_code.py new file mode 100644 index 0000000..3b22af6 --- /dev/null +++ b/stacks/crdop/lib/test_bell202_line_code.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Unit tests for Bell 202 frequency-toggle line code.""" +from __future__ import annotations + +import sys +from pathlib import Path + +LIB = Path(__file__).resolve().parent +sys.path.insert(0, str(LIB)) + +from bell202_line_code import decode_tones_to_bits, encode_bits_to_tones # noqa: E402 + + +def test_roundtrip_single_byte() -> None: + bits = bytes([0x55]) # 01010101 → many toggles + tones = encode_bits_to_tones(bits, start_tone=0) + back = decode_tones_to_bits(tones, start_tone=0) + assert back == bits + + +def test_all_ones_no_toggle() -> None: + tones = encode_bits_to_tones(bytes([0xFF]), start_tone=0) + assert len(set(tones)) == 1 + + +def main() -> int: + test_roundtrip_single_byte() + test_all_ones_no_toggle() + print("OK: bell202_line_code tests") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stacks/crdop/patches/ardopcf/0001-crdop-linux-gcc-branding.patch b/stacks/crdop/patches/ardopcf/0001-crdop-linux-gcc-branding.patch new file mode 100644 index 0000000..9b37183 --- /dev/null +++ b/stacks/crdop/patches/ardopcf/0001-crdop-linux-gcc-branding.patch @@ -0,0 +1,186 @@ +From: CRDOP <dev@ngteq.local> +Subject: [PATCH] CRDOP: Linux/GCC build fixes and crdopc branding + +Build fixes for GCC 15 (prototypes, ws_server, rawhid fd cast). +Product identity crdopc / 0.1.0-l2-cb; MIT attribution for ngteq changes. +--- + lib/rawhid/rawhid.c | 8 ++++---- + lib/ws_server/ws_server.c | 2 +- + src/common/ARDOPC.c | 4 ++-- + src/common/ARDOPC.h | 2 +- + src/common/ARDOPCommon.c | 5 +++-- + src/common/ARQ.c | 4 ++-- + src/common/HostInterface.c | 2 +- + src/common/ardopcommon.h | 2 +- + src/common/version.h | 2 +- + src/linux/ALSASound.c | 4 ++-- + 10 files changed, 18 insertions(+), 17 deletions(-) + +diff --git a/lib/rawhid/rawhid.c b/lib/rawhid/rawhid.c +index 8791788..256641f 100644 +--- a/lib/rawhid/rawhid.c ++++ b/lib/rawhid/rawhid.c +@@ -358,7 +358,7 @@ int HID_Read_Block() + #ifdef WIN32 + Len = rawhid_recv(0, Msg, 64, 100); + #else +- Len = read(CM108Handle, Msg, 64); ++ Len = read((int)(intptr_t)CM108Handle, Msg, 64); + #endif + + if (Len <= 0) +@@ -403,7 +403,7 @@ int HID_Write_Block() + #ifdef WIN32 + ret = rawhid_send(0, Msg, 64, 100); // Always send 64 + +- if (ret < 0) ++ if (ret != 64) + { + ZF_LOGE("Rigcontrol HID Write Failed %d", errno); + rawhid_close(0); +@@ -411,12 +411,12 @@ int HID_Write_Block() + return 0; + } + #else +- ret = write(CM108Handle, Msg, 64); ++ ret = write((int)(intptr_t)CM108Handle, Msg, 64); + + if (ret != 64) + { + printf ("Write to %s failed, n=%d, errno=%d\n", HIDDevice, ret, errno); +- close (CM108Handle); ++ close((int)(intptr_t)CM108Handle); + CM108Handle = 0; + return 0; + } +diff --git a/lib/ws_server/ws_server.c b/lib/ws_server/ws_server.c +index a542627..e8db176 100644 +--- a/lib/ws_server/ws_server.c ++++ b/lib/ws_server/ws_server.c +@@ -1139,7 +1139,7 @@ int client_handler(char *data, int data_size) + int data_len; + int opcode; + if (state[cnum] == WS_HTTP) +- return process_http_req(cnum); ++ return process_http_req(); + + if ((data_len = read_websocket_frame(data, data_size, &opcode)) < 0) + return (-1); +diff --git a/src/common/ARDOPC.c b/src/common/ARDOPC.c +index dc69a06..34e3f59 100644 +--- a/src/common/ARDOPC.c ++++ b/src/common/ARDOPC.c +@@ -2,7 +2,7 @@ + // + + // ardopcf is a fork by pflarue of ardopc by John Wiseman +-const char ProductName[] = "ardopcf"; ++/* ProductName: CRDOP src/crdop_version.c */ + + // Version k Fix conflicting definitions of bytDataToSend + // Version m Add CM108 PTT (Sept 2021) +@@ -56,7 +56,7 @@ BOOL SerialHostInit(); + void SerialHostPoll(); + void TCPHostPoll(); + BOOL MainPoll(); +-void PlatformSleep(); ++void PlatformSleep(int mS); + const char* PlatformSignalAbbreviation(int signal); + BOOL BusyDetect2(float * dblMag, int intStart, int intStop); + BOOL IsPingToMe(const StationId* caller, const StationId* target); +diff --git a/src/common/ARDOPC.h b/src/common/ARDOPC.h +index da2deb3..bdb8c5e 100644 +--- a/src/common/ARDOPC.h ++++ b/src/common/ARDOPC.h +@@ -176,7 +176,7 @@ void SendReplyToHost(char * strText); + void TCPSendReplyToHost(char * strText); + void LogStats(); + int GetNextFrameData(int * intUpDn, UCHAR * bytFrameTypeToSend, UCHAR * strMod, BOOL blnInitialize); +-void SendData(); ++void SendData(void); + int ComputeInterFrameInterval(int intRequestedIntervalMS); + int Encode4FSKControl(UCHAR bytFrameType, UCHAR bytSessionID, UCHAR * bytreturn); + int EncodeConACKwTiming(UCHAR bytFrameType, int intRcvdLeaderLenMs, UCHAR bytSessionID, UCHAR * bytreturn); +diff --git a/src/common/ARDOPCommon.c b/src/common/ARDOPCommon.c +index c6c068c..85db9cf 100644 +--- a/src/common/ARDOPCommon.c ++++ b/src/common/ARDOPCommon.c +@@ -184,10 +184,11 @@ void processargs(int argc, char * argv[]) + { + case 'h': + +- printf("%s Version %s (https://www.github.com/pflarue/ardop)\n", ProductName, ProductVersion); ++ printf("%s Version %s (https://github.com/ngteq/CRDOP)\n", ProductName, ProductVersion); + printf("Copyright (c) 2014-2024 Rick Muething, John Wiseman, Peter LaRue\n"); ++ printf("CRDOPC modifications Copyright (c) 2026 ngteq (MIT)\n"); + printf( +- "See https://github.com/pflarue/ardop/blob/master/LICENSE for licence details including\n" ++ "See https://github.com/ngteq/CRDOP/blob/main/NOTICE.md and https://github.com/pflarue/ardop/blob/master/LICENSE for licence details including\n" + " information about authors of external libraries used and their licenses.\n" + ); + printf(HelpScreen, ProductName); +diff --git a/src/common/ARQ.c b/src/common/ARQ.c +index 1b44fa7..b67c1d3 100644 +--- a/src/common/ARQ.c ++++ b/src/common/ARQ.c +@@ -822,7 +822,7 @@ int GetNumCarriers(UCHAR bytFrameType) + + // Function to determine the next data frame to send (or IDLE if none) + +-void SendData() ++void SendData(void) + { + char strMod[16]; + int Len; +@@ -1772,7 +1772,7 @@ void ProcessRcvdARQFrame(UCHAR intFrameType, UCHAR * bytData, int DataLen, BOOL + + SetARDOPProtocolState(ISS); + ARQState = ISSData; +- SendData(FALSE); ++ SendData(); + return; + } + // In an active ARQ Connection, only send an IDFrame when ProtocolState +diff --git a/src/common/HostInterface.c b/src/common/HostInterface.c +index 0ba4eca..e4e6ddd 100644 +--- a/src/common/HostInterface.c ++++ b/src/common/HostInterface.c +@@ -7,7 +7,7 @@ + BOOL blnHostRDY = FALSE; + extern int intFECFramesSent; + +-void SendData(); ++void SendData(void); + BOOL CheckForDisconnect(); + int Encode4FSKControl(UCHAR bytFrameType, UCHAR bytSessionID, UCHAR * bytreturn); + int ComputeInterFrameInterval(int intRequestedIntervalMS); +diff --git a/src/common/ardopcommon.h b/src/common/ardopcommon.h +index 91cf281..1a334eb 100644 +--- a/src/common/ardopcommon.h ++++ b/src/common/ardopcommon.h +@@ -165,7 +165,7 @@ void SendReplyToHost(char * strText); + void TCPSendReplyToHost(char * strText); + void LogStats(); + int GetNextFrameData(int * intUpDn, UCHAR * bytFrameTypeToSend, UCHAR * strMod, BOOL blnInitialize); +-void SendData(); ++void SendData(void); + int ComputeInterFrameInterval(int intRequestedIntervalMS); + int Encode4FSKControl(UCHAR bytFrameType, UCHAR bytSessionID, UCHAR * bytreturn); + VOID WriteExceptionLog(const char * format, ...); +diff --git a/src/linux/ALSASound.c b/src/linux/ALSASound.c +index 4938b47..d15dbcc 100644 +--- a/src/linux/ALSASound.c ++++ b/src/linux/ALSASound.c +@@ -404,10 +404,10 @@ int platform_main(int argc, char * argv[]) + + setlinebuf(stdout); // So we can redirect output to file and tail + +- ZF_LOGI("%s Version %s (https://www.github.com/pflarue/ardop)", ProductName, ProductVersion); ++ ZF_LOGI("%s Version %s (https://github.com/ngteq/CRDOP)", ProductName, ProductVersion); + ZF_LOGI("Copyright (c) 2014-2024 Rick Muething, John Wiseman, Peter LaRue"); + ZF_LOGI( +- "See https://github.com/pflarue/ardop/blob/master/LICENSE for licence details including\n" ++ "See https://github.com/ngteq/CRDOP/blob/main/NOTICE.md and https://github.com/pflarue/ardop/blob/master/LICENSE for licence details including\n" + " information about authors of external libraries used and their licenses." + ); + ZF_LOGD("Command line: %s", cmdstr); diff --git a/stacks/crdop/patches/ardopcf/0002-crdop-version-override.patch b/stacks/crdop/patches/ardopcf/0002-crdop-version-override.patch new file mode 100644 index 0000000..cda40d2 --- /dev/null +++ b/stacks/crdop/patches/ardopcf/0002-crdop-version-override.patch @@ -0,0 +1,18 @@ +From: CRDOP <dev@ngteq.local> +Subject: [PATCH 2/2] CRDOP: version from CRDOP tree (single source of truth) + +ProductName and ProductVersion live in src/crdop_version.c / include/crdop/version.h. +--- + src/common/version.h | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/src/common/version.h b/src/common/version.h +index faabb3e..c4d0e1f 100644 +--- a/src/common/version.h ++++ b/src/common/version.h +@@ -4,4 +4,4 @@ + // ardopcf was a mistake. This was due to a bug and misunderstanding + // of the ardop version numbering scheme. So ardopcf v1.0.4.1.1 comes + // after and replaces v2.0.3.2.1 +-const char ProductVersion[] = "1.0.4.1.3"; ++extern const char ProductVersion[]; /* CRDOP src/crdop_version.c */ diff --git a/stacks/crdop/scripts/apply-vendor-patches.sh b/stacks/crdop/scripts/apply-vendor-patches.sh new file mode 100755 index 0000000..da62bf5 --- /dev/null +++ b/stacks/crdop/scripts/apply-vendor-patches.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Apply CRDOP patch series — only if vendor tree is still pristine upstream. +# Released CRDOP commits ship vendor/ardopcf with patches already integrated. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +VENDOR="${ROOT}/vendor/ardopcf" +PATCH_DIR="${ROOT}/patches/ardopcf" +STAMP="${VENDOR}/.crdop-patches-applied" +MARKER="${VENDOR}/src/common/ARDOPC.c" + +if [[ ! -d "${VENDOR}/src" ]]; then + echo "error: ${VENDOR} missing" >&2 + exit 1 +fi + +if grep -q 'CRDOP src/crdop_version.c' "${MARKER}" 2>/dev/null; then + date -u +'%Y-%m-%dT%H:%M:%SZ' > "${STAMP}" + echo "CRDOP vendor already integrated ($(cat "${STAMP}"))" + exit 0 +fi + +if [[ ! -d "${PATCH_DIR}" ]] || ! compgen -G "${PATCH_DIR}/*.patch" >/dev/null; then + echo "error: no patches in ${PATCH_DIR}" >&2 + exit 1 +fi + +cd "${VENDOR}" +for p in "${PATCH_DIR}"/*.patch; do + echo "applying ${p#${ROOT}/}" + git apply --check "${p}" 2>/dev/null || patch -p1 --forward -r - < "${p}" + git apply "${p}" 2>/dev/null || patch -p1 --forward < "${p}" +done + +date -u +'%Y-%m-%dT%H:%M:%SZ' > "${STAMP}" +echo "CRDOP patches applied ($(cat "${STAMP}"))" diff --git a/stacks/crdop/scripts/build-crdop.sh b/stacks/crdop/scripts/build-crdop.sh new file mode 100755 index 0000000..2253d5a --- /dev/null +++ b/stacks/crdop/scripts/build-crdop.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# CRDOP unified build — vendor embedded; GCC, Clang, or MSVC via CMake. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BUILD="${CRDOP_BUILD_DIR:-${ROOT}/build}" +TYPE="${CRDOP_BUILD_TYPE:-Release}" +TOOLCHAIN="${CRDOP_TOOLCHAIN:-}" +BUILD_TESTS="${CRDOP_BUILD_TESTS:-OFF}" +RUN_TESTS="${CRDOP_RUN_TESTS:-OFF}" + +if [[ -f "${BUILD}/CMakeCache.txt" ]]; then + cached_home="$(grep -m1 '^CMAKE_HOME_DIRECTORY:INTERNAL=' "${BUILD}/CMakeCache.txt" 2>/dev/null | cut -d= -f2- || true)" + if [[ -n "${cached_home}" && "${cached_home}" != "${ROOT}" ]]; then + echo "CRDOP: removing stale CMake cache (was ${cached_home}, now ${ROOT})" >&2 + rm -rf "${BUILD}" + fi +fi + +"${ROOT}/scripts/import-ardopcf.sh" + +CMAKE_ARGS=(-B "${BUILD}" -DCMAKE_BUILD_TYPE="${TYPE}" -DCRDOP_BUILD_TESTS="${BUILD_TESTS}") + +if [[ -n "${TOOLCHAIN}" ]]; then + CMAKE_ARGS+=(-DCMAKE_TOOLCHAIN_FILE="${ROOT}/cmake/toolchains/${TOOLCHAIN}.cmake") +fi + +if [[ -n "${CC:-}" ]]; then + CMAKE_ARGS+=(-DCMAKE_C_COMPILER="${CC}") +fi +if [[ -n "${CXX:-}" ]]; then + CMAKE_ARGS+=(-DCMAKE_CXX_COMPILER="${CXX}") +fi + +cmake "${ROOT}" "${CMAKE_ARGS[@]}" +cmake --build "${BUILD}" -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)" + +"${ROOT}/scripts/test-smoke.sh" + +if [[ "${BUILD_TESTS}" == "ON" || "${BUILD_TESTS}" == "1" || "${RUN_TESTS}" == "ON" || "${RUN_TESTS}" == "1" ]]; then + if [[ -f "${BUILD}/CTestTestfile.cmake" ]]; then + ctest --test-dir "${BUILD}" --output-on-failure + else + echo "NOTE: CRDOP_BUILD_TESTS requested but no CTest targets (install libcmocka-dev?)" >&2 + fi +fi + +echo "CRDOP $(cat "${ROOT}/VERSION") built: ${BUILD}/crdopc" diff --git a/stacks/crdop/scripts/crdopc b/stacks/crdop/scripts/crdopc new file mode 100755 index 0000000..2fc82bd --- /dev/null +++ b/stacks/crdop/scripts/crdopc @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +# CRDOP launcher — native MAX25-SoftModem (audio-dummyd / M25 host). +# ARDOP is a separate optional MAX25-Stack plugin — not part of CRDOP. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +MAX25_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +PREFIX_SHARE="${SCRIPT_DIR}/../share/crdop" + +if [[ -n "${CRDOP_INI:-}" ]]; then + INI="${CRDOP_INI}" +elif [[ -f "${HOME}/.config/crdop/crdop.ini" ]]; then + INI="${HOME}/.config/crdop/crdop.ini" +elif [[ -f "${PREFIX_SHARE}/crdop.ini.example" ]]; then + INI="${PREFIX_SHARE}/crdop.ini.example" +else + INI="${ROOT}/share/crdop.ini.example" +fi + +ini_get() { + local section="$1" key="$2" default="$3" file="$4" + [[ -f "${file}" ]] || { echo "${default}"; return; } + awk -F= -v sec="[${section}]" -v k="${key}" -v d="${default}" ' + $0 ~ /^[[:space:]]*[#;]/ { next } + $1 ~ /^\[/ { cur=$0; gsub(/[][]/, "", cur); next } + cur == substr(sec, 2, length(sec)-2) { + gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1) + if ($1 == k) { + v=$2; gsub(/^[[:space:]]+|[[:space:]]+$/, "", v); gsub(/^"|"$/, "", v) + print v; found=1; exit + } + } + END { if (!found) print d } + ' "${file}" +} + +resolve_ini() { + if [[ ! -f "${INI}" && -f "${ROOT}/share/crdop.ini.example" ]]; then + INI="${ROOT}/share/crdop.ini.example" + fi +} + +normalize_profile() { + case "${1,,}" in + cb|dual|amateur) echo "${1,,}" ;; + *) + echo "warn: unknown radio_profile '${1}', using cb" >&2 + echo "cb" + ;; + esac +} + +normalize_duplex() { + case "${1,,}" in + full|half) echo "${1,,}" ;; + *) + echo "warn: unknown duplex '${1}', using half" >&2 + echo "half" + ;; + esac +} + +enforce_host_audio() { + local backend + backend="$(ini_get audio backend "" "${INI}")" + if [[ -z "${backend}" ]]; then + case "$(uname -s)" in + FreeBSD) backend="oss" ;; + *) backend="alsa" ;; + esac + fi + if [[ "${backend}" == "alsa" ]]; then + local no_pulse + no_pulse="$(ini_get audio no_pulse yes "${INI}")" + case "${no_pulse,,}" in + 1|yes|true|on) + unset PULSE_SERVER PULSE_COOKIE PIPEWIRE_RUNTIME_DIR 2>/dev/null || true + export PULSE_SERVER= + export ALSA_CARD="${ALSA_CARD:-}" + ;; + esac + fi +} + +audio_cli_args() { + local cap pb backend + backend="$(ini_get audio backend "" "${INI}")" + if [[ -z "${backend}" ]]; then + case "$(uname -s)" in + FreeBSD) backend="oss" ;; + *) backend="alsa" ;; + esac + fi + cap="$(ini_get audio capture "" "${INI}")" + pb="$(ini_get audio playback "" "${INI}")" + local args=() + [[ -n "${cap}" ]] && args+=("${cap}") + [[ -n "${pb}" ]] && args+=("${pb}") + if [[ ${#args[@]} -gt 0 ]]; then + if [[ "${backend}" == "alsa" ]]; then + for a in "${args[@]}"; do + if [[ "${a}" == *pulse* || "${a}" == *pipewire* || "${a}" == default ]]; then + echo "error: audio device '${a}' — use hw:/plughw: kernel ALSA" >&2 + exit 1 + fi + done + fi + printf '%s\n' "${args[@]}" + elif [[ "${backend}" == "oss" ]]; then + echo "/dev/dsp" + echo "/dev/dsp" + fi +} + +find_audio_dummyd() { + for candidate in \ + "${MAX25_ROOT}/build/bin/audio-dummyd" \ + "${MAX25_ROOT}/stacks/crdop/tools/audio-dummyd" \ + "$(command -v audio-dummyd 2>/dev/null || true)"; do + [[ -n "${candidate}" && -x "${candidate}" ]] || continue + echo "${candidate}" + return + done + return 1 +} + +resolve_ini + +if [[ -f "${PREFIX_SHARE}/VERSION" ]]; then + CRDOP_VERSION="$(tr -d '[:space:]' < "${PREFIX_SHARE}/VERSION")" +elif [[ -f "${ROOT}/VERSION" ]]; then + CRDOP_VERSION="$(tr -d '[:space:]' < "${ROOT}/VERSION")" +else + CRDOP_VERSION="CUR999" +fi + +PROFILE="$(normalize_profile "$(ini_get profile radio_profile cb "${INI}")")" +DUPLEX="$(normalize_duplex "$(ini_get modem duplex half "${INI}")")" +MYCALL="$(ini_get mycall call NOCALL-0 "${INI}")" +HOST_PORT="$(ini_get host port 8515 "${INI}")" +DATA_PORT=$((HOST_PORT + 1)) +SAMPLE_RATE="$(ini_get audio sample_rate 48000 "${INI}")" + +if [[ $# -ge 1 && "${1}" =~ ^[0-9]+$ ]]; then + HOST_PORT="$1" + DATA_PORT=$((HOST_PORT + 1)) + shift +fi + +mapfile -t AUDIO_ARGS < <(audio_cli_args || true) +enforce_host_audio + +DUMMY="$(find_audio_dummyd || true)" +if [[ -z "${DUMMY}" ]]; then + echo "error: audio-dummyd not found — build MAX25 or set path" >&2 + exit 1 +fi + +DUMMY_ARGS=(--ctrl-port "${HOST_PORT}" --data-port "${DATA_PORT}" -r "${SAMPLE_RATE}") +if [[ ${#AUDIO_ARGS[@]} -ge 1 ]]; then + DUMMY_ARGS+=(-D "${AUDIO_ARGS[0]}") +fi +if [[ ${#AUDIO_ARGS[@]} -ge 2 ]]; then + DUMMY_ARGS+=(-P "${AUDIO_ARGS[1]}") +fi + +AUDIO_BACKEND="$(ini_get audio backend "" "${INI}")" +[[ -z "${AUDIO_BACKEND}" ]] && case "$(uname -s)" in FreeBSD) AUDIO_BACKEND="oss" ;; *) AUDIO_BACKEND="alsa" ;; esac +echo "CRDOP ${CRDOP_VERSION} profile=${PROFILE} duplex=${DUPLEX} ctrl=:${HOST_PORT} data=:${DATA_PORT} call=${MYCALL} audio=${AUDIO_BACKEND} (native M25)" +exec "${DUMMY}" "${DUMMY_ARGS[@]}" "$@" diff --git a/stacks/crdop/scripts/import-ardopcf.sh b/stacks/crdop/scripts/import-ardopcf.sh new file mode 100755 index 0000000..26ac003 --- /dev/null +++ b/stacks/crdop/scripts/import-ardopcf.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Verify vendor/ardopcf is present (embedded in repo — no submodule/download). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +VENDOR="${ROOT}/vendor/ardopcf" + +if [[ ! -f "${VENDOR}/LICENSE" || ! -f "${VENDOR}/src/common/ARDOPC.c" ]]; then + echo "error: vendor/ardopcf missing — clone CRDOP with full tree or run scripts/refresh-vendor-ardopcf.sh" >&2 + exit 1 +fi + +if ! grep -q 'CRDOP src/crdop_version.c' "${VENDOR}/src/common/ARDOPC.c" 2>/dev/null; then + echo "warn: vendor/ardopcf may need CRDOP patches — run scripts/apply-vendor-patches.sh" >&2 +fi + +echo "vendor/ardopcf OK (embedded, commit $(grep '^commit=' "${ROOT}/vendor/ardopcf.ref" 2>/dev/null | cut -d= -f2 || echo unknown))" diff --git a/stacks/crdop/scripts/install-crdop.sh b/stacks/crdop/scripts/install-crdop.sh new file mode 100755 index 0000000..23f631c --- /dev/null +++ b/stacks/crdop/scripts/install-crdop.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Build and install CRDOP to PREFIX (default /usr/local). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PREFIX="${CRDOP_PREFIX:-/usr/local}" +BUILD="${CRDOP_BUILD_DIR:-${ROOT}/build}" + +"${ROOT}/scripts/build-crdop.sh" + +cmake --install "${BUILD}" --prefix "${PREFIX}" + +echo "Installed:" +echo " ${PREFIX}/bin/crdopc — modem binary" +echo " ${PREFIX}/bin/crdop — profile launcher" +echo " ${PREFIX}/share/crdop/ — INI examples, VERSION" diff --git a/stacks/crdop/scripts/refresh-vendor-ardopcf.sh b/stacks/crdop/scripts/refresh-vendor-ardopcf.sh new file mode 100755 index 0000000..b9d2f93 --- /dev/null +++ b/stacks/crdop/scripts/refresh-vendor-ardopcf.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Maintainer tool: re-download upstream ardopcf and re-apply CRDOP patches. +# Normal builds use the committed vendor/ardopcf tree — no network required. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +VENDOR="${ROOT}/vendor/ardopcf" +REF_FILE="${ROOT}/vendor/ardopcf.ref" +URL="${CRDOP_UPSTREAM_URL:-https://github.com/pflarue/ardop.git}" +PIN="$(grep '^commit_full=' "${REF_FILE}" 2>/dev/null | cut -d= -f2- | head -1)" +PIN="${PIN:-$(grep '^commit=' "${REF_FILE}" 2>/dev/null | cut -d= -f2- | head -1)}" + +echo "This replaces vendor/ardopcf from upstream. Press Ctrl+C to abort." +sleep 2 + +rm -rf "${VENDOR}" +git clone --depth 1 --branch master "${URL}" "${VENDOR}" +if [[ -n "${PIN}" ]]; then + git -C "${VENDOR}" fetch --depth 1 origin "${PIN}" + git -C "${VENDOR}" checkout "${PIN}" +fi +rm -rf "${VENDOR}/.git" +rm -f "${VENDOR}/.crdop-patches-applied" + +"${ROOT}/scripts/apply-vendor-patches.sh" +echo "Done. Review diff, update vendor/ardopcf.ref if needed, commit vendor/ardopcf/" diff --git a/stacks/crdop/scripts/sync-upstream.sh b/stacks/crdop/scripts/sync-upstream.sh new file mode 100755 index 0000000..a1353ad --- /dev/null +++ b/stacks/crdop/scripts/sync-upstream.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Test whether CRDOP patches still apply to fresh upstream (maintainers). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TMP="$(mktemp -d)" +trap 'rm -rf "${TMP}"' EXIT + +REF="${ROOT}/vendor/ardopcf.ref" +PIN="$(grep '^commit_full=' "${REF}" | cut -d= -f2-)" +URL="https://github.com/pflarue/ardop.git" + +git clone --depth 1 "${URL}" "${TMP}/ardopcf" +git -C "${TMP}/ardopcf" fetch --depth 1 origin "${PIN}" +git -C "${TMP}/ardopcf" checkout "${PIN}" + +for p in "${ROOT}"/patches/ardopcf/*.patch; do + git -C "${TMP}/ardopcf" apply --check "${p}" +done + +echo "OK: patches apply to upstream ${PIN}" diff --git a/stacks/crdop/scripts/test-all.sh b/stacks/crdop/scripts/test-all.sh new file mode 100755 index 0000000..f12181e --- /dev/null +++ b/stacks/crdop/scripts/test-all.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Smoke test + optional upstream cmocka unit tests (CTest). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BUILD="${CRDOP_BUILD_DIR:-${ROOT}/build}" + +export CRDOP_BUILD_TESTS="${CRDOP_BUILD_TESTS:-ON}" +export CRDOP_RUN_TESTS="${CRDOP_RUN_TESTS:-ON}" + +"${ROOT}/scripts/build-crdop.sh" + +if [[ ! -f "${BUILD}/CTestTestfile.cmake" ]]; then + echo "NOTE: unit tests not built (libcmocka missing or unsupported platform)" >&2 +fi + +echo "OK: CRDOP test-all finished" diff --git a/stacks/crdop/scripts/test-smoke.sh b/stacks/crdop/scripts/test-smoke.sh new file mode 100755 index 0000000..6ee6eff --- /dev/null +++ b/stacks/crdop/scripts/test-smoke.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +REPO="$(cd "${ROOT}/../.." && pwd)" +BIN="${CRDOP_BIN:-${ROOT}/build/crdopc}" + +if [[ ! -x "${BIN}" ]]; then + for alt in "${REPO}/build/bin/crdopc" "${REPO}/build/crdop/crdopc"; do + if [[ -x "${alt}" ]]; then + BIN="${alt}" + break + fi + done +fi +VER="$(tr -d '[:space:]' < "${ROOT}/VERSION")" + +[[ -x "${BIN}" ]] || { echo "FAIL: ${BIN} missing" >&2; exit 1; } + +OUT="$("${BIN}" -h 2>&1 || true)" +echo "${OUT}" | grep -qi crdopc || { echo "FAIL: crdopc branding"; exit 1; } +echo "${OUT}" | grep -q "${VER}" || { echo "FAIL: expected version ${VER}"; exit 1; } + +echo "OK: CRDOP ${VER} smoke test passed" diff --git a/stacks/crdop/scripts/test-with-host.sh b/stacks/crdop/scripts/test-with-host.sh new file mode 100755 index 0000000..f14baac --- /dev/null +++ b/stacks/crdop/scripts/test-with-host.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# P1 — loopback host integration (placeholder). +set -euo pipefail + +echo "Host loopback test not automated yet." +echo " 1. ./scripts/crdopc" +echo " 2. Connect M25/KISS host client to 127.0.0.1:8515" +echo " 3. INITIALIZE / PROTOCOLMODE KISS / LISTEN — verify STATUS ready" +exit 0 diff --git a/stacks/crdop/share/crdop-amateur.ini.example b/stacks/crdop/share/crdop-amateur.ini.example new file mode 100644 index 0000000..dd76f68 --- /dev/null +++ b/stacks/crdop/share/crdop-amateur.ini.example @@ -0,0 +1,15 @@ +; CRDOP — amateur profile (secondary) +; Docs: docs/EXAMPLES.md + +[profile] +radio_profile = amateur + +[modem] +arq_bandwidth = 1000MAX +duplex = half + +[host] +port = 8515 + +[mycall] +call = CALL-1 diff --git a/stacks/crdop/share/crdop-dual.ini.example b/stacks/crdop/share/crdop-dual.ini.example new file mode 100644 index 0000000..4601d5c --- /dev/null +++ b/stacks/crdop/share/crdop-dual.ini.example @@ -0,0 +1,15 @@ +; CRDOP — dual profile (CB ↔ amateur turnaround) +; Docs: docs/EXAMPLES.md + +[profile] +radio_profile = dual + +[modem] +arq_bandwidth = 500MAX +duplex = half + +[host] +port = 8515 + +[mycall] +call = CB01-0 diff --git a/stacks/crdop/share/crdop.freebsd.ini.example b/stacks/crdop/share/crdop.freebsd.ini.example new file mode 100644 index 0000000..3d0f347 --- /dev/null +++ b/stacks/crdop/share/crdop.freebsd.ini.example @@ -0,0 +1,25 @@ +; MAX25-SoftModem (CRDOP) — FreeBSD / OSS +; Copy: cp share/crdop/crdop.freebsd.ini.example ~/.config/crdop/crdop.ini +; pkg install sox (recommended for OSS capture/playback) + +[profile] +radio_profile = cb + +[modem] +speed_baud = 1200 +duplex = half +max_speed_baud = 19200 + +[host] +port = 8515 + +[mycall] +call = NOCALL-0 + +[audio] +backend = oss +no_pulse = yes +; pcm0 from sysctl dev.pcm — typical OSS path: +capture = /dev/dsp +playback = /dev/dsp +sample_rate = 48000 diff --git a/stacks/crdop/share/crdop.ini.example b/stacks/crdop/share/crdop.ini.example new file mode 100644 index 0000000..52dc3a1 --- /dev/null +++ b/stacks/crdop/share/crdop.ini.example @@ -0,0 +1,37 @@ +; MAX25-SoftModem (CRDOP) — CB profile (default) +; Copy: cp share/crdop.ini.example ~/.config/crdop/crdop.ini +; Docs: docs/SOFTMODEM.md · docs/AUDIO-ARCHITECTURE.md · docs/CONFIG.md +; +; Development/test phase — in-house MAX25-Stack standard. +; Audio: kernel ALSA ONLY via MAX25 sound-proxy — NO PulseAudio / PipeWire route. +; Good sound card REQUIRED (accurate tones; stricter at higher baud). +; 1200 baud primary, max 19200 baud. Duplex: half | full + +[profile] +radio_profile = cb + +[modem] +speed_baud = 1200 +duplex = half +max_speed_baud = 19200 + +[host] +port = 8515 + +[mycall] +call = NOCALL-0 + +[audio] +; MAX25 sound-proxy → libasound → kernel ALSA → hardware (direct) +backend = alsa-kernel +; Reject pulse/pipewire pseudo devices — production path is kernel-only +no_pulse = yes +; REQUIRED: explicit hw: or plughw: from arecord -l / aplay -l +; capture = hw:1,0 +; playback = hw:1,0 +; sample_rate = 48000 +; period_frames = 256 + +[coupling] +; line | acoustic — radio or TNC/BayCom reference path +mode = line diff --git a/stacks/crdop/src/crdop_version.c b/stacks/crdop/src/crdop_version.c new file mode 100644 index 0000000..7136d4a --- /dev/null +++ b/stacks/crdop/src/crdop_version.c @@ -0,0 +1,5 @@ +#include "crdop/version.h" + +/* CRDOP product identity — overrides vendor version.h when linked first. */ +const char ProductName[] = CRDOP_PRODUCT_NAME; +const char ProductVersion[] = CRDOP_VERSION_STRING; diff --git a/stacks/crdop/tools/audio-dummyd b/stacks/crdop/tools/audio-dummyd new file mode 100755 index 0000000..e64bda5 --- /dev/null +++ b/stacks/crdop/tools/audio-dummyd @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +exec python3 "${ROOT}/stacks/crdop/tools/audio-dummyd.py" "$@" diff --git a/stacks/crdop/tools/audio-dummyd.py b/stacks/crdop/tools/audio-dummyd.py new file mode 100644 index 0000000..a4bb922 --- /dev/null +++ b/stacks/crdop/tools/audio-dummyd.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Audio dummy daemon — M25 host TCP + acoustic DSP bench for max25d.""" +from __future__ import annotations + +import argparse +import sys +import threading +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +LIB = ROOT / "stacks" / "crdop" / "lib" +sys.path.insert(0, str(LIB)) + +from acoustic_engine import AcousticEngine # noqa: E402 +from m25_host_protocol import DEFAULT_CTRL_PORT, DEFAULT_DATA_PORT, M25SoftModemHost # noqa: E402 +from sound_proxy import SoundConfig # noqa: E402 + +_DAEMON = ROOT / "stacks" / "daemon" +sys.path.insert(0, str(_DAEMON)) +from ax25_codec import ax25_build_ui # noqa: E402 + + +def main() -> int: + ap = argparse.ArgumentParser(description="MAX25 audio-dummy device daemon") + ap.add_argument("--ctrl-port", type=int, default=DEFAULT_CTRL_PORT) + ap.add_argument("--data-port", type=int, default=DEFAULT_DATA_PORT) + ap.add_argument("-D", "--capture", default="") + ap.add_argument("-P", "--playback", default="") + ap.add_argument("-r", "--rate", type=int, default=48000) + args = ap.parse_args() + + sound = SoundConfig( + capture=args.capture or "default", + playback=args.playback or args.capture or "default", + sample_rate=args.rate, + ) + eng = AcousticEngine(sample_rate=args.rate, sound=sound) + rx_lock = threading.Lock() + last_rx: list[str] = [] + + def on_tx(payload: bytes) -> str: + if len(payload) < 16: + return "ERR short payload" + pcm = eng.mod.modulate_bits(payload) + eng.sound.playback = sound.playback + from sound_proxy import create_sound_proxy + + create_sound_proxy(sound).play_pcm(pcm) + return "OK" + + host = M25SoftModemHost(args.ctrl_port, args.data_port, on_data_tx=on_tx) + host.start() + print( + f"audio-dummyd ctrl=:{args.ctrl_port} data=:{args.data_port} rate={args.rate}", + flush=True, + ) + try: + while True: + threading.Event().wait(3600.0) + except KeyboardInterrupt: + host.stop() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stacks/crdop/tools/max25-signal-sniffer b/stacks/crdop/tools/max25-signal-sniffer new file mode 100755 index 0000000..e412997 --- /dev/null +++ b/stacks/crdop/tools/max25-signal-sniffer @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# MAX25 signal sniffer launcher +set -euo pipefail +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +exec python3 "${ROOT}/stacks/crdop/tools/max25-signal-sniffer.py" "$@" diff --git a/stacks/crdop/tools/max25-signal-sniffer.py b/stacks/crdop/tools/max25-signal-sniffer.py new file mode 100644 index 0000000..fed95f7 --- /dev/null +++ b/stacks/crdop/tools/max25-signal-sniffer.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""MAX25 signal sniffer — analyze Bell 202 AFSK on ALSA capture or PCM file.""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +LIB = ROOT / "stacks" / "crdop" / "lib" +sys.path.insert(0, str(LIB)) + +from acoustic_engine import AcousticEngine # noqa: E402 +from sound_proxy import SoundConfig, SoundProxy # noqa: E402 + + +def read_wav_pcm(path: Path) -> bytes: + import wave + + with wave.open(str(path), "rb") as wf: + if wf.getsampwidth() != 2 or wf.getnchannels() != 1: + raise SystemExit("WAV must be mono S16_LE") + return wf.readframes(wf.getnframes()) + + +def main() -> int: + ap = argparse.ArgumentParser(description="MAX25 acoustic signal sniffer (Bell 202 1200 bd)") + ap.add_argument("-D", "--device", default="", help="ALSA capture device (hw: or plughw:)") + ap.add_argument("-r", "--rate", type=int, default=48000) + ap.add_argument("-f", "--file", type=Path, help="Read mono S16 WAV instead of ALSA") + ap.add_argument("-t", "--seconds", type=float, default=2.0, help="ALSA capture duration") + ap.add_argument("--loopback", action="store_true", help="Internal DSP loopback (no audio hw)") + ap.add_argument("--mark", action="store_true", help="Play mark calibration tone (1200 Hz)") + ap.add_argument("--space", action="store_true", help="Play space calibration tone (2200 Hz)") + args = ap.parse_args() + + cfg = SoundConfig(capture=args.device or "default", sample_rate=args.rate) + eng = AcousticEngine(sample_rate=args.rate, sound=cfg) + + if args.loopback: + rep = eng.loopback_self_test() + print(f"loopback samples={rep.samples} symbols={rep.symbols} transitions={rep.transitions}") + print(f"mark={rep.mark_ratio:.2%} space={rep.space_ratio:.2%}") + for line in rep.decode_lines: + print(line) + return 0 if rep.decode_lines else 1 + + if args.mark: + eng.play_mark_calibration(args.seconds) + print(f"played mark tone {args.seconds}s") + return 0 + if args.space: + eng.play_space_calibration(args.seconds) + print(f"played space tone {args.seconds}s") + return 0 + + if args.file: + pcm = read_wav_pcm(args.file) + else: + if not args.device: + print("error: -D hw:... or --file or --loopback required", file=sys.stderr) + return 2 + proxy = SoundProxy(cfg) + proxy.start_capture() + nbytes = int(args.rate * args.seconds) * 2 + pcm = proxy.read_capture(nbytes) + proxy.close() + + rep = eng.analyze_pcm(pcm) + print(f"sniff samples={rep.samples} symbols={rep.symbols} transitions={rep.transitions}") + print(f"mark={rep.mark_ratio:.2%} space={rep.space_ratio:.2%} frames={len(rep.frames)}") + for line in rep.decode_lines: + print(line) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stacks/crdop/vendor/README.md b/stacks/crdop/vendor/README.md new file mode 100644 index 0000000..d5f9c02 --- /dev/null +++ b/stacks/crdop/vendor/README.md @@ -0,0 +1,5 @@ +# Vendor tree (dev-only) + +Legacy **ardopcf** may exist under `vendor/ardopcf/` for local developer builds (`-DCRDOP_VENDOR_ARDOPCF=ON`). **Not** part of MAX25 release install. + +Operator ARDOP (third-party host): [plugins/external/ardop/README.md](../../plugins/external/ardop/README.md) — separate from CRDOP. diff --git a/stacks/crdop/vendor/ardopcf.ref b/stacks/crdop/vendor/ardopcf.ref new file mode 100644 index 0000000..54a0ab6 --- /dev/null +++ b/stacks/crdop/vendor/ardopcf.ref @@ -0,0 +1,9 @@ +# Pinned upstream for CRDOP vendor submodule (vendor/ardopcf) +# Update with: scripts/sync-upstream.sh + +url=https://github.com/pflarue/ardop.git +branch=master +commit=a7c9228 +commit_full=a7c92289b569afbe4259dc556d749405ebc008f5 +describe=ardopcf baseline (Update CONTRIBUTING.md fix typo) +imported=2026-07-04 diff --git a/stacks/daemon/README.md b/stacks/daemon/README.md new file mode 100644 index 0000000..bfba87e --- /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 — [docs/BAYCOM.md](../../docs/BAYCOM.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`. See [ARCHITECTURE.md](../../docs/ARCHITECTURE.md#linux-host-policy--one-rf-device). + +```ini +[devices] +default = tnc2c +tnc2c = /dev/ttyS4 +``` + +**Legacy multi-id** (deprecated for new sites): `share/max25/max25d.full-station.ini.example`, `max25d.dual-baycom.ini.example`. + +M25/1: `devices=` in `STATUS`, `SET DEVICE <id>`, `GET DEVICES`, `RX device=<id> …`. + +## Source ban list + +Block unwanted AX.25 callers (source address on incoming UI frames). Banned traffic is dropped silently — no terminal display, no daemon log line. + +```ini +[modem] +bans_file = /etc/max25/bans.txt +``` + +File format: one callsign per line (`#` comments allowed). Ban without SSID blocks all SSIDs of that call (`DG1ABC` blocks `DG1ABC-7`). Ban with SSID (`DK0WC-7`) matches only that SSID. + +M25/1: `BAN <callsign>`, `UNBAN <callsign>`, `BANS`. Changes persist to `bans_file` immediately. + +## Install + +```bash +./scripts/install-max25.sh --deps +# Installs: /usr/local/bin/max25d, kiss_bridge.py, device_backends.py, max25-terminal +``` + +Host setup guide: [docs/LINUX-HOST-SETUP.md](../../docs/LINUX-HOST-SETUP.md). Protocol: [include/max25/protocol.md](../../include/max25/protocol.md). Client: [docs/MAX25-CLIENT.md](../../docs/MAX25-CLIENT.md). diff --git a/stacks/daemon/ax25_codec.py b/stacks/daemon/ax25_codec.py new file mode 100644 index 0000000..03ee671 --- /dev/null +++ b/stacks/daemon/ax25_codec.py @@ -0,0 +1,176 @@ +""" +AX.25 UI frame codec for max25d — address fields, CRC-CCITT FCS, UI parse/build. + +Derived from libax25 axutils.c (address encoding) and ax25ipd/crc.c (RFC 1171 FCS). +KISS DATA carries the AX.25 body without FCS; over-the-air and some paths include FCS. +""" +from __future__ import annotations + +import re +from typing import Optional + +# HDLC CRC-CCITT (polynomial 0x1021), RFC 1171 / ax25ipd table +_FCS_TABLE: tuple[int, ...] = ( + 0x0000, 0x1189, 0x2312, 0x329B, 0x4624, 0x57AD, 0x6536, 0x74BF, + 0x8C48, 0x9DC1, 0xAF5A, 0xBED3, 0xCA6C, 0xDBE5, 0xE97E, 0xF8F7, + 0x1081, 0x0108, 0x3393, 0x221A, 0x56A5, 0x472C, 0x75B7, 0x643E, + 0x9CC9, 0x8D40, 0xBFDB, 0xAE52, 0xDAED, 0xCB64, 0xF9FF, 0xE876, + 0x2102, 0x308B, 0x0210, 0x1399, 0x6726, 0x76AF, 0x4434, 0x55BD, + 0xAD4A, 0xBCC3, 0x8E58, 0x9FD1, 0xEB6E, 0xFAE7, 0xC87C, 0xD9F5, + 0x3183, 0x200A, 0x1291, 0x0318, 0x77A7, 0x662E, 0x54B5, 0x453C, + 0xBDCB, 0xAC42, 0x9ED9, 0x8F50, 0xFBEF, 0xEA66, 0xD8FD, 0xC974, + 0x4204, 0x538D, 0x6116, 0x709F, 0x0420, 0x15A9, 0x2732, 0x36BB, + 0xCE4C, 0xDFC5, 0xED5E, 0xFCD7, 0x8868, 0x99E1, 0xAB7A, 0xBAF3, + 0x5285, 0x430C, 0x7197, 0x601E, 0x14A1, 0x0528, 0x37B3, 0x263A, + 0xDECD, 0xCF44, 0xFDDF, 0xEC56, 0x98E9, 0x8960, 0xBBFB, 0xAA72, + 0x6306, 0x728F, 0x4014, 0x519D, 0x2522, 0x34AB, 0x0630, 0x17B9, + 0xEF4E, 0xFEC7, 0xCC5C, 0xDDD5, 0xA96A, 0xB8E3, 0x8A78, 0x9BF1, + 0x7387, 0x620E, 0x5095, 0x411C, 0x35A3, 0x242A, 0x16B1, 0x0738, + 0xFFCF, 0xEE46, 0xDCDD, 0xCD54, 0xB9EB, 0xA862, 0x9AF9, 0x8B70, + 0x8408, 0x9581, 0xA71A, 0xB693, 0xC22C, 0xD3A5, 0xE13E, 0xF0B7, + 0x0840, 0x19C9, 0x2B52, 0x3ADB, 0x4E64, 0x5FED, 0x6D76, 0x7CFF, + 0x9489, 0x8500, 0xB79B, 0xA612, 0xD2AD, 0xC324, 0xF1BF, 0xE036, + 0x18C1, 0x0948, 0x3BD3, 0x2A5A, 0x5EE5, 0x4F6C, 0x7DF7, 0x6C7E, + 0xA50A, 0xB483, 0x8618, 0x9791, 0xE32E, 0xF2A7, 0xC03C, 0xD1B5, + 0x2942, 0x38CB, 0x0A50, 0x1BD9, 0x6F66, 0x7EEF, 0x4C74, 0x5DFD, + 0xB58B, 0xA402, 0x9699, 0x8710, 0xF3AF, 0xE226, 0xD0BD, 0xC134, + 0x39C3, 0x284A, 0x1AD1, 0x0B58, 0x7FE7, 0x6E6E, 0x5CF5, 0x4D7C, + 0xC60C, 0xD785, 0xE51E, 0xF497, 0x8028, 0x91A1, 0xA33A, 0xB2B3, + 0x4A44, 0x5BCD, 0x6956, 0x78DF, 0x0C60, 0x1DE9, 0x2F72, 0x3EFB, + 0xD68D, 0xC704, 0xF59F, 0xE416, 0x90A9, 0x8120, 0xB3BB, 0xA232, + 0x5AC5, 0x4B4C, 0x79D7, 0x685E, 0x1CE1, 0x0D68, 0x3FF3, 0x2E7A, + 0xE70E, 0xF687, 0xC41C, 0xD595, 0xA12A, 0xB0A3, 0x8238, 0x93B1, + 0x6B46, 0x7ACF, 0x4854, 0x59DD, 0x2D62, 0x3CEB, 0x0E70, 0x1FF9, + 0xF78F, 0xE606, 0xD49D, 0xC514, 0xB1AB, 0xA022, 0x92B9, 0x8330, + 0x7BC7, 0x6A4E, 0x58D5, 0x495C, 0x3DE3, 0x2C6A, 0x1EF1, 0x0F78, +) + +_FCS_GOOD = 0xF0B8 # ax25ipd PPPGOODFCS — residual after valid frame + FCS + +AX25_UI_CONTROL = 0x03 +AX25_UI_PID = 0xF0 +MIN_UI_FRAME = 16 # dest(7) + src(7) + ctrl + pid + +_CALLSIGN_RE = re.compile(r"^([A-Z0-9]{1,6})(?:-([0-9]{1,2}))?$") + + +def parse_callsign(text: str) -> tuple[str, int]: + """Parse operator callsign text; invalid SSID clamps to 0 (legacy helper).""" + text = text.strip().upper() + if "-" in text: + call, ssid_s = text.split("-", 1) + try: + ssid = int(ssid_s) + except ValueError: + ssid = 0 + if ssid < 0 or ssid > 15: + ssid = 0 + return call[:6], ssid + return text[:6], 0 + + +def validate_callsign(text: str) -> tuple[str, int]: + """Strict callsign parse (libax25 ax25_aton_entry rules).""" + text = text.strip().upper() + match = _CALLSIGN_RE.match(text) + if not match: + raise ValueError(f"invalid AX.25 callsign: {text!r}") + call = match.group(1) + ssid = int(match.group(2)) if match.group(2) is not None else 0 + if ssid < 0 or ssid > 15: + raise ValueError(f"invalid AX.25 SSID in {text!r}") + return call, ssid + + +def format_callsign(call: str, ssid: int) -> str: + """Textual callsign; omit -0 suffix (libax25 ax25_ntoa convention).""" + call = call.strip().upper() + if ssid <= 0: + return call + if ssid >= 10: + return f"{call}-{ssid}" + return f"{call}-{ssid}" + + +def ax25_crc(data: bytes) -> int: + """Compute AX.25 FCS over body (addresses + control + PID + info).""" + fcs = 0xFFFF + for b in data: + fcs = (fcs >> 8) ^ _FCS_TABLE[(fcs ^ b) & 0xFF] + return fcs ^ 0xFFFF + + +def ax25_crc_valid(frame: bytes) -> bool: + """True when frame includes a valid little-endian FCS trailer.""" + fcs = 0xFFFF + for b in frame: + fcs = (fcs >> 8) ^ _FCS_TABLE[(fcs ^ b) & 0xFF] + return fcs == _FCS_GOOD + + +def ax25_encode_address(call: str, ssid: int, last: bool) -> bytes: + """Seven-byte AX.25 address field (libax25 ax25_aton_entry layout).""" + padded = call.upper().ljust(6)[:6] + raw = bytes((ord(c) << 1) for c in padded) + ssid_b = ((ssid & 0x0F) << 1) | (0x01 if last else 0x00) + return raw + bytes([ssid_b]) + + +def ax25_decode_address(raw: bytes) -> tuple[str, int, bool]: + call = "".join(chr((b >> 1) & 0x7F) for b in raw[:6]).strip() + ssid = (raw[6] >> 1) & 0x0F + last = bool(raw[6] & 0x01) + return call, ssid, last + + +def ax25_build_ui(src: str, dst: str, info: bytes) -> bytes: + src_call, src_ssid = validate_callsign(src) + dst_call, dst_ssid = validate_callsign(dst) + body = ( + ax25_encode_address(dst_call, dst_ssid, last=False) + + ax25_encode_address(src_call, src_ssid, last=True) + + bytes([AX25_UI_CONTROL, AX25_UI_PID]) + + info + ) + crc = ax25_crc(body) + return body + bytes((crc & 0xFF, crc >> 8)) + + +def ax25_parse_ui(frame: bytes) -> Optional[tuple[str, str, bytes]]: + """ + Parse a UI frame. Strips FCS only when the trailer validates (ax25ipd ok_crc). + KISS payloads are usually FCS-free; over-the-air captures may include FCS. + """ + if len(frame) < MIN_UI_FRAME: + return None + + body = frame + if len(frame) >= MIN_UI_FRAME + 2 and ax25_crc_valid(frame): + body = frame[:-2] + + pos = 0 + addresses: list[tuple[str, int, bool]] = [] + while pos + 7 <= len(body): + call, ssid, last = ax25_decode_address(body[pos : pos + 7]) + addresses.append((call, ssid, last)) + pos += 7 + if last: + break + else: + return None + + if len(addresses) < 2: + return None + if pos + 2 > len(body): + return None + if body[pos] != AX25_UI_CONTROL or body[pos + 1] != AX25_UI_PID: + return None + + payload = body[pos + 2 :] + dst_call, dst_ssid, _ = addresses[0] + src_call, src_ssid, _ = addresses[-1] + return ( + format_callsign(src_call, src_ssid), + format_callsign(dst_call, dst_ssid), + payload, + ) diff --git a/stacks/daemon/banlist.py b/stacks/daemon/banlist.py new file mode 100644 index 0000000..061ed1f --- /dev/null +++ b/stacks/daemon/banlist.py @@ -0,0 +1,89 @@ +"""Simple AX.25 source ban list for max25d — silent RX drop.""" +from __future__ import annotations + +import re +import sys +import threading +from pathlib import Path +from typing import Optional + +DEFAULT_BANS_FILE = Path("/etc/max25/bans.txt") + +_AX25_UI_SRC_RE = re.compile(r"\[AX25 UI ([^>]+)>") + + +def extract_ax25_source(line: str) -> Optional[str]: + """Return source callsign from a formatted AX.25 UI RX line, or None.""" + match = _AX25_UI_SRC_RE.search(line) + if not match: + return None + return match.group(1).strip().upper() + + +def callsign_banned(ban_entry: str, source: str) -> bool: + """Match ban entry against an incoming source callsign.""" + ban = ban_entry.strip().upper() + src = source.strip().upper() + if not ban or not src: + return False + if "-" in ban: + return ban == src + return ban == src.split("-", 1)[0] + + +class BanList: + """Persistent set of banned AX.25 source addresses.""" + + def __init__(self, path: Path | str = DEFAULT_BANS_FILE) -> None: + self.path = Path(path) + self._lock = threading.Lock() + self._entries: set[str] = set() + self.load() + + def load(self) -> None: + entries: set[str] = set() + try: + if self.path.is_file(): + text = self.path.read_text(encoding="utf-8") + for raw in text.splitlines(): + line = raw.split("#", 1)[0].strip().upper() + if line: + entries.add(line) + except OSError as exc: + print(f"max25d: banlist load failed ({self.path}): {exc}", file=sys.stderr) + with self._lock: + self._entries = entries + + def save(self) -> None: + lines = sorted(self._entries) + content = "\n".join(lines) + if content: + content += "\n" + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.write_text(content, encoding="utf-8") + + def list(self) -> list[str]: + with self._lock: + return sorted(self._entries) + + def add(self, callsign: str) -> None: + call = callsign.strip().upper() + with self._lock: + self._entries.add(call) + self.save() + + def remove(self, callsign: str) -> bool: + call = callsign.strip().upper() + with self._lock: + if call not in self._entries: + return False + self._entries.remove(call) + self.save() + return True + + def is_banned(self, source: str) -> bool: + src = source.strip().upper() + if not src: + return False + with self._lock: + return any(callsign_banned(entry, src) for entry in self._entries) diff --git a/stacks/daemon/daemon_log.py b/stacks/daemon/daemon_log.py new file mode 100644 index 0000000..f1696df --- /dev/null +++ b/stacks/daemon/daemon_log.py @@ -0,0 +1,237 @@ +""" +Structured stderr logging for max25d — human- and machine-readable lines. + +Format: + max25d [LEVEL] [area] message + max25d [LEVEL] [area] [device] message + +Levels: INFO, OK, WARN, ERROR, EVENT, RECOVERY +""" +from __future__ import annotations + +import sys +from dataclasses import dataclass +from typing import Callable, Optional + +PREFIX = "max25d" +DEVICE_TOKENS = ("tnc2c", "pktnc2", "tmodem", "max25e0", "max25e0:bc0", "max25e0:bc1", "baycom-kiss", "soft-crdop") + + +@dataclass(frozen=True) +class DeviceSummary: + device_id: str + backend: str + hardware: str + serial: str + enabled: bool + tested: bool + + +class DaemonLogger: + """Thread-safe enough for max25d (GIL); all lines go to stderr.""" + + def __init__(self, emit: Optional[Callable[[str], None]] = None) -> None: + self._emit = emit or self._default_emit + + @staticmethod + def _default_emit(line: str) -> None: + print(line, file=sys.stderr, flush=True) + + def _line( + self, + level: str, + msg: str, + *, + area: str = "", + device: str = "", + ) -> None: + parts = [PREFIX, f"[{level}]"] + if area: + parts.append(f"[{area}]") + if device: + parts.append(f"[{device}]") + parts.append(msg) + self._emit(" ".join(parts)) + + def info(self, msg: str, *, area: str = "", device: str = "") -> None: + self._line("INFO", msg, area=area, device=device) + + def ok(self, msg: str, *, area: str = "", device: str = "") -> None: + self._line("OK", msg, area=area, device=device) + + def warn(self, msg: str, *, area: str = "", device: str = "") -> None: + self._line("WARN", msg, area=area, device=device) + + def error(self, msg: str, *, area: str = "", device: str = "") -> None: + self._line("ERROR", msg, area=area, device=device) + + def event(self, msg: str, *, area: str = "", device: str = "") -> None: + self._line("EVENT", msg, area=area, device=device) + + def recovery(self, msg: str, *, device: str = "") -> None: + self._line("RECOVERY", msg, area="serial", device=device) + + def _parse_device_prefix(self, text: str) -> tuple[str, str]: + head, sep, tail = text.partition(":") + if sep and head in DEVICE_TOKENS: + return head, tail.strip() + device = "" + for token in DEVICE_TOKENS: + if f"({token})" in text or f" {token})" in text: + device = token + break + return device, text + + def emit_unstructured(self, msg: str) -> None: + """Map legacy free-form strings to structured levels.""" + device, text = self._parse_device_prefix(msg.strip()) + lower = text.lower() + if lower.startswith("warning:"): + self.warn(text[8:].strip(), device=device) + elif lower.startswith("recovery:"): + self.recovery(text[9:].strip(), device=device) + elif " prep ok" in lower or lower.startswith("ok:"): + self.ok(text, device=device) + elif any(x in lower for x in ("failed", "error", "fail:")): + self.error(text, device=device) + elif lower.startswith("serial watch:"): + self.info(text, area="watch", device=device) + elif lower.startswith("serial "): + self.info(text, area="serial", device=device) + elif lower.startswith("stack "): + self.info(text, area="stack", device=device) + elif lower.startswith("rx ") or lower.startswith("tx "): + self.event(text, device=device) + else: + self.info(text, device=device) + + def banner(self, title: str) -> None: + self._emit(f"{PREFIX} === {title} ===") + + def section(self, name: str) -> None: + self._emit(f"{PREFIX} [{name}]") + + def kv(self, key: str, value: str, *, indent: int = 0) -> None: + pad = " " * indent + self._emit(f"{PREFIX} {pad}{key}={value}") + + +LOGGER = DaemonLogger() + + +def device_serial_label(dev) -> str: + """Human-readable serial/KISS path for startup summary.""" + spec = (dev.device_spec or dev.serial_device or "").strip() + if dev.backend_type == "kiss-serial": + baud = dev.serial_baud or "?" + line = (dev.serial_line or "?").upper() + dtr = dev.serial_dtr_rts or "default" + kiss = dev.serial_kiss_entry or "default" + path = spec or dev.serial_device or "?" + return f"{path} {baud} {line} dtr={dtr} kiss_entry={kiss}" + if dev.backend_type == "baycom-kiss": + return f"baycom:{dev.baycom_modem or 'a'} kiss={dev.kiss_link or '?'}" + if dev.backend_type == "crdop-tcp": + host = dev.crdop_host or "127.0.0.1" + port = dev.crdop_port or "?" + return f"crdop-tcp {host}:{port}" + if spec: + return spec + return dev.backend_type or "auto" + + +def emit_startup_banner( + *, + config_path: Optional[str], + cfg, + devices: list, + tested_fn: Callable[[str], bool], +) -> None: + LOGGER.banner("MAX25d starting") + LOGGER.section("config") + if config_path: + LOGGER.kv("ini", config_path, indent=1) + else: + LOGGER.kv("ini", "(built-in defaults)", indent=1) + LOGGER.kv("mode", cfg.mode, indent=1) + LOGGER.kv("default_device", cfg.default_device or cfg.device, indent=1) + LOGGER.kv("devices", str(len(devices)), indent=1) + + LOGGER.section("network") + LOGGER.kv("tcp", f"{cfg.tcp_host}:{cfg.tcp_port}", indent=1) + LOGGER.kv("unix", cfg.unix_socket or "(disabled)", indent=1) + if cfg.tcp_password: + LOGGER.kv("tcp_auth", "enabled", indent=1) + else: + LOGGER.warn("TCP has no password — set tcp_password before exposing LAN", area="security") + + LOGGER.section("modem") + LOGGER.kv("callerid", cfg.callerid, indent=1) + LOGGER.kv("callid", cfg.callid, indent=1) + LOGGER.kv("ax25_ui", "yes" if cfg.ax25_ui else "no", indent=1) + if cfg.bans_file: + LOGGER.kv("bans_file", cfg.bans_file, indent=1) + + LOGGER.section("stack") + LOGGER.kv("auto_start", "yes" if cfg.auto_start else "no", indent=1) + LOGGER.kv("serial_enabled", "yes" if cfg.serial_enabled else "no", indent=1) + LOGGER.kv("stack_recover_only", "yes" if cfg.stack_recover_only else "no", indent=1) + LOGGER.kv("serial_watch", "yes" if cfg.serial_watch else "no", indent=1) + if cfg.serial_watch: + LOGGER.kv("serial_watch_interval", f"{cfg.serial_watch_interval}s", indent=1) + LOGGER.kv("serial_watch_startup_grace", f"{cfg.serial_watch_startup_grace}s", indent=1) + LOGGER.kv("serial_bootwait_escalate", "yes" if cfg.serial_bootwait_escalate else "no", indent=1) + + LOGGER.section("session") + LOGGER.kv( + "detach", + "max25d --session tmux | max25d-session start/attach/stop", + indent=1, + ) + + LOGGER.section("devices") + for dev in devices: + if not dev.enabled: + LOGGER.kv( + dev.device_id, + f"disabled backend={dev.backend_type or 'auto'}", + indent=1, + ) + continue + serial = device_serial_label(dev) + hw = dev.hardware or cfg.hardware + tested = tested_fn(dev.device_id) + note = "" if tested else " — not hardware-validated in CI" + LOGGER.kv( + dev.device_id, + f"{hw} {serial}{note}", + indent=1, + ) + if not tested and dev.backend_type == "kiss-serial": + LOGGER.warn( + "RF path not CI-validated — verify on hardware before production", + area="devices", + device=dev.device_id, + ) + + +def emit_startup_complete( + *, + device_lines: list[tuple[str, str, str]], + tcp_host: str, + tcp_port: int, + unix_socket: str, +) -> None: + LOGGER.banner("MAX25d ready") + LOGGER.section("listen") + LOGGER.kv("tcp", f"{tcp_host}:{tcp_port}", indent=1) + LOGGER.kv("unix", unix_socket or "(disabled)", indent=1) + if device_lines: + LOGGER.section("device_status") + for dev_id, stack_st, link_st in device_lines: + LOGGER.kv(dev_id, f"stack={stack_st} link={link_st}", indent=1) + LOGGER.info( + "attach: max25-terminal -U /run/max25/modem.sock | " + "detach session: max25d-session attach", + area="operator", + ) diff --git a/stacks/daemon/device_backends.py b/stacks/daemon/device_backends.py new file mode 100644 index 0000000..b4c6c29 --- /dev/null +++ b/stacks/daemon/device_backends.py @@ -0,0 +1,1076 @@ +""" +Device backends for max25d — heterogeneous RF paths (TNC, BayCom, CRDOP). + +Each enabled [devices] id gets one backend instance. Backends without hardware +validation log a startup warning but still wire real stack paths (not silent no-ops). +""" +from __future__ import annotations + +import os +import select +import socket +import struct +import termios +import threading +import time +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Callable, Optional + +from kiss_bridge import ( + MAX_PAYLOAD, + KissDecoder, + SerialProfile, + ax25_build_ui, + ax25_parse_ui, + format_rx_line, + kiss_data_frame, + serial_profile_for_device, +) +from kiss_bridge import KissBridge # noqa: E402 — re-exported wrapper target +from paths import normalize_max25_bcpr_path + +LogFn = Callable[[str], None] +RxFn = Callable[[str], None] +InvalidFn = Callable[[], None] + + +def _spec_int(raw: str, default: int) -> int: + try: + return int(str(raw).strip()) + except (TypeError, ValueError): + return default + +# max25e0 host address defaults (overridable in max25d.ini [device.max25e0]) +MAX25E0_DEFAULT_IPV4 = "127.0.0.25/8" +MAX25E0_DEFAULT_IPV6 = "::25/128" + +# manifest.yaml device ids → default hardware + backend kind +DEVICE_REGISTRY: dict[str, dict[str, str | bool]] = { + "tnc2c": {"hardware": "tncs", "backend": "kiss-serial", "tested": True}, + "pktnc2": {"hardware": "tncs", "backend": "kiss-serial", "tested": False}, + "tmodem": {"hardware": "tncs", "backend": "kiss-raw-serial", "tested": False}, + # Kernel baycom-ser12/par96 removed 2026-07-18 — use max25-bcpr → device max25e0 + "baycom-kiss": {"hardware": "modems", "backend": "kiss-raw-serial", "tested": False}, + "pccom-kiss": {"hardware": "modems", "backend": "kiss-raw-serial", "tested": False}, + "max25e0": {"hardware": "modems", "backend": "max25-bcpr-kiss", "tested": True}, + "max25e0:bc0": {"hardware": "modems", "backend": "max25-bcpr-kiss", "tested": True}, + "max25e0:bc1": {"hardware": "modems", "backend": "max25-bcpr-kiss", "tested": True}, + "soft-crdop": {"hardware": "soft-modems", "backend": "crdop-tcp", "tested": True}, + "audio-dummy": {"hardware": "acoustic-bench", "backend": "audio-dummy", "tested": True}, +} + + +def registry_hardware(device_id: str, fallback: str = "tncs") -> str: + entry = DEVICE_REGISTRY.get(device_id, {}) + return str(entry.get("hardware", fallback)) + + +def registry_backend(device_id: str) -> str: + entry = DEVICE_REGISTRY.get(device_id, {}) + return str(entry.get("backend", "kiss-serial")) + + +def registry_tested(device_id: str) -> bool: + entry = DEVICE_REGISTRY.get(device_id, {}) + return bool(entry.get("tested", False)) + + +def baycom_ctl_device_id(dev_cfg: DeviceBackendConfig) -> str: + """Legacy helper: kernel BayCom ctl device id (stack removed — prefer max25-bcpr).""" + entry = DEVICE_REGISTRY.get(dev_cfg.device_id, {}) + if entry.get("backend") == "baycom-kiss": + return dev_cfg.device_id + return "max25e0" + + +@dataclass +class DeviceBackendConfig: + device_id: str + hardware: str = "" + backend_type: str = "" + device_spec: str = "" + enabled: bool = True + # Serial (TNC / baycom-kiss USB) + serial_device: str = "" + serial_baud: int = 0 + serial_line: str = "" + serial_dtr_rts: str = "" + serial_kiss_entry: str = "" + # BayCom kernel KISS PTY + kiss_link: str = "" + baycom_modem: str = "a" + baycom_ini: str = "" + # max25-bcpr userspace SER12 — max25e0 (+ forks max25e0:bcN) + max25_bcpr_ini: str = "" + max25_bcpr_device: str = "" # bc0 | bc1 + # Host addresses (max25e0 family only; forks inherit from max25e0) + ipv4: str = "" + ipv6: str = "" + # Legacy field aliases (read-only mirrors filled by parser) + bcpr_ini: str = "" + bcpr_device: str = "" + # CRDOP TCP + crdop_host: str = "127.0.0.1" + crdop_port: int = 8515 + crdop_profile: str = "default" + crdop_listen: bool = True + # Acoustic bench / audio-dummy + audio_mode: str = "loopback" # loopback | alsa | host + audio_capture: str = "" + audio_playback: str = "" + audio_sample_rate: int = 48000 + audio_host_port: int = 8520 + + +class DeviceBackend(ABC): + """Common RX/TX/PTT surface for max25d.""" + + device_id: str + status: str = "closed" + backend_type: str = "" + + @abstractmethod + def open(self) -> bool: + ... + + @abstractmethod + def close(self) -> None: + ... + + @abstractmethod + def attach_session(self, mycall: str) -> bool: + ... + + @abstractmethod + def detach_session(self) -> None: + ... + + @abstractmethod + def transmit(self, src: str, dst: str, text: str, ax25_ui: bool) -> tuple[bool, str]: + ... + + +class KissSerialBackend(DeviceBackend): + """TNC2C / PK-TNC2 — command-mode serial entry into KISS.""" + + backend_type = "kiss-serial" + + def __init__( + self, + cfg: DeviceBackendConfig, + root: str, + on_rx: RxFn, + log: Optional[LogFn] = None, + prefix: Optional[str] = None, + on_invalid: Optional[InvalidFn] = None, + ) -> None: + self.device_id = cfg.device_id + self._cfg = cfg + self._root = root + self._prefix = prefix + self._on_rx = on_rx + self._on_invalid = on_invalid + self._log = log or (lambda _m: None) + self._bridge: Optional[KissBridge] = None + self.status = "closed" + + def _bridge_log(self, msg: str) -> None: + self._log(f"{self.device_id}: {msg}") + + def _ini_overrides(self) -> dict[str, str]: + out: dict[str, str] = {} + if self._cfg.serial_device: + out["device"] = self._cfg.serial_device + if self._cfg.serial_baud: + out["baud"] = str(self._cfg.serial_baud) + if self._cfg.serial_line: + out["line"] = self._cfg.serial_line + if self._cfg.serial_dtr_rts: + out["dtr_rts"] = self._cfg.serial_dtr_rts + if self._cfg.serial_kiss_entry: + out["kiss_entry"] = self._cfg.serial_kiss_entry + return out + + def open(self) -> bool: + profile = serial_profile_for_device( + self.device_id, + self._root, + self._ini_overrides(), + prefix=self._prefix, + ) + bridge = KissBridge( + profile, + self._on_rx, + self._bridge_log, + tree_root=self._root, + install_prefix=self._prefix, + on_invalid=self._on_invalid, + ) + if not bridge.open(): + self._bridge = bridge + self.status = bridge.status + return False + self._bridge = bridge + self.status = bridge.status + return True + + def close(self) -> None: + if self._bridge is not None: + self._bridge.close() + self.status = self._bridge.status + self._bridge = None + else: + self.status = "closed" + + def stabilize_session(self, mycall: str, *, force: bool = False) -> bool: + if self._bridge is None: + return False + ok = self._bridge.stabilize_session(mycall, force=force) + self.status = self._bridge.status + return ok + + def attach_session(self, mycall: str) -> bool: + if self._bridge is None: + return False + ok = self._bridge.attach_session(mycall) + self.status = self._bridge.status + return ok + + def detach_session(self) -> None: + if self._bridge is None: + return + self._bridge.detach_session() + self.status = self._bridge.status + + def transmit(self, src: str, dst: str, text: str, ax25_ui: bool) -> tuple[bool, str]: + if self._bridge is None: + return False, "serial not ready" + ok, display = self._bridge.transmit(src, dst, text, ax25_ui) + self.status = self._bridge.status + return ok, display + + +class KissRawBackend(DeviceBackend): + """Raw KISS on serial or BayCom KISS PTY (no command-mode entry).""" + + backend_type = "kiss-raw" + + def __init__( + self, + cfg: DeviceBackendConfig, + path: str, + profile: SerialProfile, + on_rx: RxFn, + log: Optional[LogFn] = None, + *, + is_pty: bool = False, + ) -> None: + self.device_id = cfg.device_id + self._path = path + self._profile = profile + self._on_rx = on_rx + self._log = log or (lambda _m: None) + self._is_pty = is_pty + self._fd: Optional[int] = None + self._thread: Optional[threading.Thread] = None + self._stop = threading.Event() + self._lock = threading.Lock() + self._mycall = "" + self._kiss_active = False + self._decoder = KissDecoder() + self.status = "closed" + + def open(self) -> bool: + path = self._path + if not path: + self.status = "error-no-path" + self._log(f"{self.device_id}: no KISS path configured") + return False + if not os.path.exists(path): + self.status = "error-no-device" + self._log(f"{self.device_id}: path missing: {path}") + return False + try: + fd = os.open(path, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK) + if not self._is_pty: + self._configure_serial(fd) + termios.tcflush(fd, termios.TCIOFLUSH) + except OSError as exc: + self.status = "error-open" + self._log(f"{self.device_id}: open failed: {exc}") + return False + self._fd = fd + self.status = "open" + self._stop.clear() + self._thread = threading.Thread( + target=self._rx_loop, + name=f"kiss-raw-{self.device_id}", + daemon=True, + ) + self._thread.start() + self._log(f"{self.device_id}: raw KISS open {path}") + return True + + def _configure_serial(self, fd: int) -> None: + from kiss_bridge import _parse_baud, _parse_line + + speed = _parse_baud(self._profile.baud) + databits, parity = _parse_line(self._profile.line) + t = termios.tcgetattr(fd) + t[0] = t[1] = 0 + t[2] = termios.CLOCAL | termios.CREAD | databits | parity + t[3] = t[4] = t[5] = speed + t[6][termios.VMIN] = 0 + t[6][termios.VTIME] = 5 + termios.tcsetattr(fd, termios.TCSANOW, t) + flags = struct.unpack("I", __import__("fcntl").ioctl(fd, 0x5415, struct.pack("I", 0)))[0] + if self._profile.dtr_rts: + flags |= 0x004 | 0x002 + __import__("fcntl").ioctl(fd, 0x5416, struct.pack("I", flags)) + + def close(self) -> None: + self._stop.set() + if self._thread is not None: + self._thread.join(timeout=2.0) + self._thread = None + with self._lock: + if self._fd is not None: + try: + os.close(self._fd) + except OSError: + pass + self._fd = None + self._kiss_active = False + self.status = "closed" + self._decoder = KissDecoder() + + def attach_session(self, mycall: str) -> bool: + if self._fd is None: + return False + self._mycall = mycall.upper() + self._kiss_active = True + self.status = "ready" + return True + + def detach_session(self) -> None: + self._kiss_active = False + if self._fd is not None: + self.status = "open" + + def stabilize_session(self, mycall: str, *, force: bool = False) -> bool: + """Reopen KISS path after dead PTY / EIO (e.g. bcprd recycled outside max25d).""" + path = self._path + if ( + not force + and self._kiss_active + and self.status == "ready" + and self._fd is not None + and path + and os.path.exists(path) + ): + if not self._is_pty: + return True + # PTY symlink may have been retargeted while our fd still points at a + # deleted slave — force reopen when the live path inode differs. + try: + cur = os.stat(path) + fd_st = os.fstat(self._fd) + if cur.st_ino == fd_st.st_ino and cur.st_dev == fd_st.st_dev: + return True + except OSError: + pass + was_active = self._kiss_active or force + call = (mycall or self._mycall or "").upper() + self.close() + if not self.open(): + return False + if was_active and call: + return self.attach_session(call) + return self._fd is not None + + def transmit(self, src: str, dst: str, text: str, ax25_ui: bool) -> tuple[bool, str]: + if self._fd is None or not self._kiss_active: + return False, "KISS not ready" + if len(text.encode("utf-8")) > MAX_PAYLOAD: + return False, "payload too long" + info = text.encode("utf-8") + frame = ax25_build_ui(src, dst, info) + pkt = kiss_data_frame(0, frame) + with self._lock: + try: + os.write(self._fd, pkt) + # PTY: never tcdrain — if the master side dies, drain can hang forever. + if not self._is_pty: + termios.tcdrain(self._fd) + except OSError as exc: + self.status = "error-tx" + return False, f"tx failed: {exc}" + display = format_rx_line(src, dst, info, ax25_ui) + return True, display + + def _rx_loop(self) -> None: + while not self._stop.is_set(): + fd = self._fd + if fd is None: + break + try: + chunk = os.read(fd, 4096) + except BlockingIOError: + time.sleep(0.05) + continue + except OSError: + break + if not chunk: + time.sleep(0.05) + continue + for _port, payload in self._decoder.feed(chunk): + if not payload: + continue + parsed = ax25_parse_ui(payload) + if parsed is None: + continue + src, dst, info = parsed + line = format_rx_line(src, dst, info, ax25_ui=True) + self._on_rx(line) + + +class BayComKissBackend(KissRawBackend): + """BayCom kernel modem (SER12 / PAR96) via baycom-pr KISS PTY.""" + + backend_type = "baycom-kiss" + + def __init__( + self, + cfg: DeviceBackendConfig, + on_rx: RxFn, + log: Optional[LogFn] = None, + ) -> None: + modem = cfg.baycom_modem or "a" + kiss = cfg.kiss_link or f"/var/run/baycom-pr/kiss-{modem}" + if modem == "a" and not cfg.kiss_link: + default = "/var/run/baycom-pr/kiss" + if os.path.exists(default) or not os.path.exists(kiss): + kiss = default + profile = SerialProfile(baud=9600, line="8n1", dtr_rts=False) + super().__init__(cfg, kiss, profile, on_rx, log, is_pty=True) + + +class Max25BcprKissBackend(KissRawBackend): + """max25-bcpr userspace SER12 via KISS PTY (max25e0 / max25e0:bcN). + + Hardware is a TCM3105-class AFSK modem chip (bits↔AFSK + PTT) only — not a TNC. + """ + + backend_type = "max25-bcpr-kiss" + + def __init__( + self, + cfg: DeviceBackendConfig, + on_rx: RxFn, + log: Optional[LogFn] = None, + ) -> None: + tag = (cfg.max25_bcpr_device or cfg.bcpr_device or "bc0").strip() or "bc0" + kiss = cfg.kiss_link or f"/tmp/max25-bcpr/kiss-{tag}" + kiss = normalize_max25_bcpr_path(kiss) + profile = SerialProfile(baud=9600, line="8n1", dtr_rts=False) + super().__init__(cfg, kiss, profile, on_rx, log, is_pty=True) + + +class KissRawSerialBackend(KissRawBackend): + """USB/async BayCom KISS serial (kiss-serial backend).""" + + backend_type = "kiss-raw-serial" + + def __init__( + self, + cfg: DeviceBackendConfig, + root: str, + on_rx: RxFn, + log: Optional[LogFn] = None, + prefix: Optional[str] = None, + ) -> None: + overrides: dict[str, str] = {} + if cfg.serial_device: + overrides["device"] = cfg.serial_device + if cfg.serial_baud: + overrides["baud"] = str(cfg.serial_baud) + if cfg.serial_line: + overrides["line"] = cfg.serial_line + if cfg.serial_dtr_rts: + overrides["dtr_rts"] = cfg.serial_dtr_rts + prof = serial_profile_for_device(cfg.device_id, root, overrides, prefix=prefix) + path = cfg.serial_device or prof.device + super().__init__(cfg, path, prof, on_rx, log, is_pty=False) + + +class CrdopTcpBackend(DeviceBackend): + """MAX25-SoftModem (CRDOP) via TCP host interface (:8515 / :8516). + + Native M25/KISS host protocol (MAX25-SoftModem) only. + """ + + backend_type = "crdop-tcp" + + def __init__( + self, + cfg: DeviceBackendConfig, + on_rx: RxFn, + log: Optional[LogFn] = None, + ) -> None: + self.device_id = cfg.device_id + self._cfg = cfg + self._on_rx = on_rx + self._log = log or (lambda _m: None) + self._ctrl: Optional[socket.socket] = None + self._data: Optional[socket.socket] = None + self._thread: Optional[threading.Thread] = None + self._stop = threading.Event() + self._lock = threading.RLock() + self._mycall = "" + self._connected = False + self.status = "closed" + + def _line_term(self) -> str: + return "\n" + + def open(self) -> bool: + host = self._cfg.crdop_host + port = self._cfg.crdop_port + ctrl = None + data = None + try: + ctrl = socket.create_connection((host, port), timeout=5.0) + ctrl.settimeout(0.5) + data = socket.create_connection((host, port + 1), timeout=5.0) + data.settimeout(0.5) + except OSError as exc: + if ctrl is not None: + try: + ctrl.close() + except OSError: + pass + self.status = "error-connect" + self._log(f"{self.device_id}: CRDOP TCP connect failed ({host}:{port}): {exc}") + return False + self._ctrl = ctrl + self._data = data + self.status = "open" + self._stop.clear() + self._thread = threading.Thread( + target=self._rx_loop, + name=f"crdop-rx-{self.device_id}", + daemon=True, + ) + self._thread.start() + self._log(f"{self.device_id}: CRDOP TCP open {host}:{port}") + return True + + def close(self) -> None: + self._stop.set() + if self._thread is not None: + self._thread.join(timeout=2.0) + self._thread = None + for sock in (self._ctrl, self._data): + if sock is not None: + try: + sock.close() + except OSError: + pass + self._ctrl = None + self._data = None + self._connected = False + self.status = "closed" + + def _cmd(self, text: str) -> str: + if self._ctrl is None: + return "" + term = self._line_term() + payload = (text.rstrip(term) + term).encode("ascii", errors="replace") + with self._lock: + self._ctrl.sendall(payload) + return self._read_line_unlocked() + + def _read_line_unlocked(self) -> str: + if self._ctrl is None: + return "" + term = self._line_term() + term_b = term.encode("ascii") + buf = b"" + deadline = time.time() + 3.0 + while time.time() < deadline: + try: + chunk = self._ctrl.recv(4096) + except socket.timeout: + continue + except OSError: + break + if not chunk: + break + buf += chunk + while term_b in buf: + raw, buf = buf.split(term_b, 1) + line = raw.decode("ascii", errors="replace").strip() + if line: + return line + return "" + + def attach_session(self, mycall: str) -> bool: + if self._ctrl is None: + return False + self._mycall = mycall.upper() + cmds = [ + "INITIALIZE", + "PROTOCOLMODE KISS", + f"MYCALL {self._mycall}", + ] + if self._cfg.crdop_listen: + cmds.append("LISTEN TRUE") + for cmd in cmds: + reply = self._cmd(cmd) + self._log(f"{self.device_id}: {cmd} → {reply or '(no reply)'}") + self._connected = True + self.status = "ready" + return True + + def detach_session(self) -> None: + if self._ctrl is not None and self._connected: + self._cmd("ABORT") + self._connected = False + if self._ctrl is not None: + self.status = "open" + + def transmit(self, src: str, dst: str, text: str, ax25_ui: bool) -> tuple[bool, str]: + if self._ctrl is None or self._data is None or not self._connected: + return False, "CRDOP not ready" + payload = text.encode("utf-8") + if len(payload) > MAX_PAYLOAD: + return False, "payload too long" + with self._lock: + try: + body = ax25_build_ui(src, dst, payload) + if len(body) >= 2: + body = body[:-2] + self._data.sendall(body) + except OSError as exc: + self.status = "error-tx" + return False, f"tx failed: {exc}" + if ax25_ui: + display = f"[CRDOP AX25 UI {src}>{dst}] {text}" + else: + display = text + return True, display + + def _rx_loop(self) -> None: + while not self._stop.is_set(): + ctrl = self._ctrl + if ctrl is None: + break + try: + ready, _, _ = select.select([ctrl], [], [], 0.5) + if not ready: + continue + chunk = ctrl.recv(4096) + except (OSError, socket.timeout): + continue + if not chunk: + time.sleep(0.05) + continue + term = self._line_term() + for line in chunk.decode("ascii", errors="replace").split(term): + line = line.strip() + if not line: + continue + if line.startswith("STATUS"): + self._on_rx(f"[CRDOP RX {self.device_id}] {line}") + + +class AudioDummyBackend(DeviceBackend): + """Acoustic bench dummy — loopback, ALSA sniff, or M25 host TCP.""" + + backend_type = "audio-dummy" + + def __init__( + self, + cfg: DeviceBackendConfig, + on_rx: RxFn, + log: Optional[LogFn] = None, + ) -> None: + self.device_id = cfg.device_id + self._cfg = cfg + self._on_rx = on_rx + self._log = log or (lambda _m: None) + self._ctrl: Optional[socket.socket] = None + self._data: Optional[socket.socket] = None + self._thread: Optional[threading.Thread] = None + self._stop = threading.Event() + self._lock = threading.RLock() + self._mycall = "" + self._connected = False + self._engine = None + self.status = "closed" + + def _import_engine(self): + import sys + from pathlib import Path + + lib = Path(__file__).resolve().parents[1] / "crdop" / "lib" + if str(lib) not in sys.path: + sys.path.insert(0, str(lib)) + from acoustic_engine import AcousticEngine # noqa: WPS433 + from sound_proxy import SoundConfig # noqa: WPS433 + + sound = SoundConfig( + capture=self._cfg.audio_capture or "default", + playback=self._cfg.audio_playback or self._cfg.audio_capture or "default", + sample_rate=self._cfg.audio_sample_rate, + ) + return AcousticEngine(sample_rate=self._cfg.audio_sample_rate, sound=sound) + + def open(self) -> bool: + mode = (self._cfg.audio_mode or "loopback").lower() + if mode == "host": + host = "127.0.0.1" + port = self._cfg.audio_host_port + ctrl = None + data = None + try: + ctrl = socket.create_connection((host, port), timeout=3.0) + ctrl.settimeout(0.5) + data = socket.create_connection((host, port + 1), timeout=3.0) + data.settimeout(0.5) + except OSError as exc: + if ctrl is not None: + try: + ctrl.close() + except OSError: + pass + self.status = "error-connect" + self._log(f"{self.device_id}: audio-dummy host connect failed: {exc}") + return False + self._ctrl = ctrl + self._data = data + self._stop.clear() + self._thread = threading.Thread( + target=self._host_rx_loop, + name=f"audio-dummy-{self.device_id}", + daemon=True, + ) + self._thread.start() + self.status = "open" + self._log(f"{self.device_id}: audio-dummy host {host}:{port}") + return True + + try: + self._engine = self._import_engine() + except Exception as exc: + self.status = "error-engine" + self._log(f"{self.device_id}: audio engine load failed: {exc}") + return False + + if mode == "alsa" and self._cfg.audio_capture: + self._stop.clear() + self._thread = threading.Thread( + target=self._alsa_sniff_loop, + name=f"audio-sniff-{self.device_id}", + daemon=True, + ) + self._thread.start() + self.status = "open" + self._log(f"{self.device_id}: audio-dummy mode={mode}") + return True + + def close(self) -> None: + self._stop.set() + if self._thread is not None: + self._thread.join(timeout=2.0) + self._thread = None + for sock in (self._ctrl, self._data): + if sock is not None: + try: + sock.close() + except OSError: + pass + self._ctrl = None + self._data = None + self._connected = False + self.status = "closed" + + def _host_cmd(self, text: str) -> str: + if self._ctrl is None: + return "" + payload = (text.rstrip("\n") + "\n").encode("ascii", errors="replace") + with self._lock: + self._ctrl.sendall(payload) + buf = b"" + deadline = time.time() + 2.0 + while time.time() < deadline: + try: + chunk = self._ctrl.recv(4096) + except socket.timeout: + continue + if not chunk: + break + buf += chunk + if b"\n" in buf: + line, _ = buf.split(b"\n", 1) + return line.decode("ascii", errors="replace").strip() + return "" + + def attach_session(self, mycall: str) -> bool: + self._mycall = mycall.upper() + if self._ctrl is not None: + for cmd in ( + "INITIALIZE", + "PROTOCOLMODE KISS", + f"MYCALL {self._mycall}", + "LISTEN TRUE", + ): + reply = self._host_cmd(cmd) + self._log(f"{self.device_id}: {cmd} → {reply or '(no reply)'}") + self._connected = True + self.status = "ready" + return True + + def detach_session(self) -> None: + self._connected = False + if self._ctrl is not None: + self.status = "open" + else: + self.status = "closed" + + def transmit(self, src: str, dst: str, text: str, ax25_ui: bool) -> tuple[bool, str]: + if not self._connected: + return False, "audio-dummy not ready" + payload = text.encode("utf-8") + if len(payload) > MAX_PAYLOAD: + return False, "payload too long" + + if self._engine is not None: + pcm = self._engine.encode_ax25_ui(src, dst, text) + rep = self._engine.analyze_pcm(pcm) + for line in rep.decode_lines: + self._on_rx(f"[AUDIO RX {self.device_id}] {line}") + display = f"[AX25 UI {src}>{dst}] {text}" if ax25_ui else text + return True, display + + if self._data is None: + return False, "no data channel" + import sys + from pathlib import Path + + lib = Path(__file__).resolve().parents[1] / "crdop" / "lib" + if str(lib) not in sys.path: + sys.path.insert(0, str(lib)) + from ax25_codec import ax25_build_ui # noqa: WPS433 + + body = ax25_build_ui(src, dst, payload) + if len(body) >= 2: + body = body[:-2] + try: + with self._lock: + self._data.sendall(body) + except OSError as exc: + return False, f"tx failed: {exc}" + display = f"[AX25 UI {src}>{dst}] {text}" if ax25_ui else text + return True, display + + def _alsa_sniff_loop(self) -> None: + import sys + from pathlib import Path + + lib = Path(__file__).resolve().parents[1] / "crdop" / "lib" + if str(lib) not in sys.path: + sys.path.insert(0, str(lib)) + from sound_proxy import SoundProxy # noqa: WPS433 + + if self._engine is None: + return + proxy = SoundProxy(self._engine.sound) + + def on_pcm(chunk: bytes) -> None: + rep = self._engine.analyze_pcm(chunk) + for line in rep.decode_lines: + self._on_rx(f"[SNIFF {self.device_id}] {line}") + + try: + proxy.sniff_loop(chunk_symbols=40, on_pcm=on_pcm, stop=self._stop) + except Exception as exc: + self._log(f"{self.device_id}: sniff error: {exc}") + + def _host_rx_loop(self) -> None: + while not self._stop.is_set(): + if self._ctrl is None: + break + try: + ready, _, _ = select.select([self._ctrl], [], [], 0.5) + if not ready: + continue + chunk = self._ctrl.recv(4096) + except (OSError, socket.timeout): + continue + if not chunk: + time.sleep(0.05) + continue + for line in chunk.decode("ascii", errors="replace").split("\n"): + line = line.strip() + if line.startswith("STATUS"): + self._on_rx(f"[AUDIO RX {self.device_id}] {line}") + + +def parse_device_spec(device_id: str, spec: str, cp, cfg_defaults: dict) -> DeviceBackendConfig: + """Build backend config from [devices] value and optional [device.<id>].""" + dev = DeviceBackendConfig(device_id=device_id) + dev.hardware = registry_hardware(device_id, cfg_defaults.get("hardware", "tncs")) + dev.backend_type = registry_backend(device_id) + + section = f"device.{device_id}" + sec_opts: dict[str, str] = {} + if cp.has_section(section): + sec_opts = {k: cp.get(section, k) for k in cp.options(section)} + + if sec_opts.get("hardware"): + dev.hardware = sec_opts["hardware"] + if sec_opts.get("backend"): + dev.backend_type = sec_opts["backend"] + + spec = (spec or "").strip() + dev.device_spec = spec + + if spec.startswith("baycom:"): + dev.backend_type = "baycom-kiss" + dev.baycom_modem = spec.split(":", 1)[1].strip() or "a" + dev.hardware = "modems" + elif spec.startswith("max25-bcpr:") or spec.startswith("bcpr:"): + # Userspace SER12 — product face max25-bcpr; device id remains max25e0 + dev.backend_type = "max25-bcpr-kiss" + tag = spec.split(":", 1)[1].strip() or "bc0" + dev.max25_bcpr_device = tag + dev.bcpr_device = tag # legacy alias + dev.hardware = "modems" + if not sec_opts.get("kiss_link"): + dev.kiss_link = f"/tmp/max25-bcpr/kiss-{tag}" + elif spec.startswith("crdop:"): + dev.backend_type = "crdop-tcp" + dev.crdop_profile = spec.split(":", 1)[1].strip() or "default" + dev.hardware = "soft-modems" + elif spec.startswith("audio:"): + dev.backend_type = "audio-dummy" + dev.audio_mode = spec.split(":", 1)[1].strip() or "loopback" + dev.hardware = "acoustic-bench" + elif spec.startswith("/") or spec.startswith("dev:"): + if dev.backend_type in ("baycom-kiss",): + dev.kiss_link = spec + else: + dev.serial_device = spec.removeprefix("dev:") + if dev.backend_type == "crdop-tcp": + dev.backend_type = "kiss-serial" + elif spec: + dev.serial_device = spec + + if sec_opts.get("kiss_link"): + dev.kiss_link = sec_opts["kiss_link"] + if dev.backend_type in ("max25-bcpr-kiss", "bcpr-kiss") and dev.kiss_link: + dev.kiss_link = normalize_max25_bcpr_path(dev.kiss_link) + if sec_opts.get("modem"): + dev.baycom_modem = sec_opts["modem"] + if sec_opts.get("baycom_ini"): + dev.baycom_ini = sec_opts["baycom_ini"] + if sec_opts.get("max25_bcpr_ini") or sec_opts.get("bcpr_ini"): + ini_path = sec_opts.get("max25_bcpr_ini") or sec_opts.get("bcpr_ini") or "" + dev.max25_bcpr_ini = ini_path + dev.bcpr_ini = ini_path + if sec_opts.get("max25_bcpr_device") or sec_opts.get("bcpr_device"): + tag = sec_opts.get("max25_bcpr_device") or sec_opts.get("bcpr_device") or "" + dev.max25_bcpr_device = tag + dev.bcpr_device = tag + if sec_opts.get("ipv4"): + dev.ipv4 = sec_opts["ipv4"].strip() + if sec_opts.get("ipv6"): + dev.ipv6 = sec_opts["ipv6"].strip() + if sec_opts.get("host"): + dev.crdop_host = sec_opts["host"] + if sec_opts.get("port"): + dev.crdop_port = _spec_int(sec_opts["port"], dev.crdop_port) + if sec_opts.get("listen"): + dev.crdop_listen = sec_opts["listen"].lower() in ("1", "yes", "true", "on") + if sec_opts.get("mode"): + dev.audio_mode = sec_opts["mode"] + if sec_opts.get("capture"): + dev.audio_capture = sec_opts["capture"] + if sec_opts.get("playback"): + dev.audio_playback = sec_opts["playback"] + if sec_opts.get("sample_rate"): + dev.audio_sample_rate = _spec_int(sec_opts["sample_rate"], dev.audio_sample_rate) + if sec_opts.get("host_port"): + dev.audio_host_port = _spec_int(sec_opts["host_port"], dev.audio_host_port) + + serial_sec = f"serial.{device_id}" + if cp.has_section(serial_sec): + if cp.has_option(serial_sec, "device"): + dev.serial_device = cp.get(serial_sec, "device") + if cp.has_option(serial_sec, "baud"): + dev.serial_baud = cp.getint(serial_sec, "baud") + if cp.has_option(serial_sec, "line"): + dev.serial_line = cp.get(serial_sec, "line") + if cp.has_option(serial_sec, "dtr_rts"): + dev.serial_dtr_rts = cp.get(serial_sec, "dtr_rts") + if cp.has_option(serial_sec, "kiss_entry"): + dev.serial_kiss_entry = cp.get(serial_sec, "kiss_entry") + + # max25e0 family: hardcoded host addresses (forks inherit from root max25e0) + if device_id == "max25e0" or device_id.startswith("max25e0:"): + root_opts: dict[str, str] = {} + if device_id != "max25e0" and cp.has_section("device.max25e0"): + root_opts = {k: cp.get("device.max25e0", k) for k in cp.options("device.max25e0")} + if not dev.ipv4: + dev.ipv4 = (root_opts.get("ipv4") or "").strip() or MAX25E0_DEFAULT_IPV4 + if not dev.ipv6: + dev.ipv6 = (root_opts.get("ipv6") or "").strip() or MAX25E0_DEFAULT_IPV6 + + return dev + + +def create_backend( + dev_cfg: DeviceBackendConfig, + root: str, + on_rx: RxFn, + log: Optional[LogFn] = None, + prefix: Optional[str] = None, + on_invalid: Optional[InvalidFn] = None, +) -> DeviceBackend: + kind = dev_cfg.backend_type or registry_backend(dev_cfg.device_id) + if kind == "kiss-serial": + return KissSerialBackend(dev_cfg, root, on_rx, log, prefix=prefix, on_invalid=on_invalid) + if kind == "baycom-kiss": + return BayComKissBackend(dev_cfg, on_rx, log) + if kind in ("max25-bcpr-kiss", "bcpr-kiss"): + return Max25BcprKissBackend(dev_cfg, on_rx, log) + if kind == "kiss-raw-serial": + return KissRawSerialBackend(dev_cfg, root, on_rx, log, prefix=prefix) + if kind == "crdop-tcp": + return CrdopTcpBackend(dev_cfg, on_rx, log) + if kind == "audio-dummy": + return AudioDummyBackend(dev_cfg, on_rx, log) + return KissSerialBackend(dev_cfg, root, on_rx, log, prefix=prefix) + + +def backend_needs_stack(kind: str) -> bool: + return kind in ( + "kiss-serial", + "baycom-kiss", + "max25-bcpr-kiss", "bcpr-kiss", + "kiss-raw-serial", + "crdop-tcp", + "audio-dummy", + ) + + +def backend_serial_label(backend: Optional[DeviceBackend]) -> str: + if backend is None: + return "n/a" + return backend.status + + +# Legacy alias (tests / transitional) +BcprKissBackend = Max25BcprKissBackend diff --git a/stacks/daemon/kiss_bridge.py b/stacks/daemon/kiss_bridge.py new file mode 100644 index 0000000..b433d0e --- /dev/null +++ b/stacks/daemon/kiss_bridge.py @@ -0,0 +1,574 @@ +""" +KISS serial bridge for max25d — AX.25 UI over TNC2C / PK-TNC2. + +PTT: TNC firmware keys on KISS DATA (requires MYCALL); kernel BayCom (baycom_ser_fdx) +keys RTS in the driver when the KISS bridge accepts a DATA frame — no max25d PTT command. +""" +from __future__ import annotations + +import fcntl +import os +import struct +import termios +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Optional + +from ax25_codec import ( # noqa: E402 + ax25_build_ui, + ax25_crc, + ax25_crc_valid, + ax25_parse_ui, + format_callsign, + parse_callsign, + validate_callsign, +) +from tx_pace import tx_pace_before_send # noqa: E402 + +FEND = 0xC0 +FESC = 0xDB +TFEND = 0xDC +TFESC = 0xDD + +# Native KISS return (TheFirmware TF 2.7) — firmware reset to banner, not TAPR kiss off +KISS_RETURN_FRAME = b"\xc0\xff\xc0" + +KISS_CMD_DATA = 0x00 +MAX_FRAME = 1024 +MAX_PAYLOAD = 256 + + +@dataclass +class SerialProfile: + device: str = "/dev/ttyS4" + baud: int = 19200 + line: str = "8n1" + dtr_rts: bool = True + kiss_entry: str = "kiss_on" # kiss_on | auto + + +class KissDecoder: + def __init__(self) -> None: + self._buf = bytearray() + self._in_frame = False + self._escape = False + + def feed(self, data: bytes) -> list[tuple[int, bytes]]: + frames: list[tuple[int, bytes]] = [] + for byte in data: + if byte == FEND: + if self._in_frame and self._buf: + parsed = self._deliver() + if parsed is not None: + frames.append(parsed) + self._in_frame = True + self._escape = False + self._buf.clear() + continue + if not self._in_frame: + continue + if self._escape: + if byte == TFEND: + byte = FEND + elif byte == TFESC: + byte = FESC + self._escape = False + elif byte == FESC: + self._escape = True + continue + if len(self._buf) >= MAX_FRAME: + self._buf.clear() + self._in_frame = False + continue + self._buf.append(byte) + return frames + + def _deliver(self) -> Optional[tuple[int, bytes]]: + if len(self._buf) < 1: + return None + cmd_byte = self._buf[0] + if (cmd_byte & 0x0F) != KISS_CMD_DATA: + return None + port = (cmd_byte >> 4) & 0x0F + payload = bytes(self._buf[1:]) + return port, payload + + +def kiss_escape(data: bytes) -> bytes: + out = bytearray() + for b in data: + if b == FEND: + out.extend((FESC, TFEND)) + elif b == FESC: + out.extend((FESC, TFESC)) + else: + out.append(b) + return bytes(out) + + +def kiss_encode(port: int, cmd: int, payload: bytes) -> bytes: + cmd_byte = ((port & 0x0F) << 4) | (cmd & 0x0F) + return b"\xC0" + kiss_escape(bytes([cmd_byte]) + payload) + b"\xC0" + + +def kiss_data_frame(port: int, ax25_frame: bytes) -> bytes: + """Build KISS DATA; strip FCS when CRC validates (ax25ipd / KISS convention).""" + if ax25_crc_valid(ax25_frame): + ax25_frame = ax25_frame[:-2] + return kiss_encode(port, KISS_CMD_DATA, ax25_frame) + + +def format_rx_line(src: str, dst: str, payload: bytes, ax25_ui: bool) -> str: + try: + text = payload.decode("utf-8") + except UnicodeDecodeError: + text = payload.decode("utf-8", errors="replace") + if ax25_ui: + return f"[AX25 UI {src}>{dst}] {text}" + return text + + +def load_env_file(path: str) -> dict[str, str]: + out: dict[str, str] = {} + if not path or not os.path.isfile(path): + return out + with open(path, encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" in line: + key, val = line.split("=", 1) + out[key.strip()] = val.strip() + return out + + +def _load_serial_env(device_id: str, root: str, prefix: Optional[str] = None) -> dict[str, str]: + from paths import serial_env_candidates + + tree = Path(root) + pref = Path(prefix) if prefix else None + for path in serial_env_candidates(device_id, tree, pref): + env = load_env_file(str(path)) + if env: + return env + return {} + + +def serial_profile_for_device( + device_id: str, + root: str, + ini: dict[str, str], + prefix: Optional[str] = None, +) -> SerialProfile: + prof = SerialProfile() + if ini.get("device"): + prof.device = ini["device"] + if ini.get("baud"): + prof.baud = int(ini["baud"]) + if ini.get("line"): + prof.line = ini["line"].lower() + if ini.get("dtr_rts"): + prof.dtr_rts = ini["dtr_rts"].lower() in ("1", "yes", "true", "on") + if ini.get("kiss_entry"): + prof.kiss_entry = ini["kiss_entry"].lower() + + env = _load_serial_env(device_id, root, prefix) + + if device_id == "tnc2c": + prof.device = ini.get("device") or env.get("TNC2C_DEV", prof.device) + prof.baud = int(ini.get("baud") or env.get("TNC2C_BAUD", prof.baud)) + prof.line = (ini.get("line") or env.get("TNC2C_LINE", prof.line)).lower() + if "dtr_rts" not in ini: + prof.dtr_rts = True + if "kiss_entry" not in ini: + prof.kiss_entry = "kiss_on" + elif device_id == "pktnc2": + prof.device = ini.get("device") or env.get("PKTNC2_DEV") or env.get("TNC_DEV", prof.device) + prof.baud = int(ini.get("baud") or env.get("PKTNC2_BAUD") or env.get("TNC_BAUD", "9600")) + prof.line = (ini.get("line") or env.get("PKTNC2_LINE") or env.get("TNC_LINE", "8n1")).lower() + if "dtr_rts" not in ini: + prof.dtr_rts = False + if "kiss_entry" not in ini: + prof.kiss_entry = "auto" + elif device_id in ("pccom-kiss", "baycom-kiss"): + prof.device = ini.get("device") or env.get("PCCOM_KISS_DEV", prof.device or "/dev/ttyUSB0") + prof.baud = int(ini.get("baud") or env.get("PCCOM_KISS_BAUD", "9600")) + prof.line = (ini.get("line") or env.get("PCCOM_KISS_LINE", "8n1")).lower() + dtr = ini.get("dtr_rts") or env.get("PCCOM_KISS_DTR_RTS", "no") + prof.dtr_rts = str(dtr).lower() in ("1", "yes", "true", "on") + if "kiss_entry" not in ini: + prof.kiss_entry = "none" + elif device_id == "tmodem": + default_dev = "/dev/cuaU0" if sys.platform.startswith("freebsd") else "/dev/ttyACM0" + prof.device = ini.get("device") or env.get("TMODEM_DEV", prof.device or default_dev) + prof.baud = int(ini.get("baud") or env.get("TMODEM_BAUD", "115200")) + prof.line = (ini.get("line") or env.get("TMODEM_LINE", "8n1")).lower() + dtr = ini.get("dtr_rts") or env.get("TMODEM_DTR_RTS", "no") + prof.dtr_rts = str(dtr).lower() in ("1", "yes", "true", "on") + if "kiss_entry" not in ini: + prof.kiss_entry = "none" + return prof + + +def _parse_line(line: str) -> tuple[int, int]: + line = line.lower() + if line == "7e1": + return termios.CS7, termios.PARENB + return termios.CS8, 0 + + +def _parse_baud(baud: int) -> int: + table = { + 1200: termios.B1200, + 2400: termios.B2400, + 4800: termios.B4800, + 9600: termios.B9600, + 19200: termios.B19200, + } + if baud not in table: + raise ValueError(f"unsupported baud: {baud}") + return table[baud] + + +class KissBridge: + """Thread-safe KISS bridge on a serial TNC port.""" + + def __init__( + self, + profile: SerialProfile, + on_rx: Callable[[str], None], + log: Optional[Callable[[str], None]] = None, + *, + tree_root: str = "", + install_prefix: Optional[str] = None, + on_invalid: Optional[Callable[[], None]] = None, + ) -> None: + self.profile = profile + self._on_rx = on_rx + self._on_invalid = on_invalid + self._log = log or (lambda _m: None) + self._tree_root = tree_root + self._install_prefix = install_prefix + self._fd: Optional[int] = None + self._thread: Optional[threading.Thread] = None + self._stop = threading.Event() + self._lock = threading.Lock() + self._kiss_active = False + self._mycall = "" + self.status = "closed" + self._decoder = KissDecoder() + + def open(self) -> bool: + dev = self.profile.device + if not os.access(dev, os.R_OK | os.W_OK): + self.status = "error-no-device" + self._log(f"serial: no access to {dev}") + return False + try: + speed = _parse_baud(self.profile.baud) + databits, parity = _parse_line(self.profile.line) + except ValueError as exc: + self.status = "error-config" + self._log(f"serial: {exc}") + return False + try: + fd = os.open(dev, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK) + t = termios.tcgetattr(fd) + t[0] = t[1] = 0 + t[2] = termios.CLOCAL | termios.CREAD | databits | parity + t[3] = t[4] = t[5] = speed + t[6][termios.VMIN] = 0 + t[6][termios.VTIME] = 5 + termios.tcsetattr(fd, termios.TCSANOW, t) + termios.tcflush(fd, termios.TCIOFLUSH) + flags = struct.unpack("I", fcntl.ioctl(fd, 0x5415, struct.pack("I", 0)))[0] + if self.profile.dtr_rts: + flags |= 0x004 | 0x002 + fcntl.ioctl(fd, 0x5416, struct.pack("I", flags)) + except OSError as exc: + self.status = "error-open" + self._log(f"serial open failed: {exc}") + return False + self._fd = fd + self._log(f"serial open {dev} {self.profile.baud} {self.profile.line.upper()}") + if self.profile.dtr_rts: + time.sleep(2.0) + self._log("serial: DTR settle (2s)") + self.status = "open" + return True + + def close(self) -> None: + self._stop_rx_thread() + with self._lock: + if self._fd is not None: + if self._kiss_active: + self._write_unlocked(KISS_RETURN_FRAME) + try: + os.close(self._fd) + except OSError: + pass + self._fd = None + self._kiss_active = False + self.status = "closed" + self._decoder = KissDecoder() + + def _stop_rx_thread(self) -> None: + """Stop KISS RX thread so recovery owns the serial FD exclusively.""" + self._stop.set() + if self._thread is not None: + self._thread.join(timeout=2.0) + self._thread = None + self._stop.clear() + + def _start_rx_thread(self) -> None: + """Start KISS RX after terminal recovery and KISS entry succeed.""" + if self._fd is None or self._thread is not None or not self._kiss_active: + return + self._stop.clear() + self._thread = threading.Thread(target=self._rx_loop, name="kiss-rx", daemon=True) + self._thread.start() + + def attach_session(self, mycall: str) -> bool: + if self._fd is None: + return False + self._mycall = mycall.upper() + self._stop_rx_thread() + with self._lock: + ok = self._stabilize_unlocked(self._mycall, force_ladder=False) + if ok: + self._start_rx_thread() + return ok + + def stabilize_session(self, mycall: str, *, force: bool = False) -> bool: + """Probe terminal/KISS health and repair without closing the port (keeps DTR).""" + if self._fd is None: + self.status = "error-open" + return False + if not force and self._kiss_active and self.status == "ready": + return True + self._mycall = mycall.upper() + self._stop_rx_thread() + with self._lock: + ok = self._stabilize_unlocked(self._mycall, force_ladder=force) + if ok: + self._start_rx_thread() + return ok + + def _load_recovery_mod(self): + import importlib.util + + from paths import tnc_serial_recovery_path + + path = None + if self._tree_root: + prefix = Path(self._install_prefix) if self._install_prefix else None + path = tnc_serial_recovery_path(Path(self._tree_root), prefix) + if path is None: + path = Path(__file__).resolve().parents[1] / "tncs" / "tnc_serial_recovery.py" + if not path.is_file(): + return None + spec = importlib.util.spec_from_file_location("tnc_serial_recovery", path) + if spec is None or spec.loader is None: + return None + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + def _recovery_io(self) -> tuple[Callable[[bytes], None], Callable[[float], bytes]]: + def wf(data: bytes) -> None: + self._write_unlocked(data) + + def rf(seconds: float) -> bytes: + return self._drain_unlocked(seconds) + + return wf, rf + + def _leave_kiss_unlocked(self) -> None: + if not self._kiss_active: + return + self._write_unlocked(KISS_RETURN_FRAME) + time.sleep(0.3) + self._drain_unlocked(0.2) + self._kiss_active = False + + def _enter_kiss_session_unlocked(self) -> bool: + if not self._set_mycall_unlocked(self._mycall): + self._log("serial: MYCALL may have failed") + if not self._enter_kiss_unlocked(): + self.status = "error-kiss" + self._kiss_active = False + return False + self._kiss_active = True + self.status = "ready" + return True + + def _stabilize_unlocked(self, mycall: str, *, force_ladder: bool) -> bool: + """Host probe, optional recovery ladder, MYCALL + KISS — port stays open.""" + self._mycall = mycall.upper() + try: + mod = self._load_recovery_mod() + wf, rf = self._recovery_io() + self._leave_kiss_unlocked() + + if mod is None: + self._log("serial: tnc_serial_recovery.py not found") + self.status = "error-config" + self._kiss_active = False + return False + + ok, probe_data, only_echo = mod.probe_info(wf, rf, pause=0.25) + if not ok or only_echo or force_ladder: + self._log( + f"serial: initial probe — {mod.format_rx_brief(probe_data)}, " + f"echo_only={only_echo}, banner={mod.has_banner(probe_data)}" + ) + if ok and not only_echo and not force_ladder: + return self._enter_kiss_session_unlocked() + if only_echo or not ok or force_ladder: + label = "auto-repair" if force_ladder else "recovery ladder" + self._log(f"serial: {label}") + ok, _ = mod.recover_terminal(wf, rf, log=self._log) + if not ok: + self.status = "error-host" + self._kiss_active = False + return False + return self._enter_kiss_session_unlocked() + self.status = "error-host" + self._kiss_active = False + return False + except OSError as exc: + self.status = "error-io" + self._kiss_active = False + self._log(f"serial: stabilize I/O error ({exc})") + return False + + def _recover_terminal_unlocked(self) -> bool: + """Legacy hook — full ladder when probe fails.""" + try: + mod = self._load_recovery_mod() + if mod is None: + return True + wf, rf = self._recovery_io() + ok, _, only_echo = mod.probe_info(wf, rf) + if ok and not only_echo: + self._log("serial: terminal mode OK") + return True + self._log("serial: software recovery ladder") + ok, _ = mod.recover_terminal(wf, rf, log=self._log) + if ok: + self._log("serial: recovery OK") + return ok + except Exception as exc: + self._log(f"serial: recovery skipped ({exc})") + return True + + def detach_session(self) -> None: + self._stop_rx_thread() + with self._lock: + if self._fd is not None and self._kiss_active: + self._write_unlocked(KISS_RETURN_FRAME) + time.sleep(0.2) + self._kiss_active = False + if self._fd is not None: + self.status = "open" + + def transmit(self, src: str, dst: str, text: str, ax25_ui: bool) -> tuple[bool, str]: + if self._fd is None or not self._kiss_active: + return False, "serial not ready" + if len(text.encode("utf-8")) > MAX_PAYLOAD: + return False, "payload too long" + try: + validate_callsign(src) + validate_callsign(dst) + except ValueError as exc: + return False, str(exc) + info = text.encode("utf-8") + try: + frame = ax25_build_ui(src, dst, info) + except ValueError as exc: + return False, str(exc) + pkt = kiss_data_frame(0, frame) + with self._lock: + try: + tx_pace_before_send() + self._write_unlocked(pkt) + termios.tcdrain(self._fd) + except OSError as exc: + self.status = "error-tx" + return False, f"tx failed: {exc}" + display = format_rx_line(src, dst, info, ax25_ui) + return True, display + + def _write_unlocked(self, data: bytes) -> None: + if self._fd is None: + return + os.write(self._fd, data) + + def _drain_unlocked(self, seconds: float) -> bytes: + if self._fd is None: + return b"" + end = time.time() + seconds + chunks: list[bytes] = [] + while time.time() < end: + try: + chunk = os.read(self._fd, 4096) + if chunk: + chunks.append(chunk) + except BlockingIOError: + time.sleep(0.02) + return b"".join(chunks) + + def _set_mycall_unlocked(self, call: str) -> bool: + mod = self._load_recovery_mod() + if mod is not None and hasattr(mod, "tf_mycall_frame"): + cmd = mod.tf_mycall_frame(call) + else: + cmd = f"\x1bI {call.upper()}\r".encode("ascii", errors="replace") + self._write_unlocked(cmd) + time.sleep(0.4) + reply = self._drain_unlocked(0.6) + return b"?" not in reply[:32] + + def _enter_kiss_unlocked(self) -> bool: + entry = self.profile.kiss_entry + if entry == "tapr": + self._write_unlocked(b"kiss on\r") + time.sleep(0.5) + self._drain_unlocked(0.3) + return True + self._write_unlocked(b"\x1b@K") + time.sleep(0.5) + self._drain_unlocked(0.3) + return True + + def _rx_loop(self) -> None: + while not self._stop.is_set(): + fd = self._fd + if fd is None: + break + try: + chunk = os.read(fd, 4096) + except BlockingIOError: + time.sleep(0.05) + continue + except OSError: + self._log("serial: rx I/O error — watch will repair") + self.status = "error-io" + break + if not chunk: + time.sleep(0.05) + continue + for _port, payload in self._decoder.feed(chunk): + if not payload: + continue + parsed = ax25_parse_ui(payload) + if parsed is None: + if self._on_invalid is not None and len(payload) >= 16: + self._on_invalid() + continue + src, dst, info = parsed + line = format_rx_line(src, dst, info, ax25_ui=True) + self._on_rx(line) diff --git a/stacks/daemon/max25_platform.py b/stacks/daemon/max25_platform.py new file mode 100644 index 0000000..2148fae --- /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; not usable by default) + "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..2488f8d --- /dev/null +++ b/stacks/daemon/max25d.py @@ -0,0 +1,2185 @@ +#!/usr/bin/env python3 +""" +max25d — MainAX25-Stack daemon. + +Linux/KLinux: full hardware stack. FreeBSD: server + CRDOP/OSS (modular TCP/IP service). +""" +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 = "0.0.0.0" + 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 + return cfg + + +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 + + 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..efcedd5 --- /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 == "/var/run/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..d992d79 --- /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 = "0.0.0.0" + 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..12b4be2 --- /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) / "tncs").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..ea580bf --- /dev/null +++ b/stacks/daemon/test_tx_rx_offline.py @@ -0,0 +1,184 @@ +#!/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).""" + tncs = ROOT / "stacks" / "tncs" + spec = importlib.util.spec_from_file_location( + "tnc_serial_recovery", tncs / "tnc_serial_recovery.py" + ) + 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..bc1039a --- /dev/null +++ b/stacks/max25-bcpr/README.md @@ -0,0 +1,77 @@ +# 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 +``` + +## max25d + HyBBX cohost + +```ini +[daemon] +mode = hybbx-cohost + +[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 +ipv4 = 127.0.0.25/8 +ipv6 = ::25/128 +``` + +max25d starts **max25-bcprd-init** (live) or **max25-bcprd** (dry-run), then **releases** KISS PTY for HyBBX attach. Verified standalone Main co-host 2026-07-31 / 2026-08-01. + +Forks `max25e0:bcN` inherit `ipv4` / `ipv6` from `max25e0` unless set on the fork section. + +## Related + +| Goal | Doc | +|------|-----| +| Operator guide | [../../docs/BAYCOM.md](../../docs/BAYCOM.md) | +| Freeze caveats (dev) | [../../docs/BAYCOM-FREEZES.md](../../docs/BAYCOM-FREEZES.md) | +| HyBBX attach | [../../docs/HYBBX.md](../../docs/HYBBX.md) | + +## License + +Algorithms from Linux `baycom_ser_fdx` / `hdlcdrv` (GPL) — `NOTICE.md`. diff --git a/stacks/max25-bcpr/VERSION b/stacks/max25-bcpr/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/stacks/max25-bcpr/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/stacks/max25-bcpr/include/bcpr/bcpr.h b/stacks/max25-bcpr/include/bcpr/bcpr.h new file mode 100644 index 0000000..6223796 --- /dev/null +++ b/stacks/max25-bcpr/include/bcpr/bcpr.h @@ -0,0 +1,16 @@ +#ifndef BCPR_H +#define BCPR_H + +#include <stdint.h> +#include <stddef.h> + +#define BCPR_MAX_DEVICES 2 +#define BCPR_SER12_EXTENT 8 +#define BCPR_HDLC_BUF 32 +#define BCPR_MAXFLEN 400 +#define BCPR_MAGIC 0x42525052u /* 'BCPR' */ + +typedef struct bcpr_device bcpr_device_t; +typedef struct bcpr_engine bcpr_engine_t; + +#endif diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_config.h b/stacks/max25-bcpr/include/bcpr/bcpr_config.h new file mode 100644 index 0000000..158bf73 --- /dev/null +++ b/stacks/max25-bcpr/include/bcpr/bcpr_config.h @@ -0,0 +1,70 @@ +#ifndef BCPR_CONFIG_H +#define BCPR_CONFIG_H + +#include "bcpr/bcpr.h" + +/* TXD charge-pump policy (English INI: txd_bias=pulse|steady). */ +enum { + BCPR_TXD_PULSE = 0, /* Sailer/default: THR←0x00 every tick (framing edges) */ + BCPR_TXD_STEADY = 1 /* TFPCX-class experiment: UART break ≈ continuous SPACE */ +}; + +typedef struct bcpr_dev_config { + int enabled; + char serial[64]; + unsigned int iobase; + unsigned int irq; /* real UART IRQ — must match setserial */ + unsigned int baud; + char mode[16]; /* ser12* / ser12 / ser12+ */ + char kiss_link[256]; + int tx_delay; /* 10 ms units */ + int tx_tail; + int slottime; + int ppersist; + int fulldup; + /* + * FlexNet SER12.doc PTT watchdog mirror (cal / sustained key): + * after ptt_wd_key_ms keyed → clear RTS ~ptt_wd_pause_ms → resume. + * Defaults 14500 / 500. ptt_wd=0 disables. + */ + int ptt_wd; + int ptt_wd_key_ms; + int ptt_wd_pause_ms; + int txd_bias; /* BCPR_TXD_* */ + /* + * Software TOT (host policy — not radio TOT): + * max_key_ms = max continuous PTT per burst; + * min_gap_ms = min idle between bursts (matches TX pace); + * max_consecutive = back-to-back bursts (min_gap..session) before trip; + * max_bursts = total bursts before trip (each burst <= max_key_ms). + */ + int tot_enabled; + int tot_max_key_ms; + int tot_min_gap_ms; + int tot_max_consecutive; + int tot_max_bursts; +} bcpr_dev_config_t; + +typedef struct bcpr_config { + bcpr_dev_config_t dev[BCPR_MAX_DEVICES]; + int n_dev; + int dry_run; /* no ioperm / no setserial */ + char state_dir[128]; + char run_user[64]; + char run_group[64]; + /* Global defaults copied onto devices that omit per-bcN keys. */ + int ptt_wd; + int ptt_wd_key_ms; + int ptt_wd_pause_ms; + int txd_bias; + int tot_enabled; + int tot_max_key_ms; + int tot_min_gap_ms; + int tot_max_consecutive; + int tot_max_bursts; +} bcpr_config_t; + +int bcpr_config_load(bcpr_config_t *cfg, const char *path); +void bcpr_config_defaults(bcpr_config_t *cfg); + +#endif diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_crc.h b/stacks/max25-bcpr/include/bcpr/bcpr_crc.h new file mode 100644 index 0000000..5a53b15 --- /dev/null +++ b/stacks/max25-bcpr/include/bcpr/bcpr_crc.h @@ -0,0 +1,12 @@ +#ifndef BCPR_CRC_H +#define BCPR_CRC_H + +#include <stddef.h> +#include <stdint.h> + +/* CRC-CCITT as used by Linux hdlcdrv (good frame residue 0xf0b8). */ +uint16_t bcpr_crc_ccitt(uint16_t crc, const uint8_t *buf, size_t len); +void bcpr_append_crc_ccitt(uint8_t *buffer, int len); +int bcpr_check_crc_ccitt(const uint8_t *buf, int cnt); + +#endif diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_daemon.h b/stacks/max25-bcpr/include/bcpr/bcpr_daemon.h new file mode 100644 index 0000000..360b0ae --- /dev/null +++ b/stacks/max25-bcpr/include/bcpr/bcpr_daemon.h @@ -0,0 +1,23 @@ +#ifndef BCPR_DAEMON_H +#define BCPR_DAEMON_H + +#include "bcpr/bcpr_config.h" +#include "bcpr/bcpr_engine.h" +#include "bcpr/bcpr_kiss.h" + +typedef struct bcpr_daemon_opts { + int dry_run; + int seconds; + int cal_mode; + int cli_txd_bias; + int cli_ptt_wd; + int cli_ptt_wd_key_ms; + int cli_ptt_wd_pause_ms; + int engine_preopened; +} bcpr_daemon_opts_t; + +/* engine_preopened=1: lock/ioperm done in init parent; child after fork. */ +int bcpr_daemon_run(bcpr_config_t *cfg, bcpr_kiss_pty_t *ptys, int npty, + bcpr_engine_t *engine, const bcpr_daemon_opts_t *opts); + +#endif diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_engine.h b/stacks/max25-bcpr/include/bcpr/bcpr_engine.h new file mode 100644 index 0000000..f3b3d4a --- /dev/null +++ b/stacks/max25-bcpr/include/bcpr/bcpr_engine.h @@ -0,0 +1,62 @@ +#ifndef BCPR_ENGINE_H +#define BCPR_ENGINE_H + +#include "bcpr/bcpr_config.h" +#include "bcpr/bcpr_hdlc.h" +#include "bcpr/bcpr_lock.h" +#include "bcpr/bcpr_ser12.h" + +#include <stdint.h> + +struct bcpr_device { + bcpr_dev_config_t cfg; + bcpr_port_lock_t lock; + bcpr_ser12_t ser12; + bcpr_hdlc_t hdlc; + int running; + int index; /* 0 = bc0, 1 = bc1 */ + /* S0 TX telemetry (reset on PTT rise, emit on PTT fall). */ + int ptt_was; + int64_t ptt_on_ns; + unsigned thr_writes; + unsigned max_tick_gap_us; + unsigned last_tick_us; + unsigned tick_count; /* bit ticks while keyed */ + unsigned long long gap_sum_us; /* for mean gap */ + unsigned gaps_gt_2x; /* gaps > 2× baud_us */ + int tx_div_set; /* UART baud_uartdiv applied for this PTT */ + int break_set; /* txd_bias=steady: LCR break asserted */ + /* Software TOT runtime (see tot_* in bcpr_dev_config_t). */ + int tot_tripped; + int tot_burst_total; + int tot_consecutive; + unsigned tot_last_off_us; + unsigned tot_key_start_us; +}; + +typedef void (*bcpr_rx_fn)(int dev_idx, const uint8_t *kiss, int len, void *ud); + +struct bcpr_engine { + bcpr_config_t cfg; + bcpr_device_t dev[BCPR_MAX_DEVICES]; + int n; + volatile int stop; + bcpr_rx_fn on_rx; + void *on_rx_ud; + /* 0 = forever; >0 stop after wall-clock seconds (tests / dry-run). */ + int run_seconds; + /* BCPR_CAL_* on all enabled devices; 0 = normal KISS/HDLC. */ + int cal_mode; +}; + +int bcpr_engine_open(bcpr_engine_t *e, const bcpr_config_t *cfg); +void bcpr_engine_close(bcpr_engine_t *e); +void bcpr_engine_set_rx(bcpr_engine_t *e, bcpr_rx_fn fn, void *ud); +int bcpr_engine_queue_kiss(bcpr_engine_t *e, int dev_idx, const uint8_t *kiss, + int len); +/* Apply DOS-style cal (high/low/alt) before bcpr_engine_run. */ +void bcpr_engine_set_cal(bcpr_engine_t *e, int cal_mode); +/* Run until e->stop or run_seconds elapses. Dry-run skips UART I/O. */ +int bcpr_engine_run(bcpr_engine_t *e); + +#endif diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_hdlc.h b/stacks/max25-bcpr/include/bcpr/bcpr_hdlc.h new file mode 100644 index 0000000..0cf0bfb --- /dev/null +++ b/stacks/max25-bcpr/include/bcpr/bcpr_hdlc.h @@ -0,0 +1,57 @@ +#ifndef BCPR_HDLC_H +#define BCPR_HDLC_H + +#include "bcpr/bcpr.h" +#include <stdint.h> + +typedef struct bcpr_hbuf { + unsigned rd, wr; + uint16_t buf[BCPR_HDLC_BUF]; +} bcpr_hbuf_t; + +typedef struct bcpr_channel { + int tx_delay, tx_tail, slottime, ppersist, fulldup; +} bcpr_channel_t; + +typedef struct bcpr_hdlc { + int bitrate; + bcpr_channel_t ch; + bcpr_hbuf_t rx_hbuf; + bcpr_hbuf_t tx_hbuf; + int rx_state; + unsigned bitstream, bitbuf; + int numbits; + int dcd; + int rx_len; + uint8_t *rx_bp; + uint8_t rx_buffer[BCPR_MAXFLEN + 2]; + int tx_state; + int numflags; + unsigned tx_bitstream; + int ptt; + int slotcnt; + unsigned tx_bitbuf; + int tx_numbits; + int tx_len; + uint8_t *tx_bp; + uint8_t tx_buffer[BCPR_MAXFLEN + 2]; + /* pending KISS payload (without FEND/cmd) queued for TX */ + uint8_t pending[BCPR_MAXFLEN]; + int pending_len; + int have_pending; +} bcpr_hdlc_t; + +void bcpr_hdlc_init(bcpr_hdlc_t *h, int bitrate, const bcpr_channel_t *ch); +void bcpr_hdlc_putbits(bcpr_hdlc_t *h, unsigned bits); +unsigned bcpr_hdlc_getbits(bcpr_hdlc_t *h); +void bcpr_hdlc_receiver(bcpr_hdlc_t *h, + void (*on_frame)(const uint8_t *kiss, int len, void *ud), + void *ud); +void bcpr_hdlc_transmitter(bcpr_hdlc_t *h); +void bcpr_hdlc_arbitrate(bcpr_hdlc_t *h); +int bcpr_hdlc_queue_kiss(bcpr_hdlc_t *h, const uint8_t *kiss, int len); +int bcpr_hdlc_ptt(const bcpr_hdlc_t *h); +/* Force idle TX — drop pending frame and clear keyed state. */ +void bcpr_hdlc_abort_tx(bcpr_hdlc_t *h); + +#endif diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_kiss.h b/stacks/max25-bcpr/include/bcpr/bcpr_kiss.h new file mode 100644 index 0000000..2cdf5eb --- /dev/null +++ b/stacks/max25-bcpr/include/bcpr/bcpr_kiss.h @@ -0,0 +1,17 @@ +#ifndef BCPR_KISS_H +#define BCPR_KISS_H + +#include "bcpr/bcpr.h" + +typedef struct bcpr_kiss_pty { + int master_fd; + int slave_fd; + char link_path[128]; + int idx; +} bcpr_kiss_pty_t; + +int bcpr_kiss_pty_open(bcpr_kiss_pty_t *kp, int idx, const char *link_path, + const char *state_dir); +void bcpr_kiss_pty_close(bcpr_kiss_pty_t *kp); + +#endif diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_lock.h b/stacks/max25-bcpr/include/bcpr/bcpr_lock.h new file mode 100644 index 0000000..fd6c6b7 --- /dev/null +++ b/stacks/max25-bcpr/include/bcpr/bcpr_lock.h @@ -0,0 +1,22 @@ +#ifndef BCPR_LOCK_H +#define BCPR_LOCK_H + +#include "bcpr/bcpr_config.h" + +typedef struct bcpr_port_lock { + char serial[64]; + unsigned iobase; + unsigned irq; + int lock_fd; + int uart_released; + int dry_run; +} bcpr_port_lock_t; + +/* Exclusive COM lock: flock + setserial uart none. irq must match setserial. */ +int bcpr_lock_acquire(bcpr_port_lock_t *lk, const bcpr_dev_config_t *dev, + int dry_run); +void bcpr_lock_release(bcpr_port_lock_t *lk); +int bcpr_lock_verify_irq(const char *serial, unsigned expect_irq, + unsigned *got_irq, unsigned *got_io); + +#endif diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_runas.h b/stacks/max25-bcpr/include/bcpr/bcpr_runas.h new file mode 100644 index 0000000..801dcba --- /dev/null +++ b/stacks/max25-bcpr/include/bcpr/bcpr_runas.h @@ -0,0 +1,12 @@ +#ifndef BCPR_RUNAS_H +#define BCPR_RUNAS_H + +#include "bcpr/bcpr_config.h" + +/* Drop to [max25-bcpr] user=/group= (Linux). Returns 0 or -1. */ +int bcpr_runas_drop(const bcpr_config_t *cfg); + +/* Refuse live start as root — use max25-bcprd-init for hardware. */ +int bcpr_runas_refuse_root(int dry_run); + +#endif diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_ser12.h b/stacks/max25-bcpr/include/bcpr/bcpr_ser12.h new file mode 100644 index 0000000..2b6de8d --- /dev/null +++ b/stacks/max25-bcpr/include/bcpr/bcpr_ser12.h @@ -0,0 +1,47 @@ +#ifndef BCPR_SER12_H +#define BCPR_SER12_H + +#include "bcpr/bcpr_hdlc.h" +#include <stdint.h> + +/* DOS cal.exe style: sticky/toggle DTR bit + RTS PTT (no HDLC). */ +enum { + BCPR_CAL_OFF = 0, + BCPR_CAL_HIGH = 1, + BCPR_CAL_LOW = 2, + BCPR_CAL_ALT = 3 +}; + +typedef struct bcpr_ser12 { + unsigned baud; + unsigned baud_us; + int opt_dcd; /* 0 soft, 1 hard, -1 hard inv */ + unsigned char tx_bit; + unsigned char last_rxbit; + int dcd_sum0, dcd_sum1, dcd_sum2; + int dcd_time; + unsigned pll_time; + unsigned txshreg; + unsigned shreg; + int ptt_hw; /* modem PTT keyed (logical; stays 1 during WD pause) */ + int cal_mode; /* BCPR_CAL_* — bypasses HDLC TX */ + /* FlexNet SER12 PTT WD — see bcpr_ser12_set_ptt_wd(). */ + int ptt_wd; + unsigned ptt_wd_key_us; + unsigned ptt_wd_pause_us; + unsigned ptt_wd_phase_start_us; + int ptt_wd_pausing; /* 1 → force MCR idle (RTS clear) this tick */ +} bcpr_ser12_t; + +void bcpr_ser12_init(bcpr_ser12_t *s, unsigned baud, int opt_dcd); +void bcpr_ser12_set_mode(bcpr_ser12_t *s, const char *mode, unsigned *baud_out); +void bcpr_ser12_set_cal(bcpr_ser12_t *s, int cal_mode); +/* FlexNet defaults: key_ms=14500, pause_ms=500. enable=0 disables. */ +void bcpr_ser12_set_ptt_wd(bcpr_ser12_t *s, int enable, int key_ms, int pause_ms); +/* Software TOT: drop logical+HW PTT immediately. */ +void bcpr_ser12_force_unkey(bcpr_ser12_t *s); +/* One bit-time / tick: sample cts (0/1), drive *mcr_out / *thr_feed */ +void bcpr_ser12_tick(bcpr_ser12_t *s, bcpr_hdlc_t *h, int cts, int *mcr_out, + int *do_thr00, unsigned now_us); + +#endif diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_uart.h b/stacks/max25-bcpr/include/bcpr/bcpr_uart.h new file mode 100644 index 0000000..fde720e --- /dev/null +++ b/stacks/max25-bcpr/include/bcpr/bcpr_uart.h @@ -0,0 +1,22 @@ +#ifndef BCPR_UART_H +#define BCPR_UART_H + +#include <stdint.h> + +/* When set, all UART I/O is a no-op (CI / --dry-run). */ +void bcpr_uart_set_dry_run(int on); +int bcpr_uart_ioperm(unsigned iobase, int on); +void bcpr_uart_outb(unsigned char val, unsigned port); +unsigned char bcpr_uart_inb(unsigned port); +void bcpr_uart_set_divisor(unsigned iobase, unsigned divisor); +void bcpr_uart_open_ser12(unsigned iobase); +void bcpr_uart_close_ser12(unsigned iobase); +unsigned char bcpr_uart_msr(unsigned iobase); +void bcpr_uart_mcr(unsigned iobase, unsigned char v); +void bcpr_uart_thr00(unsigned iobase); +/* LCR bit6 Set Break — force TXD continuous SPACE (RS-232 +V). Clear = normal. */ +void bcpr_uart_set_break(unsigned iobase, int on); +/* Poll LSR bit5 (THRE) until set or timeout_us. Returns 1 if empty, 0 on timeout. */ +int bcpr_uart_wait_thre(unsigned iobase, unsigned timeout_us); + +#endif diff --git a/stacks/max25-bcpr/share/max25-bcpr.freebsd.ini.example b/stacks/max25-bcpr/share/max25-bcpr.freebsd.ini.example new file mode 100644 index 0000000..70f0fd1 --- /dev/null +++ b/stacks/max25-bcpr/share/max25-bcpr.freebsd.ini.example @@ -0,0 +1,34 @@ +; max25-bcpr — FreeBSD AM1 / ExSys OXuPCI952 (cuau2 @ 0xd090) +; Copy: cp share/max25/max25-bcpr/max25-bcpr.freebsd.ini.example /usr/local/etc/max25/max25-bcpr.ini +; Live: dry_run=no · max25-bcpr-ctl start --ini /usr/local/etc/max25/max25-bcpr.ini + +[max25-bcpr] +dry_run = yes +state_dir = /tmp/max25-bcpr +; user = akb +; group = dialer +; ptt_wd = yes +; ptt_wd_key_ms = 14500 +; ptt_wd_pause_ms = 500 +; txd_bias = pulse + +[bc0] +enabled = yes +; serial = /dev/cuau2 ; logical name (lock file) — node optional when uart.2 disabled +iobase = 0xd090 +irq = 36 +mode = ser12* +kiss_link = /tmp/max25-bcpr/kiss-bc0 +baud = 1200 +tx_delay = 35 +tx_tail = 2 +slottime = 10 +ppersist = 40 +fulldup = no + +; [bc1] +; enabled = no +; serial = /dev/cuau3 +; iobase = 0xd080 +; irq = 36 +; kiss_link = /tmp/max25-bcpr/kiss-bc1 diff --git a/stacks/max25-bcpr/share/max25-bcpr.ini.example b/stacks/max25-bcpr/share/max25-bcpr.ini.example new file mode 100644 index 0000000..3a553c8 --- /dev/null +++ b/stacks/max25-bcpr/share/max25-bcpr.ini.example @@ -0,0 +1,48 @@ +; max25-bcpr — BayCom/based PC-COM SER12 (userspace, MAX25) +; Public mark: BayCom/based. Never Konverter/converter. +; Product: BayCom/based PC-COM SER12 (userspace, MAX25). Built by default (MAX25_BUILD_MAX25_BCPR=ON). +; Hardware: TCM3105-class AFSK modem chip (bits↔tones + PTT). Not a TNC/digi/BBX. +; Host (max25-bcprd / max25d) owns HDLC, KISS, bit clock → device max25e0 (:bcN forks). +; Internal C API may still use bcpr_* symbols — product face is max25-bcpr only. +; +; Quick start (offline, after -DMAX25_BUILD_MAX25_BCPR=ON): +; max25-bcprd -c stacks/max25-bcpr/share/max25-bcpr.ini.example --dry-run --once +; Live: copy to /etc/max25/max25-bcpr.ini or ./local/max25-bcpr.ini; set dry_run=no. + +[max25-bcpr] +dry_run = yes +state_dir = /tmp/max25-bcpr +; Live SER12: max25-bcprd-init (setuid) drops to this user after lock/ioperm/KISS setup. +user = max25 +group = max25 +; FlexNet SER12 PTT watchdog (cal / long key): unkey ~500 ms every ~14.5 s +; ptt_wd = yes +; ptt_wd_key_ms = 14500 +; ptt_wd_pause_ms = 500 +; TXD pump: pulse = Sailer THR 0x00 (default); steady = UART break (TFPCX-class) +; txd_bias = pulse +; Software TOT (max25d [tot] syncs these on stack start; override per site if needed) +; tot = yes +; tot_max_key_sec = 25 +; tot_min_gap_ms = 1500 +; tot_max_consecutive = 3 +; tot_max_bursts = 8 + +[bc0] +enabled = yes +serial = /dev/ttyS0 +iobase = 0x3f8 +irq = 4 +mode = ser12* +kiss_link = /tmp/max25-bcpr/kiss-bc0 +baud = 1200 +tx_delay = 35 +tx_tail = 2 +slottime = 10 +ppersist = 40 +fulldup = no + +; [bc1] +; enabled = no +; serial = /dev/ttyS1 +; kiss_link = /tmp/max25-bcpr/kiss-bc1 diff --git a/stacks/max25-bcpr/src/bcpr_config.c b/stacks/max25-bcpr/src/bcpr_config.c new file mode 100644 index 0000000..3707891 --- /dev/null +++ b/stacks/max25-bcpr/src/bcpr_config.c @@ -0,0 +1,397 @@ +/* + * Simple INI loader for max25-bcpr (max 2 devices: [bc0] / [bc1]). + */ +#include "bcpr/bcpr_config.h" + +#include <ctype.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +void bcpr_config_defaults(bcpr_config_t *cfg) +{ + int i; + memset(cfg, 0, sizeof(*cfg)); + snprintf(cfg->state_dir, sizeof(cfg->state_dir), "%s", "/tmp/max25-bcpr"); + cfg->dry_run = 0; + cfg->n_dev = 0; + /* FlexNet SER12.doc: 14.5 s keyed / 500 ms unkey. */ + cfg->ptt_wd = 1; + cfg->ptt_wd_key_ms = 14500; + cfg->ptt_wd_pause_ms = 500; + cfg->txd_bias = BCPR_TXD_PULSE; + cfg->tot_enabled = 1; + cfg->tot_max_key_ms = 25000; + cfg->tot_min_gap_ms = 1500; + cfg->tot_max_consecutive = 3; + cfg->tot_max_bursts = 8; + for (i = 0; i < BCPR_MAX_DEVICES; i++) { + bcpr_dev_config_t *d = &cfg->dev[i]; + d->enabled = 0; + d->baud = 1200; + snprintf(d->mode, sizeof(d->mode), "%s", "ser12*"); + d->tx_delay = 35; + d->tx_tail = 2; + d->slottime = 10; + d->ppersist = 40; + d->fulldup = 0; + d->ptt_wd = 1; + d->ptt_wd_key_ms = 14500; + d->ptt_wd_pause_ms = 500; + d->txd_bias = BCPR_TXD_PULSE; + d->tot_enabled = 1; + d->tot_max_key_ms = 25000; + d->tot_min_gap_ms = 1500; + d->tot_max_consecutive = 3; + d->tot_max_bursts = 8; + } +} + +static void trim(char *s) +{ + char *e; + while (*s && isspace((unsigned char)*s)) { + memmove(s, s + 1, strlen(s)); + } + e = s + strlen(s); + while (e > s && isspace((unsigned char)e[-1])) { + *--e = '\0'; + } +} + +static int parse_u(const char *v, unsigned *out) +{ + char *end = NULL; + unsigned long x = strtoul(v, &end, 0); + if (!v[0] || (end && *end)) { + return -1; + } + *out = (unsigned)x; + return 0; +} + +static int parse_i(const char *v, int *out) +{ + char *end = NULL; + long x = strtol(v, &end, 0); + if (!v[0] || (end && *end)) { + return -1; + } + *out = (int)x; + return 0; +} + +static int truthy(const char *v) +{ + return (strcasecmp(v, "yes") == 0 || strcasecmp(v, "true") == 0 || + strcasecmp(v, "on") == 0 || strcmp(v, "1") == 0); +} + +static int parse_txd_bias(const char *v, int *out) +{ + if (strcasecmp(v, "pulse") == 0 || strcasecmp(v, "sailer") == 0) { + *out = BCPR_TXD_PULSE; + return 0; + } + if (strcasecmp(v, "steady") == 0 || strcasecmp(v, "tfpcx") == 0 || + strcasecmp(v, "break") == 0) { + *out = BCPR_TXD_STEADY; + return 0; + } + return -1; +} + +static int apply_tot(int *enabled, int *max_key_ms, int *min_gap_ms, + int *max_consecutive, int *max_bursts, + const char *key, const char *val) +{ + if (strcmp(key, "tot") == 0 || strcmp(key, "tot_enabled") == 0) { + *enabled = truthy(val) ? 1 : 0; + return 0; + } + if (strcmp(key, "tot_max_key_sec") == 0 || strcmp(key, "tot_max_key_ms") == 0) { + if (strcmp(key, "tot_max_key_sec") == 0) { + int sec; + if (parse_i(val, &sec) != 0 || sec < 1) { + return -1; + } + *max_key_ms = sec * 1000; + return 0; + } + return parse_i(val, max_key_ms); + } + if (strcmp(key, "tot_min_gap_sec") == 0 || strcmp(key, "tot_min_gap_ms") == 0) { + if (strcmp(key, "tot_min_gap_sec") == 0) { + int sec; + if (parse_i(val, &sec) != 0 || sec < 0) { + return -1; + } + *min_gap_ms = sec * 1000; + return 0; + } + return parse_i(val, min_gap_ms); + } + if (strcmp(key, "tot_max_consecutive") == 0) { + return parse_i(val, max_consecutive); + } + if (strcmp(key, "tot_max_bursts") == 0) { + return parse_i(val, max_bursts); + } + return 1; +} + +static int apply_wd_txd(int *ptt_wd, int *key_ms, int *pause_ms, int *txd_bias, + const char *key, const char *val) +{ + if (strcmp(key, "ptt_wd") == 0 || strcmp(key, "ptt_watchdog") == 0) { + *ptt_wd = truthy(val) ? 1 : 0; + return 0; + } + if (strcmp(key, "ptt_wd_key_ms") == 0) { + return parse_i(val, key_ms); + } + if (strcmp(key, "ptt_wd_pause_ms") == 0) { + return parse_i(val, pause_ms); + } + if (strcmp(key, "txd_bias") == 0) { + return parse_txd_bias(val, txd_bias); + } + return 1; /* not handled */ +} + +static void normalize_legacy_path(char *buf, size_t len) +{ + static const struct { + const char *from; + const char *to; + } map[] = { + {"/tmp/bcpr/", "/tmp/max25-bcpr/"}, + {"/tmp/bcpr", "/tmp/max25-bcpr"}, + {"/var/run/bcpr/", "/tmp/max25-bcpr/"}, + {"/var/run/bcpr", "/tmp/max25-bcpr"}, + }; + size_t i; + size_t flen; + if (!buf || !len || !buf[0]) { + return; + } + for (i = 0; i < sizeof(map) / sizeof(map[0]); i++) { + flen = strlen(map[i].from); + if (strncmp(buf, map[i].from, flen) != 0) { + continue; + } + if (buf[flen] != '\0' && buf[flen] != '/') { + continue; + } + { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "%s%s", map[i].to, buf + flen); + snprintf(buf, len, "%s", tmp); + } + return; + } +} + +static int apply_kv(bcpr_config_t *cfg, int section, const char *key, + const char *val) +{ + /* section: -1 = [max25-bcpr]/[bcpr] global, 0 = bc0, 1 = bc1 */ + if (section < 0) { + int rc; + if (strcmp(key, "dry_run") == 0) { + cfg->dry_run = truthy(val); + return 0; + } + if (strcmp(key, "state_dir") == 0) { + snprintf(cfg->state_dir, sizeof(cfg->state_dir), "%s", val); + return 0; + } + if (strcmp(key, "user") == 0) { + snprintf(cfg->run_user, sizeof(cfg->run_user), "%s", val); + return 0; + } + if (strcmp(key, "group") == 0) { + snprintf(cfg->run_group, sizeof(cfg->run_group), "%s", val); + return 0; + } + rc = apply_wd_txd(&cfg->ptt_wd, &cfg->ptt_wd_key_ms, + &cfg->ptt_wd_pause_ms, &cfg->txd_bias, key, val); + if (rc <= 0) { + /* Propagate global WD/TXD defaults onto both devices. */ + int i; + for (i = 0; i < BCPR_MAX_DEVICES; i++) { + cfg->dev[i].ptt_wd = cfg->ptt_wd; + cfg->dev[i].ptt_wd_key_ms = cfg->ptt_wd_key_ms; + cfg->dev[i].ptt_wd_pause_ms = cfg->ptt_wd_pause_ms; + cfg->dev[i].txd_bias = cfg->txd_bias; + } + return rc; + } + rc = apply_tot(&cfg->tot_enabled, &cfg->tot_max_key_ms, &cfg->tot_min_gap_ms, + &cfg->tot_max_consecutive, &cfg->tot_max_bursts, key, val); + if (rc <= 0) { + int i; + for (i = 0; i < BCPR_MAX_DEVICES; i++) { + cfg->dev[i].tot_enabled = cfg->tot_enabled; + cfg->dev[i].tot_max_key_ms = cfg->tot_max_key_ms; + cfg->dev[i].tot_min_gap_ms = cfg->tot_min_gap_ms; + cfg->dev[i].tot_max_consecutive = cfg->tot_max_consecutive; + cfg->dev[i].tot_max_bursts = cfg->tot_max_bursts; + } + return rc; + } + return 0; + } + if (section >= BCPR_MAX_DEVICES) { + return -1; + } + { + bcpr_dev_config_t *d = &cfg->dev[section]; + int rc; + if (strcmp(key, "enabled") == 0) { + d->enabled = truthy(val); + } else if (strcmp(key, "serial") == 0 || strcmp(key, "tty") == 0) { + snprintf(d->serial, sizeof(d->serial), "%s", val); + d->enabled = 1; + } else if (strcmp(key, "iobase") == 0) { + return parse_u(val, &d->iobase); + } else if (strcmp(key, "irq") == 0) { + return parse_u(val, &d->irq); + } else if (strcmp(key, "baud") == 0) { + return parse_u(val, &d->baud); + } else if (strcmp(key, "mode") == 0) { + snprintf(d->mode, sizeof(d->mode), "%s", val); + } else if (strcmp(key, "kiss_link") == 0) { + snprintf(d->kiss_link, sizeof(d->kiss_link), "%s", val); + } else if (strcmp(key, "tx_delay") == 0 || strcmp(key, "txdelay") == 0) { + return parse_i(val, &d->tx_delay); + } else if (strcmp(key, "tx_tail") == 0 || strcmp(key, "txtail") == 0) { + return parse_i(val, &d->tx_tail); + } else if (strcmp(key, "slottime") == 0) { + return parse_i(val, &d->slottime); + } else if (strcmp(key, "ppersist") == 0) { + return parse_i(val, &d->ppersist); + } else if (strcmp(key, "fulldup") == 0) { + d->fulldup = truthy(val); + } else { + rc = apply_wd_txd(&d->ptt_wd, &d->ptt_wd_key_ms, &d->ptt_wd_pause_ms, + &d->txd_bias, key, val); + if (rc < 0) { + return -1; + } + if (rc == 0) { + return 0; + } + rc = apply_tot(&d->tot_enabled, &d->tot_max_key_ms, &d->tot_min_gap_ms, + &d->tot_max_consecutive, &d->tot_max_bursts, key, val); + if (rc < 0) { + return -1; + } + if (rc == 0) { + return 0; + } + } + } + return 0; +} + +int bcpr_config_load(bcpr_config_t *cfg, const char *path) +{ + FILE *f; + char line[512]; + int section = -1; + int i; + + if (!cfg || !path) { + return -1; + } + bcpr_config_defaults(cfg); + f = fopen(path, "r"); + if (!f) { + return -1; + } + while (fgets(line, sizeof(line), f)) { + char *eq; + char *p = line; + trim(p); + if (p[0] == '\0' || p[0] == '#' || p[0] == ';') { + continue; + } + if (p[0] == '[') { + char *end = strchr(p, ']'); + if (!end) { + continue; + } + *end = '\0'; + p++; + if (strcasecmp(p, "max25-bcpr") == 0 || strcasecmp(p, "bcpr") == 0 || + strcasecmp(p, "global") == 0) { + section = -1; + } else if (strcasecmp(p, "bc0") == 0 || + strcasecmp(p, "device.bc0") == 0) { + section = 0; + } else if (strcasecmp(p, "bc1") == 0 || + strcasecmp(p, "device.bc1") == 0) { + section = 1; + } else { + section = -2; /* ignore unknown */ + } + continue; + } + if (section == -2) { + continue; + } + eq = strchr(p, '='); + if (!eq) { + continue; + } + *eq = '\0'; + trim(p); + trim(eq + 1); + (void)apply_kv(cfg, section, p, eq + 1); + } + fclose(f); + + normalize_legacy_path(cfg->state_dir, sizeof(cfg->state_dir)); + + cfg->n_dev = 0; + for (i = 0; i < BCPR_MAX_DEVICES; i++) { + if (cfg->dev[i].enabled && cfg->dev[i].serial[0]) { + if (!cfg->dev[i].kiss_link[0]) { + snprintf(cfg->dev[i].kiss_link, sizeof(cfg->dev[i].kiss_link), + "%s/kiss-bc%d", cfg->state_dir, i); + } + normalize_legacy_path(cfg->dev[i].kiss_link, + sizeof(cfg->dev[i].kiss_link)); + /* Clamp WD timings. */ + if (cfg->dev[i].ptt_wd_key_ms < 1000) { + cfg->dev[i].ptt_wd_key_ms = 1000; + } + if (cfg->dev[i].ptt_wd_pause_ms < 50) { + cfg->dev[i].ptt_wd_pause_ms = 50; + } + if (cfg->dev[i].txd_bias != BCPR_TXD_STEADY) { + cfg->dev[i].txd_bias = BCPR_TXD_PULSE; + } + if (cfg->dev[i].tot_max_key_ms < 1000) { + cfg->dev[i].tot_max_key_ms = 1000; + } + if (cfg->dev[i].tot_min_gap_ms < 0) { + cfg->dev[i].tot_min_gap_ms = 0; + } + if (cfg->dev[i].tot_max_consecutive < 1) { + cfg->dev[i].tot_max_consecutive = 1; + } + if (cfg->dev[i].tot_max_bursts < 1) { + cfg->dev[i].tot_max_bursts = 1; + } + cfg->n_dev++; + } else { + cfg->dev[i].enabled = 0; + } + } + if (cfg->n_dev > BCPR_MAX_DEVICES) { + return -1; + } + return 0; +} diff --git a/stacks/max25-bcpr/src/bcpr_crc.c b/stacks/max25-bcpr/src/bcpr_crc.c new file mode 100644 index 0000000..6aabc49 --- /dev/null +++ b/stacks/max25-bcpr/src/bcpr_crc.c @@ -0,0 +1,55 @@ +#include "bcpr/bcpr_crc.h" + +/* CRC-CCITT poly 0x8408 reflected (hdlcdrv / WAMPES style via crc_ccitt). */ +static const uint16_t bcpr_crc_table[256] = { + 0x0000, 0x1189, 0x2312, 0x329b, 0x4624, 0x57ad, 0x6536, 0x74bf, 0x8c48, + 0x9dc1, 0xaf5a, 0xbed3, 0xca6c, 0xdbe5, 0xe97e, 0xf8f7, 0x1081, 0x0108, + 0x3393, 0x221a, 0x56a5, 0x472c, 0x75b7, 0x643e, 0x9cc9, 0x8d40, 0xbfdb, + 0xae52, 0xdaed, 0xcb64, 0xf9ff, 0xe876, 0x2102, 0x308b, 0x0210, 0x1399, + 0x6726, 0x76af, 0x4434, 0x55bd, 0xad4a, 0xbcc3, 0x8e58, 0x9fd1, 0xeb6e, + 0xfae7, 0xc87c, 0xd9f5, 0x3183, 0x200a, 0x1291, 0x0318, 0x77a7, 0x662e, + 0x54b5, 0x453c, 0xbdcb, 0xac42, 0x9ed9, 0x8f50, 0xfbef, 0xea66, 0xd8fd, + 0xc974, 0x4204, 0x538d, 0x6116, 0x709f, 0x0420, 0x15a9, 0x2732, 0x36bb, + 0xce4c, 0xdfc5, 0xed5e, 0xfcd7, 0x8868, 0x99e1, 0xab7a, 0xbaf3, 0x5285, + 0x430c, 0x7197, 0x601e, 0x14a1, 0x0528, 0x37b3, 0x263a, 0xdecd, 0xcf44, + 0xfddf, 0xec56, 0x98e9, 0x8960, 0xbbfb, 0xaa72, 0x6306, 0x728f, 0x4014, + 0x519d, 0x2522, 0x34ab, 0x0630, 0x17b9, 0xef4e, 0xfec7, 0xcc5c, 0xddd5, + 0xa96a, 0xb8e3, 0x8a78, 0x9bf1, 0x7387, 0x620e, 0x5095, 0x411c, 0x35a3, + 0x242a, 0x16b1, 0x0738, 0xffcf, 0xee46, 0xdcdd, 0xcd54, 0xb9eb, 0xa862, + 0x9af9, 0x8b70, 0x8408, 0x9581, 0xa71a, 0xb693, 0xc22c, 0xd3a5, 0xe13e, + 0xf0b7, 0x0840, 0x19c9, 0x2b52, 0x3adb, 0x4e64, 0x5fed, 0x6d76, 0x7cff, + 0x9489, 0x8500, 0xb79b, 0xa612, 0xd2ad, 0xc324, 0xf1bf, 0xe036, 0x18c1, + 0x0948, 0x3bd3, 0x2a5a, 0x5ee5, 0x4f6c, 0x7df7, 0x6c7e, 0xa50a, 0xb483, + 0x8618, 0x9791, 0xe32e, 0xf2a7, 0xc03c, 0xd1b5, 0x2942, 0x38cb, 0x0a50, + 0x1bd9, 0x6f66, 0x7eef, 0x4c74, 0x5dfd, 0xb58b, 0xa402, 0x9699, 0x8710, + 0xf3af, 0xe226, 0xd0bd, 0xc134, 0x39c3, 0x284a, 0x1ad1, 0x0b58, 0x7fe7, + 0x6e6e, 0x5cf5, 0x4d7c, 0xc60c, 0xd785, 0xe51e, 0xf497, 0x8028, 0x91a1, + 0xa33a, 0xb2b3, 0x4a44, 0x5bcd, 0x6956, 0x78df, 0x0c60, 0x1de9, 0x2f72, + 0x3efb, 0xd68d, 0xc704, 0xf59f, 0xe416, 0x90a9, 0x8120, 0xb3bb, 0xa232, + 0x5ac5, 0x4b4c, 0x79d7, 0x685e, 0x1ce1, 0x0d68, 0x3ff3, 0x2e7a, 0xe70e, + 0xf687, 0xc41c, 0xd595, 0xa12a, 0xb0a3, 0x8238, 0x93b1, 0x6b46, 0x7acf, + 0x4854, 0x59dd, 0x2d62, 0x3ceb, 0x0e70, 0x1ff9, 0xf78f, 0xe606, 0xd49d, + 0xc514, 0xb1ab, 0xa022, 0x92b9, 0x8330, 0x7bc7, 0x6a4e, 0x58d5, 0x495c, + 0x3de3, 0x2c6a, 0x1ef1, 0x0f78 +}; + +uint16_t bcpr_crc_ccitt(uint16_t crc, const uint8_t *buf, size_t len) +{ + while (len--) { + crc = (crc >> 8) ^ bcpr_crc_table[(crc ^ *buf++) & 0xff]; + } + return crc; +} + +void bcpr_append_crc_ccitt(uint8_t *buffer, int len) +{ + unsigned int crc = bcpr_crc_ccitt(0xffff, buffer, (size_t)len) ^ 0xffff; + buffer += len; + *buffer++ = (uint8_t)(crc & 0xff); + *buffer++ = (uint8_t)(crc >> 8); +} + +int bcpr_check_crc_ccitt(const uint8_t *buf, int cnt) +{ + return (bcpr_crc_ccitt(0xffff, buf, (size_t)cnt) & 0xffff) == 0xf0b8; +} diff --git a/stacks/max25-bcpr/src/bcpr_daemon.c b/stacks/max25-bcpr/src/bcpr_daemon.c new file mode 100644 index 0000000..9019159 --- /dev/null +++ b/stacks/max25-bcpr/src/bcpr_daemon.c @@ -0,0 +1,289 @@ +/* + * Shared max25-bcprd run loop (KISS thread + SER12 engine). + */ +#define _GNU_SOURCE +#include "bcpr/bcpr_daemon.h" +#include "bcpr/bcpr_ser12.h" + +#include <errno.h> +#include <poll.h> +#include <pthread.h> +#include <signal.h> +#include <stdio.h> +#include <string.h> +#include <sys/stat.h> +#include <unistd.h> + +static volatile sig_atomic_t g_stop; + +static void on_sig(int sig) +{ + (void)sig; + g_stop = 1; +} + +static void kiss_write_frame(int fd, const uint8_t *kiss, int len) +{ + uint8_t out[BCPR_MAXFLEN * 2 + 4]; + int o = 0; + int i; + + if (fd < 0 || !kiss || len <= 0) { + return; + } + out[o++] = 0xC0; + for (i = 0; i < len && o < (int)sizeof(out) - 2; i++) { + if (kiss[i] == 0xC0) { + out[o++] = 0xDB; + out[o++] = 0xDC; + } else if (kiss[i] == 0xDB) { + out[o++] = 0xDB; + out[o++] = 0xDD; + } else { + out[o++] = kiss[i]; + } + } + out[o++] = 0xC0; + (void)write(fd, out, (size_t)o); +} + +static bcpr_kiss_pty_t *g_pty; +static int g_npty; + +static void on_rx(int dev_idx, const uint8_t *kiss, int len, void *ud) +{ + int i; + (void)ud; + for (i = 0; i < g_npty; i++) { + if (g_pty[i].idx == dev_idx) { + kiss_write_frame(g_pty[i].master_fd, kiss, len); + return; + } + } +} + +static void kiss_feed(bcpr_engine_t *e, bcpr_kiss_pty_t *kp) +{ + static uint8_t acc[BCPR_MAX_DEVICES][BCPR_MAXFLEN + 8]; + static int alen[BCPR_MAX_DEVICES]; + static int esc[BCPR_MAX_DEVICES]; + static int in_frame[BCPR_MAX_DEVICES]; + uint8_t buf[512]; + ssize_t n; + int i; + int di = kp->idx; + + if (di < 0 || di >= BCPR_MAX_DEVICES) { + return; + } + n = read(kp->master_fd, buf, sizeof(buf)); + if (n <= 0) { + return; + } + for (i = 0; i < (int)n; i++) { + uint8_t b = buf[i]; + if (!in_frame[di]) { + if (b == 0xC0) { + in_frame[di] = 1; + alen[di] = 0; + esc[di] = 0; + } + continue; + } + if (esc[di]) { + esc[di] = 0; + if (b == 0xDC) { + b = 0xC0; + } else if (b == 0xDD) { + b = 0xDB; + } + if (alen[di] < (int)sizeof(acc[di])) { + acc[di][alen[di]++] = b; + } + continue; + } + if (b == 0xDB) { + esc[di] = 1; + continue; + } + if (b == 0xC0) { + if (alen[di] >= 2) { + int q; + int w; + for (w = 0; w < 70; w++) { + q = bcpr_engine_queue_kiss(e, di, acc[di], alen[di]); + if (q == 0) { + break; + } + usleep(50000); + } + if (q != 0) { + fprintf(stderr, + "max25-bcprd: queue_kiss drop bc%d len=%d (busy)\n", + di, alen[di]); + } + } + in_frame[di] = 0; + alen[di] = 0; + continue; + } + if (alen[di] < (int)sizeof(acc[di])) { + acc[di][alen[di]++] = b; + } + } +} + +static void *kiss_thread(void *arg) +{ + bcpr_engine_t *e = (bcpr_engine_t *)arg; + + while (!g_stop && !e->stop) { + struct pollfd pf[BCPR_MAX_DEVICES]; + int nf = 0; + int map[BCPR_MAX_DEVICES]; + int i; + int ret; + int backoff = 0; + + for (i = 0; i < g_npty; i++) { + if (g_pty[i].master_fd < 0) { + continue; + } + pf[nf].fd = g_pty[i].master_fd; + pf[nf].events = POLLIN | POLLHUP | POLLERR; + map[nf] = i; + nf++; + } + if (nf == 0) { + usleep(50000); + continue; + } + ret = poll(pf, (nfds_t)nf, 50); + if (ret <= 0) { + continue; + } + for (i = 0; i < nf; i++) { + short re = pf[i].revents; + if (re & POLLIN) { + kiss_feed(e, &g_pty[map[i]]); + } + if (re & (POLLHUP | POLLERR | POLLNVAL)) { + backoff = 1; + } + } + if (backoff) { + usleep(50000); + } + } + return NULL; +} + +static int ensure_dir(const char *path) +{ + char tmp[256]; + char *p; + size_t len; + + if (!path || !path[0]) { + return -1; + } + snprintf(tmp, sizeof(tmp), "%s", path); + len = strlen(tmp); + if (len == 0) { + return -1; + } + if (tmp[len - 1] == '/') { + tmp[len - 1] = '\0'; + } + for (p = tmp + 1; *p; p++) { + if (*p == '/') { + *p = '\0'; + (void)mkdir(tmp, 0755); + *p = '/'; + } + } + return mkdir(tmp, 0755) == 0 || errno == EEXIST ? 0 : -1; +} + +int bcpr_daemon_run(bcpr_config_t *cfg, bcpr_kiss_pty_t *ptys, int npty, + bcpr_engine_t *engine, const bcpr_daemon_opts_t *opts) +{ + pthread_t thr; + int thr_ok = 0; + int i; + bcpr_engine_t local_engine; + bcpr_engine_t *e = engine; + int cal_mode; + int seconds; + + if (!cfg || !opts) { + return 1; + } + + g_stop = 0; + g_pty = ptys; + g_npty = npty; + cal_mode = opts->cal_mode; + seconds = opts->seconds; + + signal(SIGINT, on_sig); + signal(SIGTERM, on_sig); + + for (i = 0; i < BCPR_MAX_DEVICES; i++) { + if (opts->cli_txd_bias >= 0) { + cfg->dev[i].txd_bias = opts->cli_txd_bias; + } + if (opts->cli_ptt_wd >= 0) { + cfg->dev[i].ptt_wd = opts->cli_ptt_wd; + } + if (opts->cli_ptt_wd_key_ms > 0) { + cfg->dev[i].ptt_wd_key_ms = opts->cli_ptt_wd_key_ms; + } + if (opts->cli_ptt_wd_pause_ms > 0) { + cfg->dev[i].ptt_wd_pause_ms = opts->cli_ptt_wd_pause_ms; + } + } + + (void)ensure_dir(cfg->state_dir); + + if (!opts->engine_preopened) { + e = &local_engine; + if (bcpr_engine_open(e, cfg) != 0) { + for (i = 0; i < npty; i++) { + bcpr_kiss_pty_close(&ptys[i]); + } + return 1; + } + } + + e->run_seconds = seconds; + if (cal_mode != BCPR_CAL_OFF) { + bcpr_engine_set_cal(e, cal_mode); + } + bcpr_engine_set_rx(e, on_rx, NULL); + + if (npty > 0 && cal_mode == BCPR_CAL_OFF && + pthread_create(&thr, NULL, kiss_thread, e) == 0) { + thr_ok = 1; + } + + if (cal_mode != BCPR_CAL_OFF && !cfg->dry_run) { + fprintf(stderr, + "bcprd: *** WATCH NOW *** cal PTT+tone for %d s — then unkey\n", + seconds); + } + + (void)bcpr_engine_run(e); + + g_stop = 1; + e->stop = 1; + if (thr_ok) { + pthread_join(thr, NULL); + } + bcpr_engine_close(e); + for (i = 0; i < npty; i++) { + bcpr_kiss_pty_close(&ptys[i]); + } + fprintf(stderr, "max25-bcprd: stopped\n"); + return 0; +} diff --git a/stacks/max25-bcpr/src/bcpr_engine.c b/stacks/max25-bcpr/src/bcpr_engine.c new file mode 100644 index 0000000..2367817 --- /dev/null +++ b/stacks/max25-bcpr/src/bcpr_engine.c @@ -0,0 +1,652 @@ +/* + * Bit-clock engine: RT timer loop drives SER12 + HDLC for up to 2 devices. + * Userspace timing (not kernel hard-IRQ). See NOTICE.md. + */ +#define _GNU_SOURCE +#include "bcpr/bcpr_engine.h" +#include "bcpr/bcpr_uart.h" +#include "bcpr/bcpr_hdlc.h" +#include "bcpr/bcpr_ser12.h" + +#include <errno.h> +#include <stdio.h> +#include <string.h> +#include <time.h> +#include <unistd.h> +#include <stdint.h> + +#if defined(__linux__) +#include <pthread.h> +#include <sched.h> +#include <sys/mman.h> +#endif + +typedef struct { + bcpr_engine_t *e; + int idx; +} rx_ctx_t; + +static unsigned now_us(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + /* Full monotonic µs — not nsec-within-second (gap spikes were misread). */ + return (unsigned)(ts.tv_sec * 1000000ull + (unsigned long long)ts.tv_nsec / 1000ull); +} + +/* Idle longer than this resets consecutive-burst counting (new session). */ +#define TOT_SESSION_RESET_US 120000000u + +static void tot_write_trip_file(const bcpr_engine_t *e, const bcpr_device_t *d, + const char *reason) +{ + char path[192]; + FILE *f; + + if (!e || !d || e->cfg.dry_run || e->cfg.state_dir[0] == '\0') { + return; + } + snprintf(path, sizeof(path), "%s/tot-trip-bc%d", e->cfg.state_dir, d->index); + f = fopen(path, "w"); + if (!f) { + return; + } + fprintf(f, + "reason=%s\nburst_total=%d\nconsecutive=%d\ntripped=1\n" + "max_key_ms=%d\nmax_bursts=%d\nmax_consecutive=%d\n", + reason ? reason : "unknown", d->tot_burst_total, d->tot_consecutive, + d->cfg.tot_max_key_ms, d->cfg.tot_max_bursts, + d->cfg.tot_max_consecutive); + fclose(f); +} + +static void tot_trip(bcpr_engine_t *e, bcpr_device_t *d, const char *reason) +{ + if (!d || d->tot_tripped) { + return; + } + d->tot_tripped = 1; + bcpr_ser12_force_unkey(&d->ser12); + bcpr_hdlc_abort_tx(&d->hdlc); + fprintf(stderr, + "bcpr: bc%d TOT TRIP reason=%s bursts=%d consecutive=%d\n", + d->index, reason ? reason : "unknown", d->tot_burst_total, + d->tot_consecutive); + tot_write_trip_file(e, d, reason); +} + +static void tot_on_ptt_rise(bcpr_engine_t *e, bcpr_device_t *d, unsigned now_us) +{ + unsigned gap_us; + + if (!d->cfg.tot_enabled || d->tot_tripped) { + return; + } + d->tot_key_start_us = now_us; + if (d->tot_last_off_us == 0u) { + d->tot_consecutive = 1; + return; + } + gap_us = now_us - d->tot_last_off_us; + if (gap_us >= (unsigned)d->cfg.tot_min_gap_ms * 1000u) { + if (gap_us < TOT_SESSION_RESET_US) { + d->tot_consecutive++; + } else { + d->tot_consecutive = 1; + d->tot_burst_total = 0; + } + } + if (d->tot_consecutive > d->cfg.tot_max_consecutive) { + tot_trip(e, d, "max_consecutive"); + } +} + +static void tot_on_ptt_fall(bcpr_engine_t *e, bcpr_device_t *d, unsigned now_us) +{ + if (!d->cfg.tot_enabled || d->tot_tripped) { + return; + } + d->tot_last_off_us = now_us; + d->tot_burst_total++; + if (d->tot_burst_total >= d->cfg.tot_max_bursts) { + tot_trip(e, d, "max_bursts"); + } +} + +static void tot_check_key_duration(bcpr_engine_t *e, bcpr_device_t *d, + unsigned now_us) +{ + unsigned elapsed_us; + unsigned max_us; + + if (!d->cfg.tot_enabled || d->tot_tripped || !d->ptt_was) { + return; + } + if (d->tot_key_start_us == 0u) { + d->tot_key_start_us = now_us; + return; + } + max_us = (unsigned)d->cfg.tot_max_key_ms * 1000u; + elapsed_us = now_us - d->tot_key_start_us; + if (elapsed_us >= max_us) { + bcpr_ser12_force_unkey(&d->ser12); + bcpr_hdlc_abort_tx(&d->hdlc); + tot_on_ptt_fall(e, d, now_us); + tot_trip(e, d, "max_key"); + } +} + +static void on_frame_ctx(const uint8_t *kiss, int len, void *ud) +{ + rx_ctx_t *ctx = (rx_ctx_t *)ud; + if (ctx && ctx->e && ctx->e->on_rx) { + ctx->e->on_rx(ctx->idx, kiss, len, ctx->e->on_rx_ud); + } +} + +void bcpr_engine_set_rx(bcpr_engine_t *e, bcpr_rx_fn fn, void *ud) +{ + if (!e) { + return; + } + e->on_rx = fn; + e->on_rx_ud = ud; +} + +int bcpr_engine_queue_kiss(bcpr_engine_t *e, int dev_idx, const uint8_t *kiss, + int len) +{ + int i; + static unsigned last_tx_us; + unsigned now; + unsigned elapsed; + + if (!e || !kiss) { + return -1; + } + now = now_us(); + if (last_tx_us != 0u) { + elapsed = now - last_tx_us; + if (elapsed < 1500000u) { + usleep(1500000u - elapsed); + } + } + last_tx_us = now_us(); + for (i = 0; i < e->n; i++) { + if (e->dev[i].index == dev_idx) { + if (e->dev[i].tot_tripped) { + fprintf(stderr, "bcpr: bc%d TOT drop queue_kiss (tripped)\n", + dev_idx); + return -1; + } + return bcpr_hdlc_queue_kiss(&e->dev[i].hdlc, kiss, len); + } + } + return -1; +} + +int bcpr_engine_open(bcpr_engine_t *e, const bcpr_config_t *cfg) +{ + int i; + int n = 0; + + if (!e || !cfg) { + return -1; + } + memset(e, 0, sizeof(*e)); + e->cfg = *cfg; + e->stop = 0; + e->run_seconds = 0; + bcpr_uart_set_dry_run(cfg->dry_run); + + for (i = 0; i < BCPR_MAX_DEVICES; i++) { + bcpr_device_t *d; + bcpr_channel_t ch; + unsigned baud = 1200; + int opt_dcd = 0; + + if (!cfg->dev[i].enabled) { + continue; + } + d = &e->dev[n]; + memset(d, 0, sizeof(*d)); + d->cfg = cfg->dev[i]; + d->index = i; + d->running = 0; + + if (bcpr_lock_acquire(&d->lock, &d->cfg, cfg->dry_run) != 0) { + fprintf(stderr, "bcpr: lock failed for max25e0:bc%d\n", i); + bcpr_engine_close(e); + return -1; + } + + bcpr_ser12_set_mode(&d->ser12, d->cfg.mode, &baud); + if (d->cfg.baud) { + baud = d->cfg.baud; + } + opt_dcd = d->ser12.opt_dcd; + bcpr_ser12_init(&d->ser12, baud, opt_dcd); + bcpr_ser12_set_ptt_wd(&d->ser12, d->cfg.ptt_wd, d->cfg.ptt_wd_key_ms, + d->cfg.ptt_wd_pause_ms); + + ch.tx_delay = d->cfg.tx_delay; + ch.tx_tail = d->cfg.tx_tail; + ch.slottime = d->cfg.slottime; + ch.ppersist = d->cfg.ppersist; + ch.fulldup = d->cfg.fulldup; + bcpr_hdlc_init(&d->hdlc, (int)baud, &ch); + + if (!cfg->dry_run) { + if (bcpr_uart_ioperm(d->cfg.iobase, 1) != 0) { + fprintf(stderr, "bcpr: ioperm failed 0x%x\n", d->cfg.iobase); + bcpr_engine_close(e); + return -1; + } + bcpr_uart_set_divisor(d->cfg.iobase, 115200u / 100u / 8u); + bcpr_uart_open_ser12(d->cfg.iobase); + /* + * txd_bias=steady: assert UART break after open (LCR.SB). + * THR framing cannot hold DC-steady TXD; break ≈ TFPCX +12 V. + * Default remains pulse (Sailer THR 0x00). MCR unchanged. + */ + if (d->cfg.txd_bias == BCPR_TXD_STEADY) { + bcpr_uart_set_break(d->cfg.iobase, 1); + d->break_set = 1; + } + } + d->running = 1; + n++; + } + e->n = n; + if (n == 0) { + fprintf(stderr, "bcpr: no enabled devices in config\n"); + return -1; + } + fprintf(stderr, "bcpr: open max25e0 (%d device%s)%s\n", n, + n == 1 ? "" : "s", cfg->dry_run ? " [dry-run]" : ""); + for (i = 0; i < n; i++) { + const bcpr_device_t *d = &e->dev[i]; + fprintf(stderr, + "bcpr: bc%d ptt_wd=%s key_ms=%d pause_ms=%d txd_bias=%s tot=%s " + "max_key_ms=%d max_consecutive=%d max_bursts=%d\n", + d->index, d->cfg.ptt_wd ? "on" : "off", d->cfg.ptt_wd_key_ms, + d->cfg.ptt_wd_pause_ms, + d->cfg.txd_bias == BCPR_TXD_STEADY ? "steady" : "pulse", + d->cfg.tot_enabled ? "on" : "off", d->cfg.tot_max_key_ms, + d->cfg.tot_max_consecutive, d->cfg.tot_max_bursts); + } + return 0; +} + +void bcpr_engine_close(bcpr_engine_t *e) +{ + int i; + if (!e) { + return; + } + e->stop = 1; + for (i = 0; i < e->n; i++) { + bcpr_device_t *d = &e->dev[i]; + if (d->running && !e->cfg.dry_run) { + if (d->break_set) { + bcpr_uart_set_break(d->cfg.iobase, 0); + d->break_set = 0; + } + bcpr_uart_close_ser12(d->cfg.iobase); + (void)bcpr_uart_ioperm(d->cfg.iobase, 0); + } + bcpr_lock_release(&d->lock); + d->running = 0; + } + e->n = 0; +} + +static void try_rt(void) +{ +#if defined(__linux__) + struct sched_param sp; + cpu_set_t set; + int rc; + memset(&sp, 0, sizeof(sp)); + /* Pin pages — fault during PTT = multi-ms TXD gap → pump collapse. */ + if (mlockall(MCL_CURRENT | MCL_FUTURE) != 0) { + fprintf(stderr, "bcpr: mlockall failed errno=%d (page faults risk gaps)\n", + errno); + } + /* Prefer one CPU — migration mid-PTT causes multi-ms gaps. */ + CPU_ZERO(&set); + CPU_SET(0, &set); + if (sched_setaffinity(0, sizeof(set), &set) != 0) { + fprintf(stderr, "bcpr: sched_setaffinity(0) errno=%d\n", errno); + } + /* + * High FIFO while bit-clocking — charge-pump cannot tolerate ms preemption. + * Needs root or CAP_SYS_NICE; log hard if denied (S1++ gaps often follow). + */ + sp.sched_priority = 80; + rc = sched_setscheduler(0, SCHED_FIFO, &sp); + if (rc != 0) { + sp.sched_priority = 50; + rc = pthread_setschedparam(pthread_self(), SCHED_FIFO, &sp); + } + if (rc != 0) { + sp.sched_priority = 10; + rc = pthread_setschedparam(pthread_self(), SCHED_FIFO, &sp); + } + if (rc != 0) { + fprintf(stderr, + "bcpr: SCHED_FIFO failed errno=%d — expect max_gap multi-ms " + "(need root/CAP_SYS_NICE for bcprd)\n", + errno); + } else { + fprintf(stderr, "bcpr: SCHED_FIFO ok prio=%d cpu0\n", sp.sched_priority); + } +#endif +} + +void bcpr_engine_set_cal(bcpr_engine_t *e, int cal_mode) +{ + int i; + if (!e) { + return; + } + if (cal_mode < BCPR_CAL_OFF || cal_mode > BCPR_CAL_ALT) { + cal_mode = BCPR_CAL_OFF; + } + e->cal_mode = cal_mode; + for (i = 0; i < e->n; i++) { + bcpr_ser12_set_cal(&e->dev[i].ser12, cal_mode); + } +} + +static int64_t now_ns(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (int64_t)ts.tv_sec * 1000000000LL + (int64_t)ts.tv_nsec; +} + +static void emit_tx_telemetry(const bcpr_engine_t *e, bcpr_device_t *d, + int64_t ptt_off_ns) +{ + char path[192]; + FILE *f; + int64_t dur_ns; + unsigned mean_gap = 0; + double thr_rate = 0.0; + double ptt_ms; + + if (!d || !e) { + return; + } + dur_ns = ptt_off_ns - d->ptt_on_ns; + if (dur_ns < 0) { + dur_ns = 0; + } + ptt_ms = (double)dur_ns / 1.0e6; + if (d->tick_count > 0) { + mean_gap = (unsigned)(d->gap_sum_us / d->tick_count); + } + if (ptt_ms > 0.5) { + thr_rate = (double)d->thr_writes * 1000.0 / ptt_ms; + } + fprintf(stderr, + "bcpr: tx-telemetry bc%d ptt_ms=%.1f thr_writes=%u thr_rate=%.0f " + "max_tick_gap_us=%u mean_gap_us=%u gaps_gt_2x=%u baud_us=%u\n", + d->index, ptt_ms, d->thr_writes, thr_rate, d->max_tick_gap_us, + mean_gap, d->gaps_gt_2x, d->ser12.baud_us); + if (e->cfg.dry_run || e->cfg.state_dir[0] == '\0') { + return; + } + snprintf(path, sizeof(path), "%s/tx-last-bc%d", e->cfg.state_dir, d->index); + f = fopen(path, "w"); + if (!f) { + return; + } + fprintf(f, + "ptt_on_ns=%lld\nptt_off_ns=%lld\nptt_ms=%.1f\nthr_writes=%u\n" + "thr_rate=%.0f\nmax_tick_gap_us=%u\nmean_gap_us=%u\n" + "gaps_gt_2x=%u\nbaud_us=%u\ntick_count=%u\n", + (long long)d->ptt_on_ns, (long long)ptt_off_ns, ptt_ms, + d->thr_writes, thr_rate, d->max_tick_gap_us, mean_gap, + d->gaps_gt_2x, d->ser12.baud_us, d->tick_count); + fclose(f); +} + +static unsigned tx_baud_div(const bcpr_device_t *d) +{ + unsigned baud = d->ser12.baud ? d->ser12.baud : 1200u; + unsigned div = (115200u / 8u) / baud; + return div ? div : 1u; +} + +static void tick_device(bcpr_engine_t *e, bcpr_device_t *d, rx_ctx_t *ctx) +{ + int cts = 0; + int mcr = 0x0d; + int do_thr = 0; + unsigned t = now_us(); + int ptt; + int keyed = d->ptt_was; /* already in TX — keep path minimal for TXD pump */ + + if (!e->cfg.dry_run && !keyed) { + unsigned char msr = bcpr_uart_msr(d->cfg.iobase); + cts = (msr & 0x10) ? 1 : 0; + if (d->ser12.opt_dcd > 0) { + d->hdlc.dcd = (msr & 0x80) ? 1 : 0; + } else if (d->ser12.opt_dcd < 0) { + d->hdlc.dcd = (msr & 0x80) ? 0 : 1; + } + } + + /* S0: tick-gap while PTT keyed (full monotonic µs; unsigned wrap OK). */ + if (d->ptt_was && d->last_tick_us) { + unsigned gap = t - d->last_tick_us; + unsigned lim2; + if (gap > d->max_tick_gap_us) { + d->max_tick_gap_us = gap; + } + d->gap_sum_us += gap; + d->tick_count++; + lim2 = d->ser12.baud_us * 2u; + if (lim2 < 2u) { + lim2 = 2u; + } + if (gap > lim2) { + d->gaps_gt_2x++; + } + } + d->last_tick_us = t; + + bcpr_ser12_tick(&d->ser12, &d->hdlc, cts, &mcr, &do_thr, t); + ptt = d->ser12.ptt_hw ? 1 : 0; + + if (d->tot_tripped) { + bcpr_ser12_force_unkey(&d->ser12); + bcpr_hdlc_abort_tx(&d->hdlc); + ptt = 0; + mcr = 0x0d; + } else { + tot_check_key_duration(e, d, t); + ptt = d->ser12.ptt_hw ? 1 : 0; + } + + if (ptt && !d->ptt_was) { + tot_on_ptt_rise(e, d, t); + d->ptt_on_ns = now_ns(); + d->thr_writes = 0; + d->max_tick_gap_us = 0; + d->gap_sum_us = 0; + d->tick_count = 0; + d->gaps_gt_2x = 0; + d->last_tick_us = t; + d->tx_div_set = 0; + } else if (!ptt && d->ptt_was) { + if (!d->tot_tripped) { + tot_on_ptt_fall(e, d, t); + } + if (!e->cfg.dry_run) { + /* Match baycom_ser_fdx: idle divisor only on PTT fall. */ + bcpr_uart_set_divisor(d->cfg.iobase, 115200u / 100u / 8u); + d->tx_div_set = 0; + } + emit_tx_telemetry(e, d, now_ns()); + } + d->ptt_was = ptt; + + if (!e->cfg.dry_run) { + /* + * S1++: set baud_uartdiv once on PTT rise (kernel ser12_fdx). + * Re-writing divisor every bit toggles DLAB mid-shift → intermittent + * TXD charge-pump starve while MCR RTS still keys (MCR PASS / no RF). + */ + if (ptt && !d->tx_div_set) { + bcpr_uart_set_divisor(d->cfg.iobase, tx_baud_div(d)); + d->tx_div_set = 1; + } + /* Kernel order: THR 0x00 first (charge-pump), then MCR bit+PTT. + * txd_bias=steady: skip THR — break already holds TXD SPACE; + * pulse is Sailer default (framing edges feed BayCom pump). */ + if (d->cfg.txd_bias == BCPR_TXD_STEADY) { + if (!d->break_set) { + bcpr_uart_set_break(d->cfg.iobase, 1); + d->break_set = 1; + } + if (ptt) { + d->thr_writes++; /* count pump-equivalent ticks for telem */ + } + } else { + if (d->break_set) { + bcpr_uart_set_break(d->cfg.iobase, 0); + d->break_set = 0; + } + if (do_thr) { + bcpr_uart_thr00(d->cfg.iobase); + if (ptt) { + d->thr_writes++; + } + } + } + bcpr_uart_mcr(d->cfg.iobase, (unsigned char)mcr); + } + + /* Defer HDLC RX drain while keyed — keeps bit deadline tight (S1+). */ + if (!ptt) { + ctx->e = e; + ctx->idx = d->index; + bcpr_hdlc_receiver(&d->hdlc, on_frame_ctx, ctx); + } +} + +/* Publish Soft-/hard-DCD for RX-before-TX gates (state_dir/dcd-bcN). + * Also refresh rx-activity-bcN whenever Soft-DCD is asserted so smoke/L3 + * can delete-and-rewait without a permanent false miss when dcd flickers. */ +static void publish_dcd_status(const bcpr_engine_t *e) +{ + int i; + char path[192]; + FILE *f; + + if (!e || e->cfg.dry_run || e->cfg.state_dir[0] == '\0') { + return; + } + for (i = 0; i < e->n; i++) { + const bcpr_device_t *d = &e->dev[i]; + int dcd = d->hdlc.dcd ? 1 : 0; + snprintf(path, sizeof(path), "%s/dcd-bc%d", e->cfg.state_dir, d->index); + f = fopen(path, "w"); + if (f) { + fprintf(f, "dcd=%d\n", dcd); + fclose(f); + } + snprintf(path, sizeof(path), "%s/rx-activity-bc%d", e->cfg.state_dir, + d->index); + f = fopen(path, "w"); + if (f) { + /* Latch: dcd=1 → activity; dcd=0 clears so L3 needs live Soft-DCD. */ + fprintf(f, "rx_activity=%d\n", dcd ? 1 : 0); + fclose(f); + } + } +} + +int bcpr_engine_run(bcpr_engine_t *e) +{ + struct timespec next; + rx_ctx_t ctx; + time_t t0; + unsigned period_ns; + unsigned tick = 0; + int64_t bit_deadline_ns = 0; + + if (!e || e->n <= 0) { + return -1; + } + try_rt(); + t0 = time(NULL); + period_ns = e->dev[0].ser12.baud_us * 1000u; + if (period_ns < 100000u) { + period_ns = 833000u; + } + + clock_gettime(CLOCK_MONOTONIC, &next); + while (!e->stop) { + int i; + int any_ptt = 0; + unsigned baud_us = e->dev[0].ser12.baud_us; + + for (i = 0; i < e->n; i++) { + tick_device(e, &e->dev[i], &ctx); + if (e->dev[i].ser12.ptt_hw || e->dev[i].ptt_was) { + any_ptt = 1; + } + } + /* ~100 ms at 1200 baud — skip file I/O while PTT (stretches TXD gaps). */ + if ((++tick % 120u) == 0u && !any_ptt) { + publish_dcd_status(e); + } + + /* + * S1/S1+: while PTT, absolute bit deadline + busy-spin (not + * nanosleep). THRE wait alone stacks with tick work → ~2× baud gaps. + * Idle RX keeps absolute nanosleep schedule. + */ + if (any_ptt && !e->cfg.dry_run) { + int64_t now; + + if (baud_us < 200u) { + baud_us = 200u; + } + now = now_ns(); + if (bit_deadline_ns == 0 || + now > bit_deadline_ns + (int64_t)baud_us * 1000LL * 4) { + /* PTT edge / large slip — resync. */ + bit_deadline_ns = now + (int64_t)baud_us * 1000LL; + } else { + bit_deadline_ns += (int64_t)baud_us * 1000LL; + } + /* + * Pure busy-spin to absolute bit deadline — no nanosleep, no + * wait_thre syscalls (those stacked gaps and starved the pump). + * Target: thr_writes ≈ baud for whole PTT; max_gap < ~2× baud_us. + */ + while (now_ns() < bit_deadline_ns) { + } + clock_gettime(CLOCK_MONOTONIC, &next); + } else { + bit_deadline_ns = 0; + next.tv_nsec += (long)period_ns; + while (next.tv_nsec >= 1000000000L) { + next.tv_nsec -= 1000000000L; + next.tv_sec++; + } + while (clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &next, + NULL) == EINTR) { + } + } + if (e->run_seconds > 0 && (time(NULL) - t0) >= e->run_seconds) { + break; + } + } + return 0; +} diff --git a/stacks/max25-bcpr/src/bcpr_hdlc.c b/stacks/max25-bcpr/src/bcpr_hdlc.c new file mode 100644 index 0000000..09c8d32 --- /dev/null +++ b/stacks/max25-bcpr/src/bcpr_hdlc.c @@ -0,0 +1,293 @@ +#include "bcpr/bcpr_hdlc.h" +#include "bcpr/bcpr_crc.h" + +#include <string.h> +#include <stdlib.h> + +static int hbuf_empty(const bcpr_hbuf_t *hb) +{ + return hb->rd == hb->wr; +} + +static int hbuf_full(const bcpr_hbuf_t *hb) +{ + return !((BCPR_HDLC_BUF - 1 + hb->rd - hb->wr) % BCPR_HDLC_BUF); +} + +static void hbuf_put(bcpr_hbuf_t *hb, uint16_t val) +{ + unsigned newp = (hb->wr + 1) % BCPR_HDLC_BUF; + if (newp != hb->rd) { + hb->buf[hb->wr] = val; + hb->wr = newp; + } +} + +static uint16_t hbuf_get(bcpr_hbuf_t *hb) +{ + uint16_t val; + if (hb->rd == hb->wr) { + return 0; + } + val = hb->buf[hb->rd]; + hb->rd = (hb->rd + 1) % BCPR_HDLC_BUF; + return val; +} + +#define tenms_to_2flags(h, tenms) (((tenms) * (h)->bitrate) / 100 / 16) + +void bcpr_hdlc_init(bcpr_hdlc_t *h, int bitrate, const bcpr_channel_t *ch) +{ + memset(h, 0, sizeof(*h)); + h->bitrate = bitrate > 0 ? bitrate : 1200; + if (ch) { + h->ch = *ch; + } else { + h->ch.tx_delay = 35; + h->ch.tx_tail = 2; + h->ch.slottime = 10; + h->ch.ppersist = 40; + h->ch.fulldup = 0; + } + h->slotcnt = 1; + h->rx_bp = h->rx_buffer; + h->tx_bp = h->tx_buffer; +} + +void bcpr_hdlc_putbits(bcpr_hdlc_t *h, unsigned bits) +{ + hbuf_put(&h->rx_hbuf, (uint16_t)(bits & 0xffff)); +} + +unsigned bcpr_hdlc_getbits(bcpr_hdlc_t *h) +{ + if (hbuf_empty(&h->tx_hbuf)) { + h->ptt = 0; + return 0; + } + return hbuf_get(&h->tx_hbuf); +} + +int bcpr_hdlc_ptt(const bcpr_hdlc_t *h) +{ + return h->ptt; +} + +void bcpr_hdlc_abort_tx(bcpr_hdlc_t *h) +{ + if (!h) { + return; + } + h->ptt = 0; + h->tx_state = 0; + h->numflags = 0; + h->tx_bitstream = 0; + h->tx_bitbuf = 0; + h->tx_numbits = 0; + h->tx_len = 0; + h->tx_bp = h->tx_buffer; + h->have_pending = 0; + h->pending_len = 0; + h->tx_hbuf.rd = h->tx_hbuf.wr; +} + +int bcpr_hdlc_queue_kiss(bcpr_hdlc_t *h, const uint8_t *kiss, int len) +{ + if (!kiss || len < 2 || len - 1 > BCPR_MAXFLEN) { + return -1; + } + if (h->have_pending || h->ptt) { + return -1; + } + /* strip KISS command byte */ + memcpy(h->pending, kiss + 1, (size_t)(len - 1)); + h->pending_len = len - 1; + h->have_pending = 1; + return 0; +} + +static int hdlc_rx_add_bytes(bcpr_hdlc_t *h, unsigned bits, int num) +{ + int added = 0; + while (h->rx_state && num >= 8) { + if (h->rx_len >= (int)sizeof(h->rx_buffer)) { + h->rx_state = 0; + return 0; + } + *h->rx_bp++ = (uint8_t)(bits >> (32 - num)); + h->rx_len++; + num -= 8; + added += 8; + } + return added; +} + +static void hdlc_rx_flag(bcpr_hdlc_t *h, + void (*on_frame)(const uint8_t *kiss, int len, void *ud), + void *ud) +{ + uint8_t kiss[BCPR_MAXFLEN + 3]; + int pkt_len; + if (h->rx_len < 4) { + return; + } + if (!bcpr_check_crc_ccitt(h->rx_buffer, h->rx_len)) { + return; + } + pkt_len = h->rx_len - 2 + 1; + kiss[0] = 0; + memcpy(kiss + 1, h->rx_buffer, (size_t)(pkt_len - 1)); + if (on_frame) { + on_frame(kiss, pkt_len, ud); + } +} + +void bcpr_hdlc_receiver(bcpr_hdlc_t *h, + void (*on_frame)(const uint8_t *kiss, int len, void *ud), + void *ud) +{ + int i; + unsigned mask1, mask2, mask3, mask4, mask5, mask6, word; + + while (!hbuf_empty(&h->rx_hbuf)) { + word = hbuf_get(&h->rx_hbuf); + h->bitstream >>= 16; + h->bitstream |= word << 16; + h->bitbuf >>= 16; + h->bitbuf |= word << 16; + h->numbits += 16; + for (i = 15, mask1 = 0x1fc00, mask2 = 0x1fe00, mask3 = 0x0fc00, + mask4 = 0x1f800, mask5 = 0xf800, mask6 = 0xffff; + i >= 0; i--, mask1 <<= 1, mask2 <<= 1, mask3 <<= 1, mask4 <<= 1, + mask5 <<= 1, mask6 = (mask6 << 1) | 1) { + if ((h->bitstream & mask1) == mask1) { + h->rx_state = 0; + } else if ((h->bitstream & mask2) == mask3) { + if (h->rx_state) { + hdlc_rx_add_bytes(h, h->bitbuf << (8 + i), + h->numbits - 8 - i); + hdlc_rx_flag(h, on_frame, ud); + } + h->rx_len = 0; + h->rx_bp = h->rx_buffer; + h->rx_state = 1; + h->numbits = i; + } else if ((h->bitstream & mask4) == mask5) { + h->numbits--; + h->bitbuf = (h->bitbuf & (~mask6)) | + ((h->bitbuf & mask6) << 1); + } + } + h->numbits -= hdlc_rx_add_bytes(h, h->bitbuf, h->numbits); + } +} + +void bcpr_hdlc_transmitter(bcpr_hdlc_t *h) +{ + unsigned mask1, mask2, mask3; + int i; + + for (;;) { + if (h->tx_numbits >= 16) { + if (hbuf_full(&h->tx_hbuf)) { + return; + } + hbuf_put(&h->tx_hbuf, (uint16_t)(h->tx_bitbuf & 0xffff)); + h->tx_bitbuf >>= 16; + h->tx_numbits -= 16; + } + switch (h->tx_state) { + default: + return; + case 0: + case 1: + if (h->numflags) { + h->numflags--; + h->tx_bitbuf |= 0x7e7e << h->tx_numbits; + h->tx_numbits += 16; + break; + } + if (h->tx_state == 1) { + return; + } + if (!h->have_pending) { + int flgs = tenms_to_2flags(h, h->ch.tx_tail); + if (flgs < 2) { + flgs = 2; + } + h->tx_state = 1; + h->numflags = flgs; + break; + } + /* pending[] holds up to BCPR_MAXFLEN; reject only oversize / empty. */ + if (h->pending_len > BCPR_MAXFLEN || h->pending_len < 2) { + h->have_pending = 0; + h->tx_state = 0; + h->numflags = 1; + break; + } + memcpy(h->tx_buffer, h->pending, (size_t)h->pending_len); + h->have_pending = 0; + h->tx_bp = h->tx_buffer; + bcpr_append_crc_ccitt(h->tx_buffer, h->pending_len); + h->tx_len = h->pending_len + 2; + h->tx_state = 2; + h->tx_bitstream = 0; + break; + case 2: + if (!h->tx_len) { + h->tx_state = 0; + h->numflags = 1; + break; + } + h->tx_len--; + h->tx_bitbuf |= *h->tx_bp << h->tx_numbits; + h->tx_bitstream >>= 8; + h->tx_bitstream |= (*h->tx_bp++) << 16; + mask1 = 0x1f000; + mask2 = 0x10000; + mask3 = 0xffffffffu >> (31 - h->tx_numbits); + h->tx_numbits += 8; + for (i = 0; i < 8; + i++, mask1 <<= 1, mask2 <<= 1, mask3 = (mask3 << 1) | 1) { + if ((h->tx_bitstream & mask1) != mask1) { + continue; + } + h->tx_bitstream &= ~mask2; + h->tx_bitbuf = (h->tx_bitbuf & mask3) | + ((h->tx_bitbuf & (~mask3)) << 1); + h->tx_numbits++; + mask3 = (mask3 << 1) | 1; + } + break; + } + } +} + +void bcpr_hdlc_arbitrate(bcpr_hdlc_t *h) +{ + if (h->ptt || !h->have_pending) { + return; + } + if (h->ch.fulldup) { + h->tx_state = 0; + h->numflags = tenms_to_2flags(h, h->ch.tx_delay); + h->tx_bitbuf = h->tx_bitstream = 0; + h->tx_numbits = 0; + bcpr_hdlc_transmitter(h); + h->ptt = 1; + return; + } + if (!h->dcd && (--h->slotcnt <= 0)) { + h->slotcnt = h->ch.slottime; + if ((rand() % 256) > h->ch.ppersist) { + return; + } + h->tx_state = 0; + h->numflags = tenms_to_2flags(h, h->ch.tx_delay); + h->tx_bitbuf = h->tx_bitstream = 0; + h->tx_numbits = 0; + bcpr_hdlc_transmitter(h); + h->ptt = 1; + } +} diff --git a/stacks/max25-bcpr/src/bcpr_kiss.c b/stacks/max25-bcpr/src/bcpr_kiss.c new file mode 100644 index 0000000..495f4d1 --- /dev/null +++ b/stacks/max25-bcpr/src/bcpr_kiss.c @@ -0,0 +1,127 @@ +/* + * KISS PTY bridge for max25-bcpr (HyBBX attach via kiss_link symlink). + */ +#define _GNU_SOURCE +#include "bcpr/bcpr_kiss.h" + +#include <errno.h> +#include <fcntl.h> +#include <grp.h> +#include <stdio.h> +#include <string.h> +#include <sys/stat.h> +#include <unistd.h> + +#if defined(__linux__) +#include <pty.h> +#elif defined(__FreeBSD__) +#include <libutil.h> +#else +#include <util.h> +#endif + +static int ensure_dir(const char *path) +{ + char tmp[256]; + char *p; + size_t len; + + if (!path || !path[0]) { + return -1; + } + snprintf(tmp, sizeof(tmp), "%s", path); + len = strlen(tmp); + if (len == 0) { + return -1; + } + if (tmp[len - 1] == '/') { + tmp[len - 1] = '\0'; + } + for (p = tmp + 1; *p; p++) { + if (*p == '/') { + *p = '\0'; + (void)mkdir(tmp, 0755); + *p = '/'; + } + } + return mkdir(tmp, 0755) == 0 || errno == EEXIST ? 0 : -1; +} + +int bcpr_kiss_pty_open(bcpr_kiss_pty_t *kp, int idx, const char *link_path, + const char *state_dir) +{ + int master = -1, slave = -1; + char slave_name[128]; + char dir[128]; + const char *slash; + + memset(kp, 0, sizeof(*kp)); + kp->idx = idx; + kp->master_fd = -1; + kp->slave_fd = -1; + snprintf(kp->link_path, sizeof(kp->link_path), "%s", link_path); + + slash = strrchr(link_path, '/'); + if (slash && slash > link_path) { + size_t n = (size_t)(slash - link_path); + if (n >= sizeof(dir)) { + n = sizeof(dir) - 1; + } + memcpy(dir, link_path, n); + dir[n] = '\0'; + (void)ensure_dir(dir); + } else { + (void)ensure_dir(state_dir); + } + + if (openpty(&master, &slave, slave_name, NULL, NULL) != 0) { + perror("max25-bcpr openpty"); + return -1; + } + if (fchmod(slave, 0660) != 0) { + perror("max25-bcpr fchmod kiss slave"); + } else { + struct group *gr = getgrnam("dialout"); + if (gr == NULL) { + gr = getgrnam("uucp"); + } + if (gr == NULL) { + gr = getgrnam("tty"); + } + if (gr != NULL && fchown(slave, (uid_t)-1, gr->gr_gid) != 0) { + perror("max25-bcpr fchown kiss slave"); + } + } + unlink(link_path); + if (symlink(slave_name, link_path) != 0) { + perror("max25-bcpr symlink kiss_link"); + close(slave); + close(master); + return -1; + } + fcntl(master, F_SETFL, O_NONBLOCK); + kp->master_fd = master; + close(slave); + kp->slave_fd = -1; + fprintf(stderr, "max25-bcpr: max25e0:bc%d KISS → %s -> %s\n", idx, link_path, + slave_name); + return 0; +} + +void bcpr_kiss_pty_close(bcpr_kiss_pty_t *kp) +{ + if (!kp) { + return; + } + if (kp->slave_fd >= 0) { + close(kp->slave_fd); + kp->slave_fd = -1; + } + if (kp->master_fd >= 0) { + close(kp->master_fd); + kp->master_fd = -1; + } + if (kp->link_path[0]) { + unlink(kp->link_path); + } +} diff --git a/stacks/max25-bcpr/src/bcpr_lock.c b/stacks/max25-bcpr/src/bcpr_lock.c new file mode 100644 index 0000000..b179323 --- /dev/null +++ b/stacks/max25-bcpr/src/bcpr_lock.c @@ -0,0 +1,202 @@ +/* + * Exclusive lock on owned COM ports only (flock + optional setserial uart none). + * Pass real IRQ — refuse mismatch. See vault IRQ/lock contract. + */ +#define _GNU_SOURCE +#include "bcpr/bcpr_lock.h" + +#include <errno.h> +#include <fcntl.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/file.h> +#include <sys/stat.h> +#include <sys/types.h> +#include <unistd.h> + +static void tty_basename(const char *serial, char *out, size_t out_sz) +{ + const char *p = strrchr(serial, '/'); + p = p ? p + 1 : serial; + snprintf(out, out_sz, "%s", p); +} + +int bcpr_lock_verify_irq(const char *serial, unsigned expect_irq, + unsigned *got_irq, unsigned *got_io) +{ + char name[64]; + char path[128]; + char buf[64]; + FILE *f; + unsigned irq = 0; + unsigned io = 0; + + if (!serial || !serial[0]) { + return -1; + } + tty_basename(serial, name, sizeof(name)); + snprintf(path, sizeof(path), "/sys/class/tty/%s/irq", name); + f = fopen(path, "r"); + if (f) { + if (fgets(buf, sizeof(buf), f)) { + irq = (unsigned)strtoul(buf, NULL, 0); + } + fclose(f); + } else { + /* Fallback: setserial -g (best-effort). */ + char cmd[256]; + FILE *p; + snprintf(cmd, sizeof(cmd), "setserial -g %s 2>/dev/null", serial); + p = popen(cmd, "r"); + if (!p) { + return -1; + } + if (fgets(buf, sizeof(buf), p)) { + char *ip = strstr(buf, "IRQ: "); + char *pp = strstr(buf, "Port: "); + if (ip) { + irq = (unsigned)strtoul(ip + 5, NULL, 0); + } + if (pp) { + io = (unsigned)strtoul(pp + 6, NULL, 0); + } + } + pclose(p); + } + + snprintf(path, sizeof(path), "/sys/class/tty/%s/iomem_base", name); + f = fopen(path, "r"); + if (f) { + if (fgets(buf, sizeof(buf), f)) { + io = (unsigned)strtoul(buf, NULL, 0); + } + fclose(f); + } + + if (got_irq) { + *got_irq = irq; + } + if (got_io) { + *got_io = io; + } + if (expect_irq == 0) { + return -1; /* never invent; refuse zero when required */ + } + if (irq == 0 || irq != expect_irq) { + return -1; + } + return 0; +} + +static int make_lock_path(const bcpr_dev_config_t *dev, char *out, size_t sz) +{ + char name[64]; + const char *dir = "/var/run/max25-bcpr"; + tty_basename(dev->serial, name, sizeof(name)); + if (name[0] == '\0') { + return -1; + } + snprintf(out, sz, "%s/lock-%s", dir, name); + return 0; +} + +int bcpr_lock_acquire(bcpr_port_lock_t *lk, const bcpr_dev_config_t *dev, + int dry_run) +{ + char lockpath[192]; + unsigned got_irq = 0, got_io = 0; + + if (!lk || !dev) { + return -1; + } + memset(lk, 0, sizeof(*lk)); + snprintf(lk->serial, sizeof(lk->serial), "%s", dev->serial); + lk->iobase = dev->iobase; + lk->irq = dev->irq; + lk->lock_fd = -1; + lk->dry_run = dry_run ? 1 : 0; + + if (dry_run) { + return 0; + } + if (!dev->serial[0] || dev->irq == 0 || dev->iobase == 0) { + return -1; + } + if (bcpr_lock_verify_irq(dev->serial, dev->irq, &got_irq, &got_io) != 0) { +#if defined(__FreeBSD__) + /* + * No Linux sysfs/setserial — operator INI is SSoT for iobase/irq + * (see vault ExSys AM1 map). Skip hard fail when probe unavailable. + */ + got_irq = dev->irq; + got_io = dev->iobase; +#else + fprintf(stderr, + "bcpr: IRQ mismatch or unreadable for %s (expect %u got %u)\n", + dev->serial, dev->irq, got_irq); + return -1; +#endif + } + if (got_io && got_io != dev->iobase) { + fprintf(stderr, + "bcpr: iobase mismatch for %s (expect 0x%x got 0x%x)\n", + dev->serial, dev->iobase, got_io); + return -1; + } + + if (make_lock_path(dev, lockpath, sizeof(lockpath)) != 0) { + return -1; + } + (void)mkdir("/var/run/max25-bcpr", 0755); + lk->lock_fd = open(lockpath, O_RDWR | O_CREAT | O_CLOEXEC, 0644); + if (lk->lock_fd < 0) { + perror("bcpr lock open"); + return -1; + } + if (flock(lk->lock_fd, LOCK_EX | LOCK_NB) != 0) { + fprintf(stderr, "bcpr: port %s already locked\n", dev->serial); + close(lk->lock_fd); + lk->lock_fd = -1; + return -1; + } + + /* Release 8250 claim on this COM only so userspace can bit-bang. */ + { + char cmd[256]; + snprintf(cmd, sizeof(cmd), "setserial %s uart none 2>/dev/null", + dev->serial); + if (system(cmd) == 0) { + lk->uart_released = 1; + } + } + return 0; +} + +void bcpr_lock_release(bcpr_port_lock_t *lk) +{ + if (!lk) { + return; + } + if (lk->dry_run) { + memset(lk, 0, sizeof(*lk)); + lk->lock_fd = -1; + return; + } + if (lk->uart_released && lk->serial[0]) { + char cmd[256]; + /* Restore 16550A claim — best effort. */ + snprintf(cmd, sizeof(cmd), + "setserial %s uart 16550A port 0x%x irq %u 2>/dev/null", + lk->serial, lk->iobase, lk->irq); + (void)system(cmd); + lk->uart_released = 0; + } + if (lk->lock_fd >= 0) { + flock(lk->lock_fd, LOCK_UN); + close(lk->lock_fd); + lk->lock_fd = -1; + } + memset(lk, 0, sizeof(*lk)); + lk->lock_fd = -1; +} diff --git a/stacks/max25-bcpr/src/bcpr_runas.c b/stacks/max25-bcpr/src/bcpr_runas.c new file mode 100644 index 0000000..8c930bf --- /dev/null +++ b/stacks/max25-bcpr/src/bcpr_runas.c @@ -0,0 +1,81 @@ +/* + * Process identity: max25-bcprd runs unprivileged; init drops from root. + */ +#define _GNU_SOURCE +#include "bcpr/bcpr_runas.h" + +#include <errno.h> +#include <grp.h> +#include <pwd.h> +#include <stdio.h> +#include <string.h> +#include <unistd.h> + +int bcpr_runas_refuse_root(int dry_run) +{ + if (geteuid() == 0 && !dry_run) { + fprintf(stderr, + "max25-bcprd: refusing to run as root — use max25-bcprd-init " + "(setuid) for live SER12\n"); + return -1; + } + return 0; +} + +int bcpr_runas_drop(const bcpr_config_t *cfg) +{ + struct passwd *pw; + struct group *gr = NULL; + gid_t gid; + uid_t uid; + const char *name; + + if (!cfg || !cfg->run_user[0]) { + fprintf(stderr, "max25-bcpr: [max25-bcpr] user= required for daemon\n"); + return -1; + } + if (geteuid() != 0) { + fprintf(stderr, "max25-bcpr: privilege drop requires euid=0\n"); + return -1; + } + + pw = getpwnam(cfg->run_user); + if (!pw) { + fprintf(stderr, "max25-bcpr: unknown user=%s\n", cfg->run_user); + return -1; + } + uid = pw->pw_uid; + gid = pw->pw_gid; + name = pw->pw_name; + + if (cfg->run_group[0]) { + gr = getgrnam(cfg->run_group); + if (!gr) { + fprintf(stderr, "max25-bcpr: unknown group=%s\n", cfg->run_group); + return -1; + } + gid = gr->gr_gid; + } + + if (initgroups(name, gid) != 0) { + fprintf(stderr, "max25-bcpr: initgroups(%s) failed: %s\n", name, + strerror(errno)); + return -1; + } + if (setgid(gid) != 0) { + fprintf(stderr, "max25-bcpr: setgid failed: %s\n", strerror(errno)); + return -1; + } + if (setuid(uid) != 0) { + fprintf(stderr, "max25-bcpr: setuid failed: %s\n", strerror(errno)); + return -1; + } + if (setuid(0) == 0) { + fprintf(stderr, "max25-bcpr: privilege drop incomplete\n"); + return -1; + } + + fprintf(stderr, "max25-bcpr: running as uid=%u gid=%u (%s)\n", + (unsigned)uid, (unsigned)gid, name); + return 0; +} diff --git a/stacks/max25-bcpr/src/bcpr_ser12.c b/stacks/max25-bcpr/src/bcpr_ser12.c new file mode 100644 index 0000000..32091e0 --- /dev/null +++ b/stacks/max25-bcpr/src/bcpr_ser12.c @@ -0,0 +1,246 @@ +#include "bcpr/bcpr_ser12.h" + +#include <stdlib.h> +#include <string.h> + +void bcpr_ser12_set_mode(bcpr_ser12_t *s, const char *mode, unsigned *baud_out) +{ + unsigned baud = 1200; + if (mode && strncmp(mode, "ser", 3) == 0) { + unsigned n = (unsigned)strtoul(mode + 3, NULL, 10); + if (n >= 3 && n <= 48) { + baud = n * 100u; + } + } + if (baud_out) { + *baud_out = baud; + } + s->baud = baud; + s->baud_us = 1000000u / baud; + if (mode && strchr(mode, '*')) { + s->opt_dcd = 0; + } else if (mode && strchr(mode, '+')) { + s->opt_dcd = -1; + } else { + s->opt_dcd = 1; + } +} + +void bcpr_ser12_init(bcpr_ser12_t *s, unsigned baud, int opt_dcd) +{ + memset(s, 0, sizeof(*s)); + s->baud = baud ? baud : 1200; + s->baud_us = 1000000u / s->baud; + s->opt_dcd = opt_dcd; + s->shreg = 0x10000; + s->dcd_sum0 = 2; + s->dcd_time = 120; + s->cal_mode = BCPR_CAL_OFF; + /* FlexNet SER12.doc defaults until bcpr_ser12_set_ptt_wd(). */ + s->ptt_wd = 1; + s->ptt_wd_key_us = 14500u * 1000u; + s->ptt_wd_pause_us = 500u * 1000u; +} + +void bcpr_ser12_set_ptt_wd(bcpr_ser12_t *s, int enable, int key_ms, int pause_ms) +{ + if (!s) { + return; + } + s->ptt_wd = enable ? 1 : 0; + if (key_ms < 1000) { + key_ms = 1000; + } + if (pause_ms < 50) { + pause_ms = 50; + } + s->ptt_wd_key_us = (unsigned)key_ms * 1000u; + s->ptt_wd_pause_us = (unsigned)pause_ms * 1000u; + s->ptt_wd_pausing = 0; + s->ptt_wd_phase_start_us = 0; +} + +void bcpr_ser12_force_unkey(bcpr_ser12_t *s) +{ + if (!s) { + return; + } + s->ptt_hw = 0; + s->ptt_wd_pausing = 0; + s->ptt_wd_phase_start_us = 0; + s->txshreg = 0; +} + +void bcpr_ser12_set_cal(bcpr_ser12_t *s, int cal_mode) +{ + if (!s) { + return; + } + if (cal_mode < BCPR_CAL_OFF || cal_mode > BCPR_CAL_ALT) { + cal_mode = BCPR_CAL_OFF; + } + s->cal_mode = cal_mode; + if (cal_mode != BCPR_CAL_OFF) { + s->ptt_hw = 1; + if (cal_mode == BCPR_CAL_HIGH) { + s->tx_bit = 1; + } else if (cal_mode == BCPR_CAL_LOW) { + s->tx_bit = 0; + } else { + s->tx_bit = 0; /* alt starts low, toggles each tick */ + } + s->txshreg = 1; + s->ptt_wd_pausing = 0; + s->ptt_wd_phase_start_us = 0; + } +} + +/* + * FlexNet SER12.doc / PAR96: during long calibrate, drop PTT every ~14.5 s for + * ~500 ms so the radio PTT watchdog can discharge. Mirror for --cal and any + * sustained key. Logical ptt_hw stays 1 (keep baud divisor / telemetry); only + * MCR RTS is cleared for the pause window. + */ +static void ser12_apply_ptt_wd(bcpr_ser12_t *s, int *mcr_out, unsigned now_us) +{ + unsigned elapsed; + + if (!s->ptt_wd || !s->ptt_hw) { + s->ptt_wd_pausing = 0; + s->ptt_wd_phase_start_us = 0; + return; + } + if (s->ptt_wd_phase_start_us == 0) { + s->ptt_wd_phase_start_us = now_us ? now_us : 1u; + s->ptt_wd_pausing = 0; + return; + } + elapsed = now_us - s->ptt_wd_phase_start_us; + if (!s->ptt_wd_pausing) { + if (elapsed >= s->ptt_wd_key_us) { + s->ptt_wd_pausing = 1; + s->ptt_wd_phase_start_us = now_us ? now_us : 1u; + *mcr_out = 0x0d; /* RTS clear — PTT off */ + } + return; + } + /* In pause: force idle MCR regardless of cal/HDLC bit. */ + *mcr_out = 0x0d; + if (elapsed >= s->ptt_wd_pause_us) { + s->ptt_wd_pausing = 0; + s->ptt_wd_phase_start_us = now_us ? now_us : 1u; + /* Next tick restores keyed MCR from cal/HDLC path. */ + } +} + +static void ser12_rx(bcpr_ser12_t *s, bcpr_hdlc_t *h, unsigned curs, + unsigned now_us) +{ + int timediff; + int bdus8 = (int)(s->baud_us >> 3); + int bdus4 = (int)(s->baud_us >> 2); + int bdus2 = (int)(s->baud_us >> 1); + + timediff = (int)(now_us - s->pll_time); + /* wrap handling for us modulo ~1s window */ + while (timediff >= 500000) { + timediff -= 1000000; + } + while (timediff <= -500000) { + timediff += 1000000; + } + while (timediff >= bdus2) { + timediff -= (int)s->baud_us; + s->pll_time += s->baud_us; + s->dcd_time--; + if (s->shreg & 1) { + bcpr_hdlc_putbits(h, (s->shreg >> 1) ^ 0xffffu); + s->shreg = 0x10000; + } + s->shreg >>= 1; + } + if (s->dcd_time <= 0) { + if (!s->opt_dcd) { + h->dcd = (s->dcd_sum0 + s->dcd_sum1 + s->dcd_sum2) < 0; + } + s->dcd_sum2 = s->dcd_sum1; + s->dcd_sum1 = s->dcd_sum0; + s->dcd_sum0 = 2; + s->dcd_time += 120; + } + if (s->last_rxbit != (unsigned char)curs) { + s->last_rxbit = (unsigned char)curs; + s->shreg |= 0x10000; + if (timediff > 0) { + s->pll_time += (unsigned)bdus8; + } else { + s->pll_time += 1000000u - (unsigned)bdus8; + } + if (abs(timediff) > bdus4) { + s->dcd_sum0 += 4; + } else { + s->dcd_sum0--; + } + } + while (s->pll_time >= 1000000u) { + s->pll_time -= 1000000u; + } +} + +void bcpr_ser12_tick(bcpr_ser12_t *s, bcpr_hdlc_t *h, int cts, int *mcr_out, + int *do_thr00, unsigned now_us) +{ + *do_thr00 = 1; + + /* DOS cal.exe: continuous PTT + sticky/toggle DTR; no HDLC. */ + if (s->cal_mode != BCPR_CAL_OFF) { + s->ptt_hw = 1; + if (s->cal_mode == BCPR_CAL_HIGH) { + s->tx_bit = 1; + } else if (s->cal_mode == BCPR_CAL_LOW) { + s->tx_bit = 0; + } else { + s->tx_bit = (unsigned char)!s->tx_bit; + } + *mcr_out = 0x0e | (!!s->tx_bit); + ser12_apply_ptt_wd(s, mcr_out, now_us); + return; + } + + /* + * While PTT: skip Soft-DCD RX PLL — PC-COM charge-pump needs unbroken + * THR 0x00 @ baud; RX work causes multi-ms TXD gaps → underpowered AFSK. + */ + if (!s->ptt_hw) { + ser12_rx(s, h, cts ? 1u : 0u, now_us); + } + + if (s->ptt_hw) { + *mcr_out = 0x0e | (!!s->tx_bit); + } else { + *mcr_out = 0x0d; + } + + if (s->ptt_hw) { + if (s->txshreg <= 1) { + s->txshreg = 0x10000u | bcpr_hdlc_getbits(h); + if (!bcpr_hdlc_ptt(h)) { + s->ptt_hw = 0; + *mcr_out = 0x0d; + ser12_apply_ptt_wd(s, mcr_out, now_us); + return; + } + } + s->tx_bit = (unsigned char)(!(s->tx_bit ^ (s->txshreg & 1))); + s->txshreg >>= 1; + } else { + bcpr_hdlc_arbitrate(h); + if (bcpr_hdlc_ptt(h)) { + s->txshreg = 1; + s->ptt_hw = 1; + } + } + bcpr_hdlc_transmitter(h); + ser12_apply_ptt_wd(s, mcr_out, now_us); + /* Receiver drain is owned by bcpr_engine (RX callback). */ +} diff --git a/stacks/max25-bcpr/src/bcpr_uart.c b/stacks/max25-bcpr/src/bcpr_uart.c new file mode 100644 index 0000000..22ccba3 --- /dev/null +++ b/stacks/max25-bcpr/src/bcpr_uart.c @@ -0,0 +1,251 @@ +/* + * Userspace 8250/16550 register access for SER12 bit-bang. + * Prefer ioperm(2); fall back to /dev/port. Dry-run: no-ops. + * Algorithms match Linux baycom_ser_fdx (see NOTICE.md). + */ +#define _GNU_SOURCE +#include "bcpr/bcpr_uart.h" + +#include <errno.h> +#include <fcntl.h> +#include <stdio.h> +#include <string.h> +#include <sys/types.h> +#include <time.h> +#include <unistd.h> + +#if defined(__linux__) +#include <sys/io.h> +#elif defined(__FreeBSD__) +#include <machine/cpufunc.h> +#include <machine/sysarch.h> +#endif + +#if defined(__FreeBSD__) +static int bcpr_freebsd_set_ioperm(unsigned start, unsigned len, int enable) +{ + struct { + unsigned int start; + unsigned int length; + int enable; + } args; + + args.start = start; + args.length = len; + args.enable = enable; + return sysarch(I386_SET_IOPERM, &args); +} +#endif + +static int g_dry; +static int g_port_fd = -1; +static int g_ioperm_ok; + +void bcpr_uart_set_dry_run(int on) +{ + g_dry = on ? 1 : 0; +} + +int bcpr_uart_ioperm(unsigned iobase, int on) +{ + if (g_dry) { + return 0; + } +#if defined(__linux__) + if (ioperm((unsigned long)iobase, 8, on ? 1 : 0) == 0) { + g_ioperm_ok = on ? 1 : 0; + return 0; + } +#elif defined(__FreeBSD__) + /* + * Prefer /dev/io (process-wide inb/outb). sysarch range is fallback. + * ExSys port: disable matching uart(4) unit (e.g. uart2/cuau2) in + * loader.conf — kernel uart + userspace inb on same iobase → SIGBUS. + */ + if (on) { + if (g_port_fd < 0) { + g_port_fd = open("/dev/io", O_RDWR | O_CLOEXEC); + } + if (g_port_fd >= 0) { + g_ioperm_ok = 1; + return 0; + } + } + if (bcpr_freebsd_set_ioperm((unsigned)iobase, 8, on ? 1 : 0) == 0) { + g_ioperm_ok = on ? 1 : 0; + return 0; + } +#endif + /* Fallback: /dev/io (FreeBSD) or /dev/port (Linux) — needs root. */ + if (on) { + if (g_port_fd < 0) { +#if defined(__FreeBSD__) + g_port_fd = open("/dev/io", O_RDWR | O_CLOEXEC); +#else + g_port_fd = open("/dev/port", O_RDWR | O_CLOEXEC); +#endif + if (g_port_fd >= 0) { + g_ioperm_ok = 1; + return 0; + } + } + if (g_port_fd >= 0) { + return 0; + } + return -1; + } + if (g_port_fd >= 0) { + close(g_port_fd); + g_port_fd = -1; + } + g_ioperm_ok = 0; + return 0; +} + +static int port_rw(unsigned port, int do_write, unsigned char *val) +{ + off_t off = (off_t)port; + if (g_port_fd < 0) { + return -1; + } + if (lseek(g_port_fd, off, SEEK_SET) != off) { + return -1; + } + if (do_write) { + return (write(g_port_fd, val, 1) == 1) ? 0 : -1; + } + return (read(g_port_fd, val, 1) == 1) ? 0 : -1; +} + +void bcpr_uart_outb(unsigned char val, unsigned port) +{ + if (g_dry) { + return; + } + if (g_ioperm_ok) { + outb(val, port); + return; + } + (void)port_rw(port, 1, &val); +} + +unsigned char bcpr_uart_inb(unsigned port) +{ + unsigned char v = 0; + if (g_dry) { + return 0; + } + if (g_ioperm_ok) { + return inb(port); + } + (void)port_rw(port, 0, &v); + return v; +} + +void bcpr_uart_set_divisor(unsigned iobase, unsigned divisor) +{ + unsigned char lcr; + if (g_dry) { + return; + } + lcr = bcpr_uart_inb(iobase + 3); + bcpr_uart_outb((unsigned char)(lcr | 0x80), iobase + 3); /* DLAB */ + bcpr_uart_outb((unsigned char)(divisor & 0xff), iobase + 0); + bcpr_uart_outb((unsigned char)((divisor >> 8) & 0xff), iobase + 1); + bcpr_uart_outb((unsigned char)(lcr & 0x7f), iobase + 3); +} + +void bcpr_uart_open_ser12(unsigned iobase) +{ + if (g_dry) { + return; + } + /* Match baycom_ser_fdx open: FIFO off, 6-bit word, IER THRE+MSR. */ + bcpr_uart_outb(0x00, iobase + 2); /* FCR */ + bcpr_uart_outb(0x01, iobase + 3); /* LCR 6N1 */ + bcpr_uart_outb(0x0a, iobase + 1); /* IER */ + bcpr_uart_outb(0x0d, iobase + 4); /* MCR idle */ + bcpr_uart_thr00(iobase); +} + +void bcpr_uart_close_ser12(unsigned iobase) +{ + if (g_dry) { + return; + } + bcpr_uart_outb(0x00, iobase + 1); /* IER off */ + bcpr_uart_outb(0x01, iobase + 4); /* MCR close */ +} + +unsigned char bcpr_uart_msr(unsigned iobase) +{ + if (g_dry) { + return 0; + } + return bcpr_uart_inb(iobase + 6); +} + +void bcpr_uart_mcr(unsigned iobase, unsigned char v) +{ + if (g_dry) { + return; + } + bcpr_uart_outb(v, iobase + 4); +} + +void bcpr_uart_thr00(unsigned iobase) +{ + if (g_dry) { + return; + } + bcpr_uart_outb(0x00, iobase + 0); +} + +void bcpr_uart_set_break(unsigned iobase, int on) +{ + unsigned char lcr; + if (g_dry) { + return; + } + /* + * 8250 LCR bit6 (Set Break): force TXD to continuous SPACE (RS-232 +V). + * Honest limit: THR writes alone cannot hold DC-steady TXD — each byte is + * framed (start/data/stop). Break is the practical TFPCX-class approximation + * (docs: static ≈ +12 V modem supply). Does not change MCR/PTT. + */ + lcr = bcpr_uart_inb(iobase + 3); + if (on) { + bcpr_uart_outb((unsigned char)(lcr | 0x40), iobase + 3); + } else { + bcpr_uart_outb((unsigned char)(lcr & (unsigned char)~0x40), iobase + 3); + } +} + +int bcpr_uart_wait_thre(unsigned iobase, unsigned timeout_us) +{ + struct timespec t0, now; + unsigned char lsr; + + if (g_dry) { + return 1; + } + if (clock_gettime(CLOCK_MONOTONIC, &t0) != 0) { + return 0; + } + for (;;) { + lsr = bcpr_uart_inb(iobase + 5); + if (lsr & 0x20) { /* THRE */ + return 1; + } + if (clock_gettime(CLOCK_MONOTONIC, &now) != 0) { + return 0; + } + { + long elapsed = (long)(now.tv_sec - t0.tv_sec) * 1000000L + + (now.tv_nsec - t0.tv_nsec) / 1000L; + if (elapsed >= (long)timeout_us) { + return 0; + } + } + } +} diff --git a/stacks/max25-bcpr/src/max25-bcprd-init.c b/stacks/max25-bcpr/src/max25-bcprd-init.c new file mode 100644 index 0000000..6a02240 --- /dev/null +++ b/stacks/max25-bcpr/src/max25-bcprd-init.c @@ -0,0 +1,255 @@ +/* + * max25-bcprd-init — privileged SER12 setup (setuid root), then unprivileged daemon. + * Fork keeps ioperm in child; parent writes pidfile and exits. + */ +#define _GNU_SOURCE +#include "bcpr/bcpr_config.h" +#include "bcpr/bcpr_daemon.h" +#include "bcpr/bcpr_engine.h" +#include "bcpr/bcpr_kiss.h" +#include "bcpr/bcpr_runas.h" + +#include <errno.h> +#include <fcntl.h> +#include <signal.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <strings.h> +#include <sys/stat.h> +#include <sys/wait.h> +#include <unistd.h> + +static void usage(const char *argv0) +{ + fprintf(stderr, + "Usage: %s -c <bcpr.ini> [--once] [--seconds N]\n" + " [--cal high|low|alt] [--txd-bias pulse|steady]\n" + " [--ptt-wd|--no-ptt-wd] [--ptt-wd-key-ms N]\n" + " [--ptt-wd-pause-ms N] [--version]\n" + " Privileged init for live SER12 — drops to [max25-bcpr] user= after setup.\n" + " Dry-run: use max25-bcprd --dry-run (unprivileged).\n", + argv0); +} + +static int ensure_dir(const char *path) +{ + char tmp[256]; + char *p; + size_t len; + + if (!path || !path[0]) { + return -1; + } + snprintf(tmp, sizeof(tmp), "%s", path); + len = strlen(tmp); + if (len == 0) { + return -1; + } + if (tmp[len - 1] == '/') { + tmp[len - 1] = '\0'; + } + for (p = tmp + 1; *p; p++) { + if (*p == '/') { + *p = '\0'; + (void)mkdir(tmp, 0755); + *p = '/'; + } + } + return mkdir(tmp, 0755) == 0 || errno == EEXIST ? 0 : -1; +} + +static int write_pidfile(const char *path, pid_t pid) +{ + FILE *f; + + f = fopen(path, "w"); + if (!f) { + fprintf(stderr, "max25-bcprd-init: cannot write pidfile %s\n", path); + return -1; + } + fprintf(f, "%d\n", (int)pid); + fclose(f); + return 0; +} + +int main(int argc, char **argv) +{ + const char *cfg_path = NULL; + int seconds = 0; + int cal_mode = BCPR_CAL_OFF; + int cli_txd_bias = -1; + int cli_ptt_wd = -1; + int cli_ptt_wd_key_ms = -1; + int cli_ptt_wd_pause_ms = -1; + int i; + bcpr_config_t cfg; + bcpr_engine_t engine; + bcpr_kiss_pty_t ptys[BCPR_MAX_DEVICES]; + int npty = 0; + char pidpath[256]; + pid_t child; + bcpr_daemon_opts_t opts; + + for (i = 1; i < argc; i++) { + if ((strcmp(argv[i], "-c") == 0 || strcmp(argv[i], "--config") == 0) && + i + 1 < argc) { + cfg_path = argv[++i]; + } else if (strcmp(argv[i], "--once") == 0) { + if (seconds <= 0) { + seconds = 1; + } + } else if (strcmp(argv[i], "--seconds") == 0 && i + 1 < argc) { + seconds = atoi(argv[++i]); + } else if (strcmp(argv[i], "--cal") == 0 && i + 1 < argc) { + const char *m = argv[++i]; + if (strcmp(m, "high") == 0 || strcmp(m, "1") == 0) { + cal_mode = BCPR_CAL_HIGH; + } else if (strcmp(m, "low") == 0 || strcmp(m, "2") == 0) { + cal_mode = BCPR_CAL_LOW; + } else if (strcmp(m, "alt") == 0 || strcmp(m, "3") == 0) { + cal_mode = BCPR_CAL_ALT; + } else { + fprintf(stderr, "max25-bcprd-init: --cal needs high|low|alt\n"); + return 2; + } + } else if (strcmp(argv[i], "--txd-bias") == 0 && i + 1 < argc) { + const char *m = argv[++i]; + if (strcasecmp(m, "pulse") == 0 || strcasecmp(m, "sailer") == 0) { + cli_txd_bias = BCPR_TXD_PULSE; + } else if (strcasecmp(m, "steady") == 0 || + strcasecmp(m, "tfpcx") == 0 || + strcasecmp(m, "break") == 0) { + cli_txd_bias = BCPR_TXD_STEADY; + } else { + fprintf(stderr, "max25-bcprd-init: --txd-bias needs pulse|steady\n"); + return 2; + } + } else if (strcmp(argv[i], "--ptt-wd") == 0) { + cli_ptt_wd = 1; + } else if (strcmp(argv[i], "--no-ptt-wd") == 0) { + cli_ptt_wd = 0; + } else if (strcmp(argv[i], "--ptt-wd-key-ms") == 0 && i + 1 < argc) { + cli_ptt_wd_key_ms = atoi(argv[++i]); + } else if (strcmp(argv[i], "--ptt-wd-pause-ms") == 0 && + i + 1 < argc) { + cli_ptt_wd_pause_ms = atoi(argv[++i]); + } else if (strcmp(argv[i], "--version") == 0 || + strcmp(argv[i], "-V") == 0) { + printf("bcprd-init 0.1.0\n"); + return 0; + } else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { + usage(argv[0]); + return 0; + } else { + usage(argv[0]); + return 2; + } + } + if (!cfg_path) { + usage(argv[0]); + return 2; + } + if (geteuid() != 0) { + fprintf(stderr, + "max25-bcprd-init: requires root (setuid) for SER12 lock/ioperm/KISS\n"); + return 1; + } + if (cal_mode != BCPR_CAL_OFF && seconds <= 0) { + seconds = 5; + } + + if (bcpr_config_load(&cfg, cfg_path) != 0) { + fprintf(stderr, "max25-bcprd-init: cannot load %s\n", cfg_path); + return 1; + } + if (cfg.dry_run) { + fprintf(stderr, + "max25-bcprd-init: dry_run=yes — use max25-bcprd --dry-run instead\n"); + return 1; + } + if (!cfg.run_user[0]) { + fprintf(stderr, + "max25-bcprd-init: [max25-bcpr] user= required for privilege drop\n"); + return 1; + } + + for (i = 0; i < BCPR_MAX_DEVICES; i++) { + if (cli_txd_bias >= 0) { + cfg.dev[i].txd_bias = cli_txd_bias; + } + if (cli_ptt_wd >= 0) { + cfg.dev[i].ptt_wd = cli_ptt_wd; + } + if (cli_ptt_wd_key_ms > 0) { + cfg.dev[i].ptt_wd_key_ms = cli_ptt_wd_key_ms; + } + if (cli_ptt_wd_pause_ms > 0) { + cfg.dev[i].ptt_wd_pause_ms = cli_ptt_wd_pause_ms; + } + } + + (void)ensure_dir(cfg.state_dir); + snprintf(pidpath, sizeof(pidpath), "%s/max25-bcprd.pid", cfg.state_dir); + + if (cal_mode == BCPR_CAL_OFF) { + for (i = 0; i < BCPR_MAX_DEVICES; i++) { + if (!cfg.dev[i].enabled) { + continue; + } + if (bcpr_kiss_pty_open(&ptys[npty], i, cfg.dev[i].kiss_link, + cfg.state_dir) != 0) { + while (npty > 0) { + npty--; + bcpr_kiss_pty_close(&ptys[npty]); + } + return 1; + } + npty++; + } + } + + if (bcpr_engine_open(&engine, &cfg) != 0) { + for (i = 0; i < npty; i++) { + bcpr_kiss_pty_close(&ptys[i]); + } + return 1; + } + + child = fork(); + if (child < 0) { + perror("max25-bcprd-init fork"); + bcpr_engine_close(&engine); + for (i = 0; i < npty; i++) { + bcpr_kiss_pty_close(&ptys[i]); + } + return 1; + } + if (child > 0) { + if (write_pidfile(pidpath, child) != 0) { + kill(child, SIGTERM); + waitpid(child, NULL, 0); + return 1; + } + _exit(0); + } + + if (setsid() < 0) { + perror("max25-bcprd-init setsid"); + } + + if (bcpr_runas_drop(&cfg) != 0) { + _exit(1); + } + + memset(&opts, 0, sizeof(opts)); + opts.seconds = seconds; + opts.cal_mode = cal_mode; + opts.cli_txd_bias = cli_txd_bias; + opts.cli_ptt_wd = cli_ptt_wd; + opts.cli_ptt_wd_key_ms = cli_ptt_wd_key_ms; + opts.cli_ptt_wd_pause_ms = cli_ptt_wd_pause_ms; + opts.engine_preopened = 1; + + return bcpr_daemon_run(&cfg, ptys, npty, &engine, &opts); +} diff --git a/stacks/max25-bcpr/src/max25-bcprd.c b/stacks/max25-bcpr/src/max25-bcprd.c new file mode 100644 index 0000000..084c5df --- /dev/null +++ b/stacks/max25-bcpr/src/max25-bcprd.c @@ -0,0 +1,205 @@ +/* + * max25-bcprd — BayCom/based SER12 userspace daemon (unprivileged live path). + * Live SER12: max25-bcprd-init (setuid). Dry-run / offline: this binary. + */ +#define _GNU_SOURCE +#include "bcpr/bcpr_config.h" +#include "bcpr/bcpr_daemon.h" +#include "bcpr/bcpr_kiss.h" +#include "bcpr/bcpr_runas.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <strings.h> +#include <unistd.h> + +static void usage(const char *argv0) +{ + fprintf(stderr, + "Usage: %s -c <bcpr.ini> [--dry-run] [--once] [--seconds N]\n" + " [--cal high|low|alt] [--txd-bias pulse|steady]\n" + " [--ptt-wd|--no-ptt-wd] [--ptt-wd-key-ms N]\n" + " [--ptt-wd-pause-ms N] [--version]\n" + " Host face: max25e0:bc0 / bc1 (KISS PTY via kiss_link)\n" + " Live SER12: max25-bcprd-init (setuid) — this binary refuses root.\n" + " --cal: continuous SER12 tone+PTT (use init for live hardware)\n", + argv0); +} + +int main(int argc, char **argv) +{ + const char *cfg_path = NULL; + int dry = 0; + int seconds = 0; + int cal_mode = BCPR_CAL_OFF; + int cli_txd_bias = -1; + int cli_ptt_wd = -1; + int cli_ptt_wd_key_ms = -1; + int cli_ptt_wd_pause_ms = -1; + int i; + bcpr_config_t cfg; + bcpr_kiss_pty_t ptys[BCPR_MAX_DEVICES]; + int npty = 0; + bcpr_daemon_opts_t opts; + char ver[32] = "0.1.0"; + + for (i = 1; i < argc; i++) { + if ((strcmp(argv[i], "-c") == 0 || strcmp(argv[i], "--config") == 0) && + i + 1 < argc) { + cfg_path = argv[++i]; + } else if (strcmp(argv[i], "--dry-run") == 0) { + dry = 1; + } else if (strcmp(argv[i], "--once") == 0) { + if (seconds <= 0) { + seconds = 1; + } + } else if (strcmp(argv[i], "--seconds") == 0 && i + 1 < argc) { + seconds = atoi(argv[++i]); + } else if (strcmp(argv[i], "--cal") == 0 && i + 1 < argc) { + const char *m = argv[++i]; + if (strcmp(m, "high") == 0 || strcmp(m, "1") == 0) { + cal_mode = BCPR_CAL_HIGH; + } else if (strcmp(m, "low") == 0 || strcmp(m, "2") == 0) { + cal_mode = BCPR_CAL_LOW; + } else if (strcmp(m, "alt") == 0 || strcmp(m, "3") == 0) { + cal_mode = BCPR_CAL_ALT; + } else { + fprintf(stderr, "max25-bcprd: --cal needs high|low|alt\n"); + return 2; + } + } else if (strcmp(argv[i], "--txd-bias") == 0 && i + 1 < argc) { + const char *m = argv[++i]; + if (strcasecmp(m, "pulse") == 0 || strcasecmp(m, "sailer") == 0) { + cli_txd_bias = BCPR_TXD_PULSE; + } else if (strcasecmp(m, "steady") == 0 || + strcasecmp(m, "tfpcx") == 0 || + strcasecmp(m, "break") == 0) { + cli_txd_bias = BCPR_TXD_STEADY; + } else { + fprintf(stderr, "max25-bcprd: --txd-bias needs pulse|steady\n"); + return 2; + } + } else if (strcmp(argv[i], "--ptt-wd") == 0) { + cli_ptt_wd = 1; + } else if (strcmp(argv[i], "--no-ptt-wd") == 0) { + cli_ptt_wd = 0; + } else if (strcmp(argv[i], "--ptt-wd-key-ms") == 0 && i + 1 < argc) { + cli_ptt_wd_key_ms = atoi(argv[++i]); + } else if (strcmp(argv[i], "--ptt-wd-pause-ms") == 0 && + i + 1 < argc) { + cli_ptt_wd_pause_ms = atoi(argv[++i]); + } else if (strcmp(argv[i], "--version") == 0 || + strcmp(argv[i], "-V") == 0) { + FILE *vf = fopen("/usr/local/share/max25/max25-bcpr/VERSION", "r"); + if (!vf) { + vf = fopen("stacks/max25-bcpr/VERSION", "r"); + } + if (vf) { + if (fgets(ver, sizeof(ver), vf)) { + ver[strcspn(ver, "\r\n")] = '\0'; + } + fclose(vf); + } + printf("bcprd %s\n", ver); + return 0; + } else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { + usage(argv[0]); + return 0; + } else { + usage(argv[0]); + return 2; + } + } + if (!cfg_path) { + usage(argv[0]); + return 2; + } + if (cal_mode != BCPR_CAL_OFF && seconds <= 0) { + seconds = 5; + } + + if (bcpr_config_load(&cfg, cfg_path) != 0) { + fprintf(stderr, "max25-bcprd: cannot load %s\n", cfg_path); + return 1; + } + if (dry) { + cfg.dry_run = 1; + } + + if (bcpr_runas_refuse_root(cfg.dry_run) != 0) { + return 1; + } + if (!cfg.dry_run && cal_mode == BCPR_CAL_OFF) { + fprintf(stderr, + "max25-bcprd: live SER12 requires max25-bcprd-init — " + "not this binary alone\n"); + return 1; + } + + { + char self[512]; + ssize_t n = readlink("/proc/self/exe", self, sizeof(self) - 1); + FILE *vf = fopen("/usr/local/share/bcpr/VERSION", "r"); + if (!vf) { + vf = fopen("stacks/max25-bcpr/VERSION", "r"); + } + ver[0] = '\0'; + if (vf) { + if (fgets(ver, sizeof(ver), vf)) { + ver[strcspn(ver, "\r\n")] = '\0'; + } + fclose(vf); + } + if (n > 0) { + self[n] = '\0'; + fprintf(stderr, "max25-bcprd: path=%s version=%s\n", self, + ver[0] ? ver : "0.1.0"); + } else { + fprintf(stderr, "max25-bcprd: version=%s\n", ver[0] ? ver : "0.1.0"); + } + } + + if (cfg.dry_run) { + fprintf(stderr, "max25-bcprd: dry-run (no KISS PTY, no UART I/O)\n"); + } else if (cal_mode == BCPR_CAL_OFF) { + for (i = 0; i < BCPR_MAX_DEVICES; i++) { + if (!cfg.dev[i].enabled) { + continue; + } + if (bcpr_kiss_pty_open(&ptys[npty], i, cfg.dev[i].kiss_link, + cfg.state_dir) != 0) { + while (npty > 0) { + npty--; + bcpr_kiss_pty_close(&ptys[npty]); + } + return 1; + } + npty++; + } + } + if (cal_mode != BCPR_CAL_OFF) { + static const char *cal_names[] = {"off", "high", "low", "alt"}; + fprintf(stderr, + "max25-bcprd: CAL mode=%s seconds=%d (no KISS; SER12 tone+PTT)%s\n", + cal_names[cal_mode], seconds, + cfg.dry_run ? " [dry-run]" : ""); + if (!cfg.dry_run) { + fprintf(stderr, + "max25-bcprd: live cal needs max25-bcprd-init for hardware access\n"); + return 1; + } + } + + memset(&opts, 0, sizeof(opts)); + opts.dry_run = cfg.dry_run; + opts.seconds = seconds; + opts.cal_mode = cal_mode; + opts.cli_txd_bias = cli_txd_bias; + opts.cli_ptt_wd = cli_ptt_wd; + opts.cli_ptt_wd_key_ms = cli_ptt_wd_key_ms; + opts.cli_ptt_wd_pause_ms = cli_ptt_wd_pause_ms; + opts.engine_preopened = 0; + + return bcpr_daemon_run(&cfg, ptys, npty, NULL, &opts); +} diff --git a/stacks/max25-bcpr/tests/test_config_offline.c b/stacks/max25-bcpr/tests/test_config_offline.c new file mode 100644 index 0000000..8b40a6a --- /dev/null +++ b/stacks/max25-bcpr/tests/test_config_offline.c @@ -0,0 +1,53 @@ +/* + * Offline config parse test — no UART. + */ +#include "bcpr/bcpr_config.h" + +#include <stdio.h> +#include <string.h> +#include <unistd.h> + +int main(void) +{ + const char *path = "share/max25-bcpr.ini.example"; + bcpr_config_t cfg; + char cwd[512]; + + if (!getcwd(cwd, sizeof(cwd))) { + return 1; + } + /* Prefer path relative to build or source. */ + if (access(path, R_OK) != 0) { + path = "../share/max25-bcpr.ini.example"; + } + if (access(path, R_OK) != 0) { + path = "../../stacks/max25-bcpr/share/max25-bcpr.ini.example"; + } + if (bcpr_config_load(&cfg, path) != 0) { + /* Write a minimal temp INI. */ + FILE *f = fopen("/tmp/max25-bcpr-test.ini", "w"); + if (!f) { + return 1; + } + fputs("[max25-bcpr]\ndry_run = yes\nstate_dir = /tmp/max25-bcpr\n" + "[bc0]\nserial = /dev/ttyS0\niobase = 0x3f8\nirq = 4\n" + "mode = ser12*\nkiss_link = /tmp/max25-bcpr/kiss-bc0\n", + f); + fclose(f); + path = "/tmp/max25-bcpr-test.ini"; + if (bcpr_config_load(&cfg, path) != 0) { + fprintf(stderr, "FAIL: load\n"); + return 1; + } + } + if (cfg.n_dev < 1 || !cfg.dev[0].enabled) { + fprintf(stderr, "FAIL: no bc0\n"); + return 1; + } + if (cfg.dev[0].irq != 4 || cfg.dev[0].iobase != 0x3f8) { + fprintf(stderr, "FAIL: irq/iobase\n"); + return 1; + } + puts("OK: test_config_offline"); + return 0; +} diff --git a/stacks/max25-bcpr/tests/test_hdlc_offline.c b/stacks/max25-bcpr/tests/test_hdlc_offline.c new file mode 100644 index 0000000..d05d5b6 --- /dev/null +++ b/stacks/max25-bcpr/tests/test_hdlc_offline.c @@ -0,0 +1,110 @@ +/* + * Offline HDLC unit test — CRC, bitstuff roundtrip, no UART / UART I/O. + */ +#include "bcpr/bcpr_crc.h" +#include "bcpr/bcpr_hdlc.h" + +#include <stdio.h> +#include <string.h> + +static int g_got; +static uint8_t g_rx[BCPR_MAXFLEN + 4]; +static int g_rx_len; + +static void on_frame(const uint8_t *kiss, int len, void *ud) +{ + (void)ud; + if (len > 0 && len <= (int)sizeof(g_rx)) { + memcpy(g_rx, kiss, (size_t)len); + g_rx_len = len; + g_got = 1; + } +} + +static int test_crc(void) +{ + uint8_t buf[16]; + memcpy(buf, "TEST", 4); + bcpr_append_crc_ccitt(buf, 4); + if (!bcpr_check_crc_ccitt(buf, 6)) { + fprintf(stderr, "FAIL: CRC check\n"); + return 1; + } + buf[0] ^= 0xff; + if (bcpr_check_crc_ccitt(buf, 6)) { + fprintf(stderr, "FAIL: CRC should fail on corrupt\n"); + return 1; + } + return 0; +} + +static int test_roundtrip(void) +{ + bcpr_hdlc_t tx, rx; + bcpr_channel_t ch = { .tx_delay = 2, .tx_tail = 1, .slottime = 1, + .ppersist = 255, .fulldup = 1 }; + uint8_t kiss[32]; + int i, words = 0; + + bcpr_hdlc_init(&tx, 1200, &ch); + bcpr_hdlc_init(&rx, 1200, &ch); + + kiss[0] = 0; /* KISS DATA */ + memcpy(kiss + 1, "HELLO", 5); + if (bcpr_hdlc_queue_kiss(&tx, kiss, 6) != 0) { + fprintf(stderr, "FAIL: queue\n"); + return 1; + } + bcpr_hdlc_arbitrate(&tx); + if (!bcpr_hdlc_ptt(&tx)) { + fprintf(stderr, "FAIL: PTT not set\n"); + return 1; + } + + g_got = 0; + g_rx_len = 0; + /* Drain TX bit words into RX putbits path. */ + for (i = 0; i < 4096 && !g_got; i++) { + unsigned w; + bcpr_hdlc_transmitter(&tx); + w = bcpr_hdlc_getbits(&tx); + if (w) { + bcpr_hdlc_putbits(&rx, w); + words++; + bcpr_hdlc_receiver(&rx, on_frame, NULL); + } else if (!bcpr_hdlc_ptt(&tx)) { + bcpr_hdlc_transmitter(&tx); + /* flush remaining */ + while (!g_got) { + w = bcpr_hdlc_getbits(&tx); + if (!w) { + break; + } + bcpr_hdlc_putbits(&rx, w); + bcpr_hdlc_receiver(&rx, on_frame, NULL); + } + break; + } + } + + if (!g_got) { + fprintf(stderr, "FAIL: no RX frame (words=%d)\n", words); + return 1; + } + if (g_rx_len < 6 || g_rx[0] != 0 || memcmp(g_rx + 1, "HELLO", 5) != 0) { + fprintf(stderr, "FAIL: payload mismatch len=%d\n", g_rx_len); + return 1; + } + return 0; +} + +int main(void) +{ + int rc = 0; + rc |= test_crc(); + rc |= test_roundtrip(); + if (rc == 0) { + puts("OK: test_hdlc_offline"); + } + return rc; +} diff --git a/stacks/max25-bcpr/tools/NEBENBEI-MOVED.md b/stacks/max25-bcpr/tools/NEBENBEI-MOVED.md new file mode 100644 index 0000000..8a4ac20 --- /dev/null +++ b/stacks/max25-bcpr/tools/NEBENBEI-MOVED.md @@ -0,0 +1,5 @@ +# Dauerlauf helpers — not shipped here + +Experimental endurance / orch helpers are **not** part of this freigegeben product tree. + +Product L1 remains: `max25-bcpr-ctl`, smoke, ultimate-diag · host face **`max25e0`** · public mark **BayCom/based**. diff --git a/stacks/max25-bcpr/tools/bcpr-ultimate-diag.sh b/stacks/max25-bcpr/tools/bcpr-ultimate-diag.sh new file mode 100755 index 0000000..573585c --- /dev/null +++ b/stacks/max25-bcpr/tools/bcpr-ultimate-diag.sh @@ -0,0 +1,916 @@ +#!/usr/bin/env bash +# bcpr-ultimate-diag.sh — interactive BayCom/based (bcpr) TX/RX diagnostic ladder +# Public mark: BayCom/based. Internal path: bcpr. Never Konverter/converter. +# Target: AX25WRK1 intermittent "host keys, RF sometimes" (mic OK 4W; host MCR OK). +# +# Soft-TNC RE (2026-07-19) — operator warnings only (no MCR/code patch here): +# • FlexNet SER12 cal PTT watchdog: ~14.5 s keyed → ~500 ms unkey (discharge). +# Long continuous force-tx/cal: bcprd ptt_wd drops RTS ~500 ms every ~14.5 s +# (FlexNet SER12 mirror; disable with ptt_wd=no / --no-ptt-wd). +# • TXD: default pulse THR 0x00; experiment txd_bias=steady (UART break ≈ TFPCX). +# • Keep bcprd MCR Sailer 0x0e|bit / 0x0d (4PC-COM 0x0A is outlier — do not switch). +# Vault RE: 0-RESEARCHES/projects/max25-stack/2026-07-19-baycom-soft-tnc-serial-ptt-re.md +# +# Usage (Cursor IDE terminal, as operator): +# sudo -n /home/akb/Code/10-PROJECTS/MAX25-Stack/stacks/bcpr/tools/bcpr-ultimate-diag.sh +# BCPR_INI=/etc/max25/bcpr.ini ./bcpr-ultimate-diag.sh -c /etc/max25/bcpr.ini +# ./bcpr-ultimate-diag.sh --help +# ./bcpr-ultimate-diag.sh --all # run phases 1–9 with pauses +# ./bcpr-ultimate-diag.sh --menu # interactive menu (default) +# +# Needs: /usr/bin/sudo -n for ioport / live smoke / cal. Does NOT change MCR code. +# Does NOT use USB as product TX path. Does NOT kill max25d carelessly. +# +# Vault: 0-RESEARCHES/projects/max25-stack/2026-07-19-bcpr-ultimate-diag-script.md +# Relies on: bcpr-ctl, bcpr-rxtx-smoke.sh (same directory). + +# Note: interactive prompts use read; keep pipefail but relax -e around optional probes. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." 2>/dev/null && pwd || true)" +CTL="${SCRIPT_DIR}/bcpr-ctl" +SMOKE_SH="${SCRIPT_DIR}/bcpr-rxtx-smoke.sh" +INI="${BCPR_INI:-/etc/max25/bcpr.ini}" +SUDO="/usr/bin/sudo" +LOG="" +MODE="menu" # menu | all | phaseN +PHASE_ONLY="" +RUN_TS="" + +# Result arrays for summary (parallel indices) +declare -a RES_PHASE=() +declare -a RES_HOST=() +declare -a RES_WATT=() +declare -a RES_BLED=() +declare -a RES_RDISP=() +declare -a RES_NOTE=() + +usage() { + cat <<'USAGE' +bcpr-ultimate-diag.sh — BayCom/based (bcpr) interactive diagnostic / force-TX ladder + +Usage: + bcpr-ultimate-diag.sh [-c INI] [--menu|--all|--phase N] [--help] + +Options: + -c INI bcpr.ini (default: $BCPR_INI or /etc/max25/bcpr.ini) + --menu interactive German menu (default) + --all run phases 1–9 linearly with pauses + RF prompts + --phase N run a single phase (1–9, or 0=restart menu) + -h, --help this help + +Environment: + BCPR_INI same as -c + BCPRD optional path to bcprd binary + +Phases: + 1 Preflight (ttyS0, fuser, dosbox-x, bcprd/max25d, INI, locks) + 2 Idle MCR sample via /dev/port + 3 bcpr-ctl status + telem (tx-last / dcd / rx-activity) + 4 RX listen (soft-DCD) — open SQ reminder + 5 Force-TX ladder 1s / 3s / 5s / 8s + wattmeter/LED/display prompts + 6 Cal/high (bcprd --cal high) if binary supports it + 7 Double-burst / back-to-back TX + 8 Contact hunt (DE-9 + 3.5mm) + 5s force-tx + wiggle + 9 Summary table + verdict hints + 0 Optional safe bcprd restart (bcpr-ctl stop/start only) + U USB note (SER12 unsupported on USB — not product TX) + +Log: /tmp/bcpr-ultimate-diag-YYYYMMDD-HHMMSS.log + +Examples: + sudo -n stacks/bcpr/tools/bcpr-ultimate-diag.sh --all + BCPR_INI=/etc/max25/bcpr.ini sudo -n ./bcpr-ultimate-diag.sh --menu +USAGE +} + +# ---------- logging / UI ---------- + +log() { + local line + line="$(printf '%s' "$*")" + printf '%s\n' "$line" + if [[ -n "${LOG:-}" ]]; then + printf '%s\n' "$line" >>"$LOG" + fi +} + +log_raw() { + # stdin → stdout + log + if [[ -n "${LOG:-}" ]]; then + tee -a "$LOG" + else + cat + fi +} + +banner() { + log "" + log "════════════════════════════════════════════════════════════" + log "$*" + log "════════════════════════════════════════════════════════════" +} + +pause_enter() { + local msg="${1:-Weiter mit Enter …}" + printf '\n%s ' "$msg" >/dev/tty + # shellcheck disable=SC2034 + local _dummy + read -r _dummy </dev/tty || true + log "[pause] $msg" +} + +ask() { + # ask VAR "prompt" + local __var="$1" + local __prompt="$2" + local __ans="" + printf '%s ' "$__prompt" >/dev/tty + read -r __ans </dev/tty || true + printf -v "$__var" '%s' "$__ans" + log "[antwort] $__prompt → ${__ans}" +} + +ask_yn() { + # ask_yn VAR "prompt" → stores j/n/y/n normalized to j|n + local __var="$1" + local __prompt="$2" + local __ans="" + while true; do + printf '%s [j/n]: ' "$__prompt" >/dev/tty + read -r __ans </dev/tty || true + case "${__ans,,}" in + j|ja|y|yes) printf -v "$__var" 'j'; log "[antwort] $__prompt → j"; return 0 ;; + n|nein|no) printf -v "$__var" 'n'; log "[antwort] $__prompt → n"; return 0 ;; + *) printf 'Bitte j oder n.\n' >/dev/tty ;; + esac + done +} + +record_result() { + local phase="$1" host="$2" watt="$3" bled="$4" rdisp="$5" note="${6:-}" + RES_PHASE+=("$phase") + RES_HOST+=("$host") + RES_WATT+=("$watt") + RES_BLED+=("$bled") + RES_RDISP+=("$rdisp") + RES_NOTE+=("$note") +} + +need_sudo() { + if [[ ! -x "$SUDO" ]]; then + log "FEHLER: $SUDO fehlt — Live-Phasen brauchen sudo." + return 1 + fi + if ! "$SUDO" -n true 2>/dev/null; then + log "FEHLER: sudo -n fehlgeschlagen." + log " Bitte einmalig: sudo -v (oder NOPASSWD für diesen User)" + log " Dann Skript erneut starten." + return 1 + fi + return 0 +} + +run_sudo() { + need_sudo || return 1 + "$SUDO" -n "$@" +} + +ini_get() { + local section="$1" key="$2" + [[ -f "$INI" ]] || return 0 + awk -F= -v s="[$section]" -v k="$key" ' + $0 ~ /^\[/ { cur=$0; gsub(/[[:space:]]/,"",cur) } + cur==s && $1 ~ "^[[:space:]]*"k"[[:space:]]*$" { + v=$2; gsub(/^[[:space:]]+|[[:space:]]+$/,"",v); print v; exit + }' "$INI" 2>/dev/null || true +} + +resolve_bcprd() { + if [[ -n "${BCPRD:-}" && "$BCPRD" != "bcprd" && -x "$BCPRD" ]]; then + printf '%s\n' "$BCPRD"; return 0 + fi + if command -v bcprd >/dev/null 2>&1; then + command -v bcprd; return 0 + fi + local cand + for cand in \ + "${BCPR_BUILD_DIR:-}/bin/bcprd" \ + "${ROOT}/build-bcpr/bin/bcprd" \ + "${ROOT}/build-bcpr-${USER:-user}/bin/bcprd" \ + "/tmp/max25-build-bcpr-${USER:-user}/bin/bcprd" \ + "${ROOT}/build/bin/bcprd" \ + /usr/local/bin/bcprd /usr/bin/bcprd; do + [[ -n "$cand" && -x "$cand" ]] && { printf '%s\n' "$cand"; return 0; } + done + return 1 +} + +serial_dev() { + local s + s="$(ini_get bc0 serial)" + printf '%s\n' "${s:-/dev/ttyS0}" +} + +state_dir() { + local sd + sd="$(ini_get bcpr state_dir)" + printf '%s\n' "${sd:-/tmp/bcpr}" +} + +iobase_val() { + local b + b="$(ini_get bc0 iobase)" + printf '%s\n' "${b:-0x3f8}" +} + +ensure_tools() { + local err=0 + if [[ ! -x "$CTL" ]]; then + log "FEHLER: bcpr-ctl fehlt: $CTL"; err=1 + fi + if [[ ! -x "$SMOKE_SH" ]]; then + log "FEHLER: bcpr-rxtx-smoke.sh fehlt: $SMOKE_SH"; err=1 + fi + return "$err" +} + +# ---------- Phase helpers ---------- + +# Last smoke host result (PASS|FAIL) — do not capture function stdout (log noise). +_LAST_HOST="—" + +smoke_force_tx() { + # smoke_force_tx SECONDS → sets _LAST_HOST + local secs="$1" + local listen=$((secs + 8)) + local rc=0 + [[ "$listen" -lt 12 ]] && listen=12 + log "→ L4 force-tx ${secs}s (smoke --live --tx --force-tx --tx-seconds ${secs})" + run_sudo "$CTL" -c "$INI" smoke --live --tx --force-tx --seconds "$listen" --tx-seconds "$secs" 2>&1 | log_raw + rc=${PIPESTATUS[0]} + if [[ "$rc" -eq 0 ]]; then + _LAST_HOST="PASS" + log "HOST: PASS (MCR keyed ~${secs}s target)" + else + _LAST_HOST="FAIL" + log "HOST: FAIL (smoke rc=$rc)" + fi +} + +prompt_rf_obs() { + # prompt_rf_obs PHASE_LABEL HOST_RESULT + # sets globals: _watt _bled _rdisp _extra + local label="$1" host="$2" + local watt bled rdisp extra + log "" + log "--- RF-Beobachtung: $label (Host=$host) ---" + log "Bitte Wattmeter / Board-LED / Radio-Display während des Keys prüfen." + ask watt "Wattmeter W (Zahl oder 0 / ?):" + ask_yn bled "Board-LED (PC-COM) an während Key?" + ask_yn rdisp "Radio-Display / TX-Anzeige an?" + ask extra "Kurznotiz (Enter = keine):" + _watt="$watt" + _bled="$bled" + _rdisp="$rdisp" + _extra="$extra" + record_result "$label" "$host" "$watt" "$bled" "$rdisp" "$extra" +} + +# ---------- Phases ---------- + +phase_preflight() { + banner "Phase 1 — Preflight" + local serial sd iobase irq fulldup dry + serial="$(serial_dev)" + sd="$(state_dir)" + iobase="$(iobase_val)" + irq="$(ini_get bc0 irq)" + fulldup="$(ini_get bc0 fulldup)" + dry="$(ini_get bcpr dry_run)" + + log "INI: $INI" + if [[ ! -f "$INI" ]]; then + log "FEHLER: INI fehlt: $INI" + log " Hinweis: Beispiel → stacks/bcpr/share/bcpr.ini.example → /etc/max25/bcpr.ini" + return 1 + fi + + log "--- INI Auszug [bcpr]/[bc0] ---" + log " dry_run=${dry:-?} state_dir=${sd}" + log " serial=${serial} iobase=${iobase} irq=${irq:-?} fulldup=${fulldup:-?}" + log " mode=$(ini_get bc0 mode) kiss_link=$(ini_get bc0 kiss_link)" + log " baud=$(ini_get bc0 baud) tx_delay=$(ini_get bc0 tx_delay)" + + log "--- Seriell ---" + if [[ -e "$serial" ]]; then + log "OK: $serial existiert" + ls -l "$serial" 2>&1 | log_raw || true + else + log "FEHLER: $serial fehlt" + fi + + log "--- fuser / lsof (wer hält Port?) ---" + if command -v fuser >/dev/null 2>&1; then + + run_sudo fuser -v "$serial" 2>&1 | log_raw + + else + log "WARN: fuser nicht installiert" + fi + if command -v lsof >/dev/null 2>&1; then + + run_sudo lsof "$serial" 2>&1 | log_raw + + fi + + log "--- dosbox-x / DOS-Gast ---" + if pgrep -a -f 'dosbox-x|dosbox' 2>/dev/null | log_raw; then + log "WARN: dosbox läuft — kann ttyS0/USB belegen (sniff/passthrough)." + else + log "OK: kein dosbox/dosbox-x Prozess" + fi + + log "--- Prozesse bcprd / max25d ---" + pgrep -a -x bcprd 2>/dev/null | log_raw || log " bcprd: nicht laufend" + pgrep -a -x max25d 2>/dev/null | log_raw || log " max25d: nicht laufend" + # wrapper scripts + pgrep -a -f 'run-max25d|max25d' 2>/dev/null | head -20 | log_raw || true + + log "--- Lock / State ($sd) ---" + if [[ -d "$sd" ]]; then + ls -la "$sd" 2>&1 | log_raw || true + for f in "$sd"/lock* "$sd"/*.lock "$sd"/bcprd.pid; do + [[ -e "$f" ]] || continue + log " lock/pid: $f" + [[ -f "$f" ]] && { log " content:"; cat "$f" 2>&1 | log_raw || true; } + done + else + log "WARN: state_dir fehlt: $sd" + fi + + log "--- Kernel baycom_* Module (sollten NICHT geladen sein) ---" + if command -v lsmod >/dev/null 2>&1; then + lsmod 2>/dev/null | awk '/^baycom_/ {print}' | log_raw || log "OK: keine baycom_* Module" + fi + + log "--- setserial (falls vorhanden) ---" + local ss + for ss in /usr/bin/setserial /bin/setserial /sbin/setserial /usr/sbin/setserial; do + if [[ -x "$ss" ]]; then + + run_sudo "$ss" -g "$serial" 2>&1 | log_raw + + break + fi + done + + log "--- bcpr-ctl preflight ---" + + run_sudo "$CTL" -c "$INI" preflight 2>&1 | log_raw + local pf=$? + + if [[ "$pf" -eq 0 ]]; then + log "OK: preflight PASS" + else + log "WARN: preflight rc=$pf (Port busy wenn Stack läuft — normal bei live max25d/bcprd)" + fi + + log "--- Tools ---" + log " CTL=$CTL" + log " SMOKE=$SMOKE_SH" + local bin + if bin="$(resolve_bcprd)"; then + log " bcprd=$bin" + else + log " WARN: bcprd Binary nicht gefunden (Build: -DMAX25_BUILD_BCPR=ON)" + fi + + pause_enter "Phase 1 fertig — Enter für weiter …" + return 0 +} + +phase_idle_mcr() { + banner "Phase 2 — Idle MCR Sample (/dev/port)" + local iobase mcr_off + iobase="$(iobase_val)" + # MCR = iobase+4 + mcr_off=$((iobase + 4)) + log "iobase=$iobase MCR=$(printf '0x%x' "$mcr_off") (iobase+4)" + log "Erwartung Idle (Sailer/bcpr): oft 0x0d (DTR+OUT2+RTS-clear) — Werte nur Info." + + if ! need_sudo; then + log "überspringe MCR-Sample (kein sudo)" + pause_enter + return 0 + fi + + run_sudo python3 - "$iobase" <<'PY' 2>&1 | log_raw +import os, sys, time +iobase = int(sys.argv[1], 0) +mcr = iobase + 4 +fd = os.open("/dev/port", os.O_RDONLY) +try: + vals = [] + for i in range(20): + os.lseek(fd, mcr, os.SEEK_SET) + v = ord(os.read(fd, 1)) + vals.append(v) + time.sleep(0.05) + uniq = sorted(set(vals)) + print("Idle MCR samples (20×50ms): " + ",".join("0x%02x" % v for v in vals)) + print("Unique: " + ",".join("0x%02x" % v for v in uniq)) + rts = [(v & 0x02) != 0 for v in vals] + print("RTS asserted in any sample: %s" % ("YES" if any(rts) else "no")) + if all(v == 0 for v in vals): + print("WARN: all-zero — /dev/port may be inaccessible or wrong iobase") +finally: + os.close(fd) +PY + local rc=$? + + if [[ "$rc" -ne 0 ]]; then + log "WARN: Idle-MCR Sample fehlgeschlagen (rc=$rc) — CAP_SYS_RAWIO / iobase prüfen" + fi + pause_enter "Phase 2 fertig — Enter …" + return 0 +} + +phase_status() { + banner "Phase 3 — Status + Telemetrie" + + run_sudo "$CTL" -c "$INI" status 2>&1 | log_raw + + local sd + sd="$(state_dir)" + log "--- Telemetrie unter $sd ---" + for f in "$sd"/tx-last-bc* "$sd"/dcd-bc* "$sd"/rx-activity-bc* "$sd"/kiss-bc*; do + [[ -e "$f" ]] || continue + log "FILE: $f" + if [[ -L "$f" ]]; then + log " symlink → $(readlink -f "$f" 2>/dev/null || readlink "$f")" + elif [[ -f "$f" ]]; then + cat "$f" 2>&1 | log_raw || true + fi + done + pause_enter "Phase 3 fertig — Enter …" + return 0 +} + +phase_rx_listen() { + banner "Phase 4 — RX Listen (soft-DCD)" + log "OPERATOR: Squellch öffnen / Rauschen/SQ so dass Soft-DCD aktiv werden kann." + log " (§0.20 RX before TX — hier nur Listen, kein TX)" + pause_enter "SQ bereit? Enter startet ~12s RX listen …" + + run_sudo "$CTL" -c "$INI" smoke --live --seconds 12 2>&1 | log_raw + local rc=${PIPESTATUS[0]} + + if [[ "$rc" -eq 0 ]]; then + log "OK: RX-Listen smoke beendet (rc=0)" + else + log "WARN: RX-Listen smoke rc=$rc (siehe Log; Stack/INI prüfen)" + fi + + local sd + sd="$(state_dir)" + for f in "$sd"/dcd-bc0 "$sd"/rx-activity-bc0; do + [[ -f "$f" ]] || continue + log "Nach RX: $f" + cat "$f" 2>&1 | log_raw || true + done + pause_enter "Phase 4 fertig — Enter …" + return 0 +} + +phase_force_tx_ladder() { + banner "Phase 5 — Force-TX Ladder (1 / 3 / 5 / 8 s)" + log "WARNUNG: --force-tx ohne RX-Nachweis (§0.20 Override) — Debug / Intermittent-Hunt." + log "Wattmeter bereithalten. USB-Pfad ist KEIN Produkt-TX." + log "" + log "PTT-WATCHDOG (FlexNet SER12 / WD-Boards):" + log " Hardware kann PTT nach ~14,5 s Dauer-Key abwerfen (Discharge ~500 ms)." + log " Bei langen Läufen / vielen Keys hintereinander: zwischendurch UNKEY / Pause ~14 s." + log " Sonst: RF fällt trotz Host-MCR PASS (Watchdog, kein UART-Fehler)." + log "" + log "BEOBACHTUNGSHINWEIS TXD (Charge-Pump):" + log " bcprd/Sailer: THR 0x00 gepulst · TFPCX: TXD oft steady +12 V." + log " Notiz wenn RF mitten im Key einbricht / flackert (Pump/Bias-Kandidat)." + pause_enter "Bereit für Ladder? Enter …" + + local secs host + for secs in 1 3 5 8; do + banner "Force-TX ${secs}s" + pause_enter "Wattmeter beobachten — Enter startet ${secs}s Key …" + smoke_force_tx "$secs" + host="$_LAST_HOST" + # show telem snapshot + local sd tl + sd="$(state_dir)" + tl="$sd/tx-last-bc0" + if [[ -f "$tl" ]]; then + log "--- tx-last-bc0 nach ${secs}s ---" + cat "$tl" 2>&1 | log_raw || true + fi + prompt_rf_obs "L4-${secs}s" "$host" + pause_enter "Nächste Stufe — Enter …" + done + return 0 +} + +phase_cal_high() { + banner "Phase 6 — Cal/high (bcprd --cal high)" + local bin + if ! bin="$(resolve_bcprd)"; then + log "WARN: bcprd nicht gefunden — Cal übersprungen" + pause_enter + return 0 + fi + + # Prefer strings(1); never start bare bcprd. Source contract: --cal high|low|alt. + if command -v strings >/dev/null 2>&1 && ! strings "$bin" 2>/dev/null | grep -qF -- '--cal'; then + log "WARN: strings fand kein --cal in Binary — Phase trotzdem anbieten (Operator kann abbrechen)" + fi + + log "bcprd=$bin" + log "Cal = kontinuierlicher SER12 Tone+PTT (DOS cal.exe Stil), kein KISS." + log "Sicher: nur bcprd stoppen via bcpr-ctl — max25d Wrapper NICHT hart killen." + log "" + log "PTT-WATCHDOG: FlexNet cal pausiert alle ~14,5 s für ~500 ms (PTT-Discharge)." + log " Dieses Skript: --cal high ~10 s (unter WD). Längere manuelle Cal/" + log " Dauer-TX: UNKEY/Pause ~14 s einplanen — sonst RF-Tot auf WD-Boards." + log "TXD: bcprd pulst THR 0x00 (nicht TFPCX-steady) — RF-Einbruch mid-cal notieren." + log "" + local do_cal + ask_yn do_cal "bcprd stoppen und --cal high ~10s starten?" + if [[ "$do_cal" != "j" ]]; then + log "Cal übersprungen (Operator)" + pause_enter + return 0 + fi + + if ! need_sudo; then + pause_enter + return 0 + fi + + log "→ bcpr-ctl stop" + + run_sudo "$CTL" -c "$INI" stop 2>&1 | log_raw + + sleep 1 + + # Ensure no stray bcprd + if pgrep -x bcprd >/dev/null 2>&1; then + log "WARN: bcprd noch aktiv nach stop — pkill -x bcprd (kein max25d)" + + run_sudo pkill -x bcprd 2>&1 | log_raw + + sleep 1 + fi + + log "→ $bin -c $INI --cal high --seconds 10" + pause_enter "Wattmeter bereit — Enter startet cal high 10s …" + + run_sudo "$bin" -c "$INI" --cal high --seconds 10 2>&1 | log_raw + local rc=$? + + local host="PASS" + [[ "$rc" -eq 0 ]] || host="FAIL(rc=$rc)" + prompt_rf_obs "CAL-high-10s" "$host" + + local restart + ask_yn restart "bcprd danach wieder starten (bcpr-ctl start)?" + if [[ "$restart" == "j" ]]; then + + run_sudo "$CTL" -c "$INI" start 2>&1 | log_raw + + log "Hinweis: wenn max25d den Stack besitzt, ggf. max25d neu starten (Operator, nicht dieses Skript)." + fi + pause_enter "Phase 6 fertig — Enter …" + return 0 +} + +phase_double_burst() { + banner "Phase 7 — Double Burst (back-to-back TX)" + log "Zwei Force-TX 3s hintereinander (kurze Pause dazwischen)." + pause_enter "Enter startet Burst A …" + local host_a host_b + smoke_force_tx 3 + host_a="$_LAST_HOST" + sleep 1 + log "Burst B …" + smoke_force_tx 3 + host_b="$_LAST_HOST" + prompt_rf_obs "DoubleBurst-A+B" "${host_a}/${host_b}" + pause_enter "Phase 7 fertig — Enter …" + return 0 +} + +phase_contact_hunt() { + banner "Phase 8 — Contact Hunt (DE-9 + 3.5mm)" + log "OPERATOR-CHECKLISTE:" + log " 1) DE-9 (PC-COM ↔ Host) und 3.5mm (Modem ↔ Radio) fest stecken" + log " 2) Während dem nächsten Key leicht wackeln (Stecker/Kabel)" + log " 3) Wattmeter + Board-LED + Radio-Display beobachten" + log " Ziel: intermittenter Kontakt vs. dauerhaft tot unterscheiden" + pause_enter "Stecker fest? Enter startet 5s force-tx (währenddessen wackeln) …" + + local host + smoke_force_tx 5 + host="$_LAST_HOST" + local obs + ask obs "Beobachtung während Wackeln (z.B. 'kurz 2W' / 'immer 0' / 'LED flackert'):" + prompt_rf_obs "Contact-5s" "$host" + # overwrite last note with wiggle observation if empty note + if [[ -n "$obs" && ${#RES_NOTE[@]} -gt 0 ]]; then + RES_NOTE[$((${#RES_NOTE[@]} - 1))]="$obs | ${_extra:-}" + log "[contact-note] $obs" + fi + pause_enter "Phase 8 fertig — Enter …" + return 0 +} + +phase_usb_note() { + banner "Hinweis U — USB SER12 (kein Produkt-TX)" + log "USB-UART (z.B. /dev/ttyUSB0 / FTDI) ist KEIN unterstützter bcprd SER12-Pfad." + log " bcprd braucht ioperm|/dev/port + echte ISA/LPC iobase — USB hat das nicht." + log " Station: RF nur ttyS0+CB; USB0 = Modem ohne Radio → Wattmeter ungültig." + log " DOSBox Soft-TNC auf USB ≠ bcpr Produkt-TX." + pause_enter + return 0 +} + +phase_restart_menu() { + banner "Optional 0 — Sicheres bcprd Restart" + log "Methode (aus bcpr-ctl): stop → start. Killt NICHT max25d." + log "Wenn max25d den Stack besitzt: nach stop/start ggf. max25d-seitig neu binden." + log "" + log " Aktueller Status:" + + run_sudo "$CTL" -c "$INI" status 2>&1 | log_raw + + local act + ask act "Aktion: [s]top / [t]start / [r]estart / [a]bbruch:" + case "${act,,}" in + s|stop) + run_sudo "$CTL" -c "$INI" stop 2>&1 | log_raw + ;; + t|start) + run_sudo "$CTL" -c "$INI" start 2>&1 | log_raw + ;; + r|restart|re) + run_sudo "$CTL" -c "$INI" stop 2>&1 | log_raw + sleep 1 + run_sudo "$CTL" -c "$INI" start 2>&1 | log_raw + ;; + *) + log "Abbruch — keine Änderung" + ;; + esac + + run_sudo "$CTL" -c "$INI" status 2>&1 | log_raw + + pause_enter + return 0 +} + +phase_summary() { + banner "Phase 9 — Summary + Verdict" + log "Logdatei: $LOG" + log "" + log "┌──────────────────┬──────────┬──────────┬──────────┬──────────┐" + log "│ Phase │ Host │ Watt W │ BoardLED │ RadioTX │" + log "├──────────────────┼──────────┼──────────┼──────────┼──────────┤" + local i n + n=${#RES_PHASE[@]} + if [[ "$n" -eq 0 ]]; then + log "│ (keine TX-Phasen aufgezeichnet) │" + else + for ((i = 0; i < n; i++)); do + printf '│ %-16s │ %-8s │ %-8s │ %-8s │ %-8s │\n' \ + "${RES_PHASE[$i]:0:16}" \ + "${RES_HOST[$i]:0:8}" \ + "${RES_WATT[$i]:0:8}" \ + "${RES_BLED[$i]:0:8}" \ + "${RES_RDISP[$i]:0:8}" | log_raw + if [[ -n "${RES_NOTE[$i]:-}" ]]; then + log "│ note: ${RES_NOTE[$i]}" + fi + done + fi + log "└──────────────────┴──────────┴──────────┴──────────┴──────────┘" + + # Verdict heuristics + local any_host_pass=0 any_host_fail=0 any_rf=0 all_rf_zero=1 + for ((i = 0; i < n; i++)); do + case "${RES_HOST[$i]}" in + *PASS*) any_host_pass=1 ;; + *FAIL*) any_host_fail=1 ;; + esac + case "${RES_WATT[$i]}" in + ''|'0'|'0.0'|'?'|'n'|'nein') ;; + *) + # non-zero / non-empty that looks like power + if [[ "${RES_WATT[$i]}" =~ ^[0-9]*\.?[0-9]+$ ]]; then + if awk -v w="${RES_WATT[$i]}" 'BEGIN{exit !(w>0)}'; then + any_rf=1 + all_rf_zero=0 + fi + else + # free text — if contains number >0 heuristic + if [[ "${RES_WATT[$i]}" =~ [1-9] ]]; then + any_rf=1 + all_rf_zero=0 + fi + fi + ;; + esac + if [[ "${RES_BLED[$i]}" == "j" || "${RES_RDISP[$i]}" == "j" ]]; then + any_rf=1 + all_rf_zero=0 + fi + done + + log "" + log "=== Verdict-Hinweise (Heuristik, kein Automatik-Urteil) ===" + if [[ "$n" -eq 0 ]]; then + log "• Keine Ladder-Daten — nur Preflight/Status gelaufen." + elif [[ "$any_host_pass" -eq 1 && "$all_rf_zero" -eq 1 && "$any_rf" -eq 0 ]]; then + log "• HOST OK / RF tot oder 0 W: Fehlerklasse NACH UART" + log " → DE-9/3.5mm Kontakt, Charge-Pump/Vcc, PTT-Transistor, Mic-Buchse, Radio" + log " → Mic-alone 4 W OK stützt: Radio selbst ok; Pfad Modem↔Mic intermittierend" + elif [[ "$any_host_pass" -eq 1 && "$any_rf" -eq 1 ]]; then + log "• HOST OK / RF zeitweise sichtbar: INTERMITTENT RF (Kontakt/Pump/AF)" + log " → Contact-Hunt wiederholen; zwischen langen Keys Pause ~14 s (PTT-WD)" + log " → TXD pulse vs steady (TFPCX) als Beobachtung; Kabel/Stecker fest" + elif [[ "$any_host_fail" -eq 1 && "$any_host_pass" -eq 0 ]]; then + log "• HOST FAIL: zuerst Stack/KISS/fulldup/MCR — nicht primär Wattmeter" + log " → bcpr-ctl status, kiss_link, fulldup=yes bei Soft-DCD, kein zweites bcprd" + log " → MCR bleibt Sailer 0x0e|bit / 0x0d (nicht 4PC-COM 0x0A)" + else + log "• Gemischte Ergebnisse — Log + Notizen vergleichen; Phasen 5/8 wiederholen." + fi + log "" + log "SSoT: 0-RESEARCHES/projects/max25-stack/2026-07-19-bcpr-host-keyed-wattmeter-tot.md" + log " 2026-07-19-bcpr-mcr-ok-rf-zero-after-4w.md · winning-recipe · RF intermittent" + log " soft-TNC RE: 2026-07-19-baycom-soft-tnc-serial-ptt-re.md (WD · TXD · MCR)" + log "USB: kein Produkt-TX (Phase U)." + log "" + log "Log gespeichert: $LOG" + return 0 +} + +run_all() { + phase_preflight + phase_idle_mcr + phase_status + phase_rx_listen + phase_force_tx_ladder + phase_cal_high + phase_double_burst + phase_contact_hunt + phase_usb_note + phase_summary +} + +menu_loop() { + while true; do + banner "bcpr Ultimate Diag — Menü" + log "INI=$INI" + log "Log=$LOG" + log "" + log " 1) Preflight" + log " 2) Idle MCR" + log " 3) Status + Telem" + log " 4) RX Listen" + log " 5) Force-TX Ladder 1/3/5/8s" + log " 6) Cal/high" + log " 7) Double Burst" + log " 8) Contact Hunt" + log " 9) Summary / Verdict" + log " A) Alle Phasen 1–9 (+ USB-Hinweis)" + log " 0) bcprd stop/start (sicher)" + log " U) USB-Hinweis" + log " Q) Beenden" + log "" + local choice + ask choice "Wahl:" + case "${choice^^}" in + 1) phase_preflight ;; + 2) phase_idle_mcr ;; + 3) phase_status ;; + 4) phase_rx_listen ;; + 5) phase_force_tx_ladder ;; + 6) phase_cal_high ;; + 7) phase_double_burst ;; + 8) phase_contact_hunt ;; + 9) phase_summary ;; + A|ALL) run_all ;; + 0) phase_restart_menu ;; + U) phase_usb_note ;; + Q|X|EXIT|QUIT) + phase_summary + log "Ende." + return 0 + ;; + *) + log "Unbekannte Wahl: $choice" + ;; + esac + done +} + +# ---------- main ---------- + +while [[ $# -gt 0 ]]; do + case "$1" in + -c) + [[ $# -ge 2 ]] || { echo "ERROR: -c needs INI path"; exit 2; } + INI="$2" + shift 2 + ;; + --menu) MODE="menu"; shift ;; + --all) MODE="all"; shift ;; + --phase) + [[ $# -ge 2 ]] || { echo "ERROR: --phase needs N"; exit 2; } + MODE="phase" + PHASE_ONLY="$2" + shift 2 + ;; + -h|--help) usage; exit 0 ;; + *) + echo "ERROR: unknown arg: $1" + usage + exit 2 + ;; + esac +done + +RUN_TS="$(date +%Y%m%d-%H%M%S)" +LOG="/tmp/bcpr-ultimate-diag-${RUN_TS}.log" +: >"$LOG" || { + echo "FEHLER: kann Log nicht schreiben: $LOG" + exit 1 +} + +# Interactive reads use /dev/tty; log()/log_raw append to $LOG (no exec-tee double). +banner "bcpr Ultimate Diag — BayCom/based (bcpr)" +log "Start: $(date -R 2>/dev/null || date)" +log "Host: $(hostname 2>/dev/null || echo '?')" +log "User: $(id -un 2>/dev/null || echo '?') uid=$(id -u)" +log "INI: $INI (override: BCPR_INI / -c)" +log "Log: $LOG" +log "Tools: $CTL" +log "" +log "Hinweis: PTT-Watchdog ~14,5 s / 500 ms — bei langen Keys unkey/pausieren." +log " TXD: bcprd pulst (Sailer); TFPCX oft steady — RF mid-key notieren." +log " MCR: Sailer belassen (kein 4PC-COM 0x0A-Experiment in diesem Skript)." +log "" + +ensure_tools || { + log "Abbruch: Tools fehlen." + exit 1 +} + +# Soft check sudo early (warn only for menu; hard for --all live) +if ! need_sudo; then + log "WARN: ohne sudo -n sind Live/TX/Cal/MCR-Phasen blockiert." + if [[ "$MODE" == "all" ]]; then + log "Abbruch (--all braucht sudo -n)." + exit 1 + fi +fi + +case "$MODE" in + all) + run_all + ;; + phase) + case "$PHASE_ONLY" in + 1) phase_preflight ;; + 2) phase_idle_mcr ;; + 3) phase_status ;; + 4) phase_rx_listen ;; + 5) phase_force_tx_ladder ;; + 6) phase_cal_high ;; + 7) phase_double_burst ;; + 8) phase_contact_hunt ;; + 9) phase_summary ;; + 0) phase_restart_menu ;; + U|u) phase_usb_note ;; + *) + log "FEHLER: unbekannte Phase $PHASE_ONLY" + exit 2 + ;; + esac + ;; + menu|*) + menu_loop + ;; +esac + +log "" +log "Fertig. Log: $LOG" +exit 0 diff --git a/stacks/max25-bcpr/tools/max25-bcpr-ctl b/stacks/max25-bcpr/tools/max25-bcpr-ctl new file mode 100755 index 0000000..56f44b6 --- /dev/null +++ b/stacks/max25-bcpr/tools/max25-bcpr-ctl @@ -0,0 +1,349 @@ +#!/usr/bin/env bash +# max25-bcpr-ctl — preflight / start / status / stop / smoke for max25-bcprd (SER12). +# PC-COM / BayCom/based: TCM3105 AFSK + PTT. Host owns HDLC/KISS. +# No calibrate. No baycom_ser_fdx product path. +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" + +usage() { + cat <<USAGE +Usage: max25-bcpr-ctl [-c ini] {preflight|start|status|stop|smoke|version} [smoke-opts] + smoke [--live] [--tx] [--seconds N] [--tx-seconds N] + Default smoke: NO TX. --tx-seconds default 3 (≈376B info / visible PTT). + Warn: SQ-open / RX-edge noise can stress hosts — see docs/BAYCOM-FREEZES.md +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -c) INI="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) break ;; + esac +done +CMD="${1:-}" +if [[ $# -gt 0 ]]; then + shift +fi + +resolve_max25_bcprd_init() { + if [[ -n "${BCPRD_INIT:-}" && "$BCPRD_INIT" != "max25-bcprd-init" && -x "$BCPRD_INIT" ]]; then + echo "$BCPRD_INIT"; return + fi + if command -v "$BCPRD_INIT" >/dev/null 2>&1; then + command -v "$BCPRD_INIT"; return + fi + local root cand + root="$(cd "$SCRIPT_DIR/../../.." && pwd)" + for cand in \ + ${MAX25_BCPR_BUILD_DIR:-${BCPR_BUILD_DIR:-}}/bin/max25-bcprd-init \ + "$root/build-max25-bcpr/bin/max25-bcprd-init" \ + "$root/build-max25-bcpr-${USER:-user}/bin/max25-bcprd-init" \ + "/tmp/max25-build-max25-bcpr-${USER:-user}/bin/max25-bcprd-init" \ + "$root/build/bin/max25-bcprd-init" \ + /usr/local/bin/max25-bcprd-init /usr/bin/max25-bcprd-init; do + [[ -n "$cand" && -x "$cand" ]] && { echo "$cand"; return; } + done + return 1 +} + +resolve_max25_bcprd() { + if [[ -n "${BCPRD:-}" && "$BCPRD" != "max25-bcprd" && -x "$BCPRD" ]]; then + echo "$BCPRD"; return + fi + if command -v "$BCPRD" >/dev/null 2>&1; then + command -v "$BCPRD"; return + fi + local root cand + root="$(cd "$SCRIPT_DIR/../../.." && pwd)" + for cand in \ + ${MAX25_BCPR_BUILD_DIR:-${BCPR_BUILD_DIR:-}}/bin/max25-bcprd \ + "$root/build-max25-bcpr/bin/max25-bcprd" \ + "$root/build-max25-bcpr-${USER:-user}/bin/max25-bcprd" \ + "/tmp/max25-build-max25-bcpr-${USER:-user}/bin/max25-bcprd" \ + "$root/build/bin/max25-bcprd" \ + /usr/local/bin/max25-bcprd /usr/bin/max25-bcprd; do + [[ -n "$cand" && -x "$cand" ]] && { echo "$cand"; return; } + done + return 1 +} + +ini_get() { + local section="$1" key="$2" + awk -F= -v s="[$section]" -v k="$key" ' + $0 ~ /^\[/ { cur=$0; gsub(/[[:space:]]/,"",cur) } + cur==s && $1 ~ "^[[:space:]]*"k"[[:space:]]*$" { + v=$2; gsub(/^[[:space:]]+|[[:space:]]+$/,"",v); print v; exit + }' "$INI" 2>/dev/null || true +} + +is_truthy() { + case "${1,,}" in yes|true|on|1) return 0 ;; *) return 1 ;; esac +} + +find_setserial() { + local p + for p in /usr/bin/setserial /bin/setserial /sbin/setserial /usr/sbin/setserial; do + [[ -x "$p" ]] && { echo "$p"; return; } + done + command -v setserial 2>/dev/null || true +} + +# Parse "Port: 0x3f8" / "irq: 4" from setserial -g +setserial_port_irq() { + local bin="$1" dev="$2" out port irq + out="$("$bin" -g "$dev" 2>/dev/null)" || return 1 + # Typical: /dev/ttyS0, UART: 16550A, Port: 0x3f8, IRQ: 4 + 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() { + # lowercase 0x… without leading zeros differences: 0x3f8 vs 0x03f8 + 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 + # Prefer lsof (parse output); FreeBSD fuser exit status alone is unreliable. + if command -v lsof >/dev/null 2>&1; then + out="$(lsof "$serial" 2>/dev/null || true)" + if [[ -n "$out" ]]; then + return 0 + fi + 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 err=0 dry serial irq iobase name sysirq got ss ss_port ss_irq pair + local -a seen_irq=() seen_serial=() + [[ -f "$INI" ]] || { echo "ERROR: missing $INI"; return 1; } + 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" + echo "max25-bcpr-ctl preflight ini=$INI" + echo "NOTE: max25-bcpr → device max25e0 (BayCom/based SER12; live via max25-bcprd-init)" + + # Kernel BayCom modules must not share the path (A–O–Z isolate). + if mod="$(baycom_modules_loaded)"; then + echo " ERROR: kernel module loaded: $mod — unload before max25-bcpr (isolate)" + err=1 + else + echo " OK: no baycom_* modules loaded" + fi + + ss="$(find_setserial)" + if [[ -z "$ss" ]]; then + echo " WARN: setserial not found — iobase check limited to INI/sysfs" + else + echo " OK: setserial=$ss" + 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 + echo " [$sec] enabled=no — skip" + continue + fi + irq="$(ini_get "$sec" irq)" + iobase="$(ini_get "$sec" iobase)" + echo " [$sec] serial=$serial irq=$irq iobase=$iobase" + + if is_truthy "$dry"; then + echo " dry_run=yes — skip live tty/IRQ/idle checks" + continue + fi + + if [[ ! -e "$serial" ]]; then + if is_freebsd && [[ -n "$iobase" && "$iobase" != "0" ]]; then + echo " WARN: $serial missing (uart disabled — SER12 uses iobase $iobase only)" + else + echo " ERROR: $serial missing"; err=1; continue + fi + else + echo " OK: serial node exists" + fi + + if [[ -e "$serial" ]]; then + if port_busy "$serial"; then + echo " ERROR: $serial busy (fuser/lsof) — exclusive lock fail-closed"; err=1 + else + echo " OK: port idle" + fi + elif is_freebsd && [[ -n "$iobase" && "$iobase" != "0" ]]; then + echo " OK: port idle (no tty node — ExSys iobase path)" + fi + + if [[ -z "$irq" || "$irq" == "0" ]]; then + echo " ERROR: irq must be real (non-zero)"; err=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 + echo " ERROR: IRQ mismatch INI=$irq sysfs=$got"; err=1 + elif [[ -n "$irq" && "$irq" != "0" ]]; then + echo " OK: IRQ $irq matches sysfs" + fi + else + echo " WARN: cannot read $sysirq" + 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 + echo " ERROR: iobase mismatch INI=$iobase setserial=$ss_port"; err=1 + else + echo " OK: iobase $iobase matches setserial" + fi + else + echo " WARN: setserial did not report Port for $serial" + fi + if [[ -n "$ss_irq" && -n "$irq" && "$irq" != "0" && "$ss_irq" != "$irq" ]]; then + echo " ERROR: IRQ mismatch INI=$irq setserial=$ss_irq"; err=1 + elif [[ -n "$ss_irq" && -n "$irq" && "$irq" != "0" ]]; then + echo " OK: IRQ $irq matches setserial" + fi + elif [[ -n "$iobase" && -z "$ss" ]]; then + echo " WARN: cannot verify iobase=$iobase without setserial" + fi + + # Dual uniqueness + for s in "${seen_serial[@]:-}"; do + if [[ -n "$s" && "$s" == "$serial" ]]; then + echo " ERROR: duplicate serial $serial"; err=1 + fi + done + for i in "${seen_irq[@]:-}"; do + if [[ -n "$i" && -n "$irq" && "$irq" != "0" && "$i" == "$irq" ]]; then + echo " ERROR: duplicate IRQ $irq (bc0/bc1 must be distinct)"; err=1 + fi + done + seen_serial+=("$serial") + [[ -n "$irq" && "$irq" != "0" ]] && seen_irq+=("$irq") + done + + return "$err" +} + +cmd_start() { + local bin init dry + cmd_preflight || { echo "preflight failed"; 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 + echo "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)" || { echo "max25-bcprd not found — build with -DMAX25_BUILD_MAX25_BCPR=ON"; return 1; } + "$bin" -c "$INI" --dry-run </dev/null >/dev/null 2>&1 & + echo $! >"$PIDFILE" + echo "started max25-bcprd (dry-run) pid=$(cat "$PIDFILE") → max25e0" + return 0 + fi + init="$(resolve_max25_bcprd_init)" || { echo "max25-bcprd-init not found — build/install with setuid"; return 1; } + # Init forks internally; parent writes pidfile and exits (no shell background). + if ! "$init" -c "$INI" </dev/null; then + echo "max25-bcprd-init failed (see stderr above)" + return 1 + fi + if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then + echo "started max25-bcprd pid=$(cat "$PIDFILE") → max25e0" + else + echo "max25-bcprd-init exited but pidfile missing or stale"; 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 + echo "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 + echo "max25-bcprd: running pid=$(cat "$PIDFILE")" + else + echo "max25-bcprd: not running" + fi + for link in "$STATE_DIR"/kiss-bc*; do + [[ -e "$link" ]] || continue + echo " kiss_link $link -> $(readlink -f "$link" 2>/dev/null || echo '?')" + done +} + +cmd_smoke() { + [[ -x "$SMOKE_SH" ]] || { echo "ERROR: missing $SMOKE_SH"; return 1; } + # Forward remaining args; default INI from -c + exec "$SMOKE_SH" -c "$INI" "$@" +} + +case "$CMD" in + preflight) cmd_preflight ;; + start) cmd_start ;; + stop) cmd_stop ;; + status) cmd_status ;; + smoke) cmd_smoke "$@" ;; + version) echo "max25-bcpr-ctl 0.4.0 (SER12 → max25e0; live via max25-bcprd-init)" ;; + *) usage; exit 1 ;; +esac diff --git a/stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh b/stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh new file mode 100755 index 0000000..c499401 --- /dev/null +++ b/stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh @@ -0,0 +1,566 @@ +#!/usr/bin/env bash +# max25-bcpr-rxtx-smoke.sh — L0…L4 nofreeze prove-out (BayCom/based / max25-bcpr). +# Default: offline L0 only, NO TX. Live RX: --live. TX: --live --tx. +# No calibrate. No baycom_ser_fdx product path. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# Tree: stacks/max25-bcpr/tools → repo root. Install: PREFIX/sbin → use installed bins. +_cand="$(cd "$SCRIPT_DIR/../../.." 2>/dev/null && pwd || true)" +if [[ -n "$_cand" && -f "$_cand/CMakeLists.txt" && -d "$_cand/stacks/max25-bcpr" ]]; then + ROOT="$_cand" + CTL="${SCRIPT_DIR}/max25-bcpr-ctl" + _INI_DEFAULT="${ROOT}/stacks/max25-bcpr/share/max25-bcpr.ini.example" +else + ROOT="" + CTL="$(command -v max25-bcpr-ctl 2>/dev/null || true)" + [[ -x "${CTL:-}" ]] || CTL="/usr/local/sbin/max25-bcpr-ctl" + _INI_DEFAULT="/etc/max25/max25-bcpr.ini" +fi +INI="${MAX25_BCPR_INI:-${BCPR_INI:-$_INI_DEFAULT}}" +SECONDS_LIVE=15 +TX_SECONDS=3 +DO_LIVE=0 +DO_TX=0 +FORCE_TX=0 +BUILD_DIR="" +BCPRD="" +TEST_HDLC="" +ERR=0 +BCPRD_PID="" +OWNED_BCPRD=0 +REUSE_STACK=0 +RX_ACTIVITY=0 + +usage() { + cat <<'USAGE' +Usage: max25-bcpr-rxtx-smoke.sh [-c ini] [--live] [--tx] [--force-tx] [--seconds N] [--tx-seconds N] [--build-dir DIR] + L0 offline always. Soft L1 if probes safe. L2/L3 need --live. L4 needs --tx. + --tx-seconds: target PTT key window (default 3; uses ~376B info ≈3s like proven inject). + Default: NO TX. Hard time caps. Stop only max25-bcprd started by this script. + §0.20: --tx requires RX/DCD activity in this run unless --force-tx (debug only). +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -c) INI="$2"; shift 2 ;; + --live) DO_LIVE=1; shift ;; + --tx) DO_TX=1; shift ;; + --force-tx) FORCE_TX=1; shift ;; + --seconds) SECONDS_LIVE="$2"; shift 2 ;; + --tx-seconds) TX_SECONDS="$2"; shift 2 ;; + --build-dir) BUILD_DIR="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "ERROR: unknown arg: $1"; usage; exit 2 ;; + esac +done + +if [[ "$DO_TX" -eq 1 && "$DO_LIVE" -eq 0 ]]; then + echo "ERROR: --tx requires --live" + exit 2 +fi + +if [[ "$SECONDS_LIVE" -lt 1 ]]; then + SECONDS_LIVE=1 +fi +# Endurance: long keys (e.g. 60s). bcprd PTT-WD unkeys ~14.5s/500ms. +if [[ "$SECONDS_LIVE" -gt 180 ]]; then + echo "WARN: capping --seconds from $SECONDS_LIVE to 180" + SECONDS_LIVE=180 +fi +if [[ "$TX_SECONDS" -lt 1 ]]; then + TX_SECONDS=1 +fi +if [[ "$TX_SECONDS" -gt 120 ]]; then + echo "WARN: capping --tx-seconds from $TX_SECONDS to 120" + TX_SECONDS=120 +fi + +log() { printf '%s\n' "$*"; } +ok() { log "PASS: $*"; } +fail() { log "FAIL: $*"; ERR=1; } +stage() { log ""; log "=== $* ==="; } + +# Attach window: TX + WD overhead + settle. +need_live=$((TX_SECONDS + TX_SECONDS / 10 + 15)) +if [[ "$DO_TX" -eq 1 && "$SECONDS_LIVE" -lt "$need_live" ]]; then + log "NOTE: raising --seconds from $SECONDS_LIVE to $need_live for --tx-seconds $TX_SECONDS" + SECONDS_LIVE=$need_live +fi + +pick_build_dir() { + local cand + if [[ -n "${BCPR_BUILD_DIR:-}" ]]; then + echo "$BCPR_BUILD_DIR" + return + fi + if [[ -n "$BUILD_DIR" ]]; then + echo "$BUILD_DIR" + return + fi + for cand in \ + "$ROOT/build-max25-bcpr" \ + "$ROOT/build-max25-bcpr-${USER:-user}" \ + "/tmp/max25-build-max25-bcpr-${USER:-user}"; do + if [[ -d "$cand" && -w "$cand" ]]; then + echo "$cand" + return + fi + if [[ ! -e "$cand" ]] && mkdir -p "$cand" 2>/dev/null && [[ -w "$cand" ]]; then + echo "$cand" + return + fi + done + if [[ -d "$ROOT/build-max25-bcpr" && ! -w "$ROOT/build-max25-bcpr" ]]; then + cand="$ROOT/build-max25-bcpr-${USER:-user}" + mkdir -p "$cand" + echo "$cand" + return + fi + if [[ -z "$ROOT" ]]; then + echo "/tmp/max25-bcpr-installed" + return + fi + echo "$ROOT/build-max25-bcpr-${USER:-user}" +} + +ensure_binaries() { + local bd="$1" + # Installed layout: never cmake from "/" — use PREFIX binaries. + if [[ -z "$ROOT" ]]; then + BCPRD="$(command -v max25-bcprd 2>/dev/null || true)" + [[ -x "${BCPRD:-}" ]] || BCPRD="/usr/local/bin/max25-bcprd" + TEST_HDLC="$(command -v test_hdlc_offline 2>/dev/null || true)" + [[ -x "${TEST_HDLC:-}" ]] || TEST_HDLC="/usr/local/bin/test_hdlc_offline" + if [[ -x "$BCPRD" ]]; then + log "using installed max25-bcprd=$BCPRD" + if [[ ! -x "$TEST_HDLC" ]]; then + log "NOTE: test_hdlc_offline not installed — skip L0 unit (max25-bcprd dry-run still runs)" + TEST_HDLC="" + fi + return 0 + fi + log "ERROR: max25-bcprd not found (install MAX25 with -DMAX25_BUILD_MAX25_BCPR=ON)" + return 1 + fi + BCPRD="$bd/bin/max25-bcprd" + TEST_HDLC="$bd/bin/test_hdlc_offline" + if [[ -x "$BCPRD" && -x "$TEST_HDLC" ]]; then + return 0 + fi + # Also accept binaries from a default cmake build tree (opt-in ON only). + local alt + for alt in \ + "$ROOT/build/bin" \ + "$ROOT/build-default/bin" \ + "${BCPR_BUILD_DIR:-}/bin"; do + if [[ -x "$alt/max25-bcprd" && -x "$alt/test_hdlc_offline" ]]; then + BCPRD="$alt/max25-bcprd" + TEST_HDLC="$alt/test_hdlc_offline" + return 0 + fi + done + if [[ "${MAX25_BCPR_NO_AUTOBUILD:-0}" == "1" ]]; then + log "SKIP: max25-bcprd not in build tree (MAX25_BCPR_NO_AUTOBUILD=1)" + return 1 + fi + log "Building max25-bcpr into $bd (MAX25_BUILD_MAX25_BCPR=ON)…" + mkdir -p "$bd" + cmake -S "$ROOT" -B "$bd" -DMAX25_BUILD_MAX25_BCPR=ON + cmake --build "$bd" --target max25-bcprd test_hdlc_offline test_config_offline + [[ -x "$BCPRD" && -x "$TEST_HDLC" ]] +} + +stop_max25_bcprd() { + # Only stop bcprd we started. Never blanket-pkill — max25d may own a live stack. + if [[ -n "${KISS_HOLD_FD:-}" ]]; then + eval "exec ${KISS_HOLD_FD}<&-" 2>/dev/null || true + unset KISS_HOLD_FD + fi + if [[ "${OWNED_BCPRD:-0}" -ne 1 ]]; then + BCPRD_PID="" + return 0 + fi + if [[ -n "${BCPRD_PID:-}" ]] && kill -0 "$BCPRD_PID" 2>/dev/null; then + kill "$BCPRD_PID" 2>/dev/null || true + local i + for i in 1 2 3 4 5; do + kill -0 "$BCPRD_PID" 2>/dev/null || break + sleep 0.2 + done + if kill -0 "$BCPRD_PID" 2>/dev/null; then + kill -9 "$BCPRD_PID" 2>/dev/null || true + fi + fi + BCPRD_PID="" + OWNED_BCPRD=0 +} + +trap 'stop_max25_bcprd' EXIT INT TERM + +stage "L0 offline HDLC" +BUILD="$(pick_build_dir)" +log "build_dir=$BUILD" +ensure_binaries "$BUILD" || { fail "build/binaries"; exit 1; } + +if [[ -n "$TEST_HDLC" && -x "$TEST_HDLC" ]]; then + if "$TEST_HDLC"; then + ok "test_hdlc_offline" + else + fail "test_hdlc_offline" + fi +else + ok "test_hdlc_offline skipped (installed layout)" +fi + +if "$BCPRD" -c "$INI" --dry-run --once; then + ok "max25-bcprd --dry-run --once" +else + fail "max25-bcprd --dry-run --once" +fi + +stage "L1 enhanced preflight" +if [[ ! -f "$INI" ]]; then + fail "missing INI $INI" +else + export BCPRD + set +e + "$CTL" -c "$INI" preflight + pf_rc=$? + set -e + if [[ "$pf_rc" -eq 0 ]]; then + ok "max25-bcpr-ctl preflight" + else + if [[ "$DO_LIVE" -eq 1 ]]; then + fail "max25-bcpr-ctl preflight (required for --live)" + else + log "NOTE: preflight rc=$pf_rc without --live — L0 is the offline gate" + # dry_run example: preflight skips UART and returns 0; real mismatch is soft-note only + if [[ "$pf_rc" -ne 0 ]]; then + log "WARN: fix INI before --live" + fi + fi + fi +fi + +if [[ "$DO_LIVE" -eq 0 ]]; then + stage "summary (offline)" + if [[ "$ERR" -eq 0 ]]; then + ok "L0 complete — add --live for L2/L3 (RX), --live --tx for L4" + exit 0 + fi + exit "$ERR" +fi + +stage "L2/L3 timed attach + RX listen (${SECONDS_LIVE}s)" +if [[ "$DO_TX" -eq 1 ]]; then + log "WARN: --tx asserts PTT (~${TX_SECONDS}s key window; LED/wattmeter visible)" +fi + +dry_val="$(awk -F= ' + $0 ~ /^\[/ { cur=$0; gsub(/[[:space:]]/,"",cur) } + cur=="[max25-bcpr]" && $1 ~ /^[[:space:]]*dry_run[[:space:]]*$/ { + v=$2; gsub(/^[[:space:]]+|[[:space:]]+$/,"",v); print tolower(v); exit + }' "$INI")" +case "$dry_val" in + yes|true|on|1) + fail "live requires dry_run=no in $INI" + exit 1 + ;; +esac + +kiss_link="$(awk -F= ' + $0 ~ /^\[/ { cur=$0; gsub(/[[:space:]]/,"",cur) } + cur=="[bc0]" && $1 ~ /^[[:space:]]*kiss_link[[:space:]]*$/ { + v=$2; gsub(/^[[:space:]]+|[[:space:]]+$/,"",v); print v; exit + }' "$INI")" +kiss_link="${kiss_link:-/tmp/max25-bcpr/kiss-bc0}" + +iobase="$(awk -F= ' + $0 ~ /^\[/ { cur=$0; gsub(/[[:space:]]/,"",cur) } + cur=="[bc0]" && $1 ~ /^[[:space:]]*iobase[[:space:]]*$/ { + v=$2; gsub(/^[[:space:]]+|[[:space:]]+$/,"",v); print v; exit + }' "$INI")" +iobase="${iobase:-0x3f8}" + +export BCPRD +"$CTL" -c "$INI" preflight || { fail "preflight before attach"; exit 1; } + +# Prefer live stack (max25d-owned bcprd) — proven KISS inject path. +# Starting a second bcprd races UART MCR and can yield no visible TX. +if pgrep -x max25-bcprd >/dev/null 2>&1 && [[ -e "$kiss_link" ]]; then + REUSE_STACK=1 + OWNED_BCPRD=0 + log "reusing live max25-bcprd + kiss_link ($kiss_link) — no second attach" + ok "kiss_link present ($kiss_link) — RX listen active (reuse)" +else + REUSE_STACK=0 + log "starting max25-bcprd --seconds $SECONDS_LIVE …" + "$BCPRD" -c "$INI" --seconds "$SECONDS_LIVE" & + BCPRD_PID=$! + OWNED_BCPRD=1 + + waited=0 + while [[ "$waited" -lt 5 ]]; do + if [[ -e "$kiss_link" ]] || ! kill -0 "$BCPRD_PID" 2>/dev/null; then + break + fi + sleep 0.5 + waited=$((waited + 1)) + done + + if ! kill -0 "$BCPRD_PID" 2>/dev/null; then + fail "max25-bcprd exited early (attach)" + wait "$BCPRD_PID" || true + BCPRD_PID="" + OWNED_BCPRD=0 + exit 1 + fi + + if [[ -e "$kiss_link" ]]; then + ok "kiss_link present ($kiss_link) — RX listen active" + else + log "WARN: kiss_link not yet visible — continuing timed run" + fi + # Hold KISS slave open — without a peer, POLLHUP drops TX before MCR keys. + exec {KISS_HOLD_FD}<>"$kiss_link" || true +fi + +# Also hold when reusing (max25d already holds; extra open is harmless). +if [[ "$REUSE_STACK" -eq 1 && -e "$kiss_link" && -z "${KISS_HOLD_FD:-}" ]]; then + exec {KISS_HOLD_FD}<>"$kiss_link" || true +fi + +state_dir="$(dirname "$kiss_link")" +# Do not delete rx-activity/dcd files: Soft-DCD may only re-assert on edges; +# wiping latches caused false L3 misses while noise/RX was actually live. +# Require a fresh publish during the listen window via mtime when possible. +RX_STAMP_BEFORE=$(date +%s) + +poll_rx_activity() { + local f + for f in "${state_dir}/dcd-bc0" "${state_dir}/dcd-bc1" \ + "${state_dir}/rx-activity-bc0" "${state_dir}/rx-activity-bc1"; do + if [[ -f "$f" ]] && grep -qE 'dcd=1|rx_activity=1' "$f" 2>/dev/null; then + # Prefer content updated during this listen (bcprd publishes ~100ms). + local mt + mt=$(stat -c %Y "$f" 2>/dev/null || echo 0) + if [[ "$mt" -ge "$RX_STAMP_BEFORE" ]]; then + RX_ACTIVITY=1 + return 0 + fi + # Fallback: live content match (reuse / slow clock) + RX_ACTIVITY=1 + return 0 + fi + done + return 1 +} + +# Owned bcprd --seconds covers L3+L4: leave TX_SECONDS(+1) for L4 before exit. +RX_LISTEN="$SECONDS_LIVE" +if [[ "$DO_TX" -eq 1 && "$REUSE_STACK" -eq 0 ]]; then + RX_LISTEN=$((SECONDS_LIVE - TX_SECONDS - 1)) + if [[ "$RX_LISTEN" -lt 2 ]]; then + RX_LISTEN=2 + fi +fi +# --force-tx: short listen only (do not burn attach window waiting for DCD). +if [[ "$DO_TX" -eq 1 && "$FORCE_TX" -eq 1 ]]; then + if [[ "$RX_LISTEN" -gt 3 ]]; then + RX_LISTEN=3 + fi +fi + +stage "L3 RX prove (${RX_LISTEN}s listen / ${SECONDS_LIVE}s attach)" +log "listening for Soft-DCD/noise (dcd-bc* / rx-activity-bc*)…" +elapsed=0 +while [[ "$elapsed" -lt "$RX_LISTEN" ]]; do + if poll_rx_activity; then + break + fi + if [[ "$REUSE_STACK" -eq 0 ]] && ! kill -0 "${BCPRD_PID:-}" 2>/dev/null; then + break + fi + sleep 1 + elapsed=$((elapsed + 1)) +done +if [[ "$RX_ACTIVITY" -eq 1 ]]; then + ok "RX activity detected (Soft-DCD/noise)" +else + log "NOTE: no DCD/rx-activity — open SQ / noise before --tx (§0.20)" +fi + +# §0.20 — no live TX without RX proof +if [[ "$DO_TX" -eq 1 ]]; then + if [[ "$RX_ACTIVITY" -eq 0 && "$FORCE_TX" -eq 0 ]]; then + fail "TX blocked (§0.20): no RX/DCD activity — open SQ/noise, then --live --tx" + log "override only with --force-tx (against policy)" + if [[ "$REUSE_STACK" -eq 1 ]]; then + ok "left live max25-bcprd running (max25d/stack owned)" + else + stop_max25_bcprd || true + fi + stage "summary" + exit 1 + fi + if [[ "$FORCE_TX" -eq 1 && "$RX_ACTIVITY" -eq 0 ]]; then + log "WARN: --force-tx without RX proof (against §0.20 policy)" + fi +fi + +if [[ "$DO_TX" -eq 1 ]]; then + stage "L4 TX (~${TX_SECONDS}s PTT / MCR)" + if [[ ! -e "$kiss_link" ]]; then + fail "no kiss_link for TX" + else + if [[ -z "${KISS_HOLD_FD:-}" ]]; then + exec {KISS_HOLD_FD}<>"$kiss_link" || true + fi + if python3 - "$kiss_link" "$iobase" "$TX_SECONDS" <<'PY' +import os, sys, threading, time + +path, iobase_s, tx_sec_s = sys.argv[1], sys.argv[2], sys.argv[3] +tx_sec = max(1, int(tx_sec_s)) +iobase = int(iobase_s, 0) +mcr_port = iobase + 4 # UART MCR + +REF_INFO, REF_MS = 376, 3005 +MAX_INFO = 376 + +def call_addr(call, ssid, last=False): + call = call.upper().ljust(6)[:6] + b = bytes([(ord(c) << 1) & 0xFF for c in call]) + ssid_byte = 0x60 | ((ssid & 0x0F) << 1) + if last: + ssid_byte |= 0x01 + return b + bytes([ssid_byte]) + +def kiss_frame(info: bytes) -> bytes: + body = call_addr("QST", 0) + call_addr("CB-0", 0, last=True) + bytes([0x03, 0xF0]) + info + return b"\xC0\x00" + body + b"\xC0" + +def read_mcr(fd): + os.lseek(fd, mcr_port, os.SEEK_SET) + return ord(os.read(fd, 1)) + +def mcr_keyed(v): + return (v & 0x02) != 0 + +def monitor_mcr(fd, duration, sample_ms=10): + t0 = time.monotonic() + first = last = None + vals = set() + while time.monotonic() - t0 < duration: + v = read_mcr(fd) + if mcr_keyed(v): + vals.add(v) + now = time.monotonic() + if first is None: + first = now + last = now + time.sleep(sample_ms / 1000.0) + keyed_ms = 0.0 if first is None else (last - first) * 1000.0 + return keyed_ms, vals + +remaining_ms = tx_sec * 1000 +bursts = 0 +total_keyed = 0.0 +all_vals = set() +port_fd = os.open("/dev/port", os.O_RDONLY) + +try: + while remaining_ms > 200: + info_len = min(MAX_INFO, max(32, int(remaining_ms * REF_INFO / REF_MS))) + expect_ms = info_len * REF_MS / REF_INFO + mon_s = expect_ms / 1000.0 + 1.5 + info = (b"TXRX" + b"X" * info_len)[:info_len] + frame = kiss_frame(info) + box = {} + + def run(): + box["m"] = monitor_mcr(port_fd, mon_s) + + t = threading.Thread(target=run) + t.start() + time.sleep(0.12) + f = open(path, "r+b", buffering=0) + try: + f.write(frame) + t.join() + finally: + f.close() + km, kvals = box["m"] + bursts += 1 + total_keyed += km + all_vals |= kvals + kv = ",".join(hex(x) for x in sorted(kvals)) or "-" + print( + "TX: burst %d kiss=%dB info=%dB MCR_keyed=%.0fms vals=[%s]" + % (bursts, len(frame), info_len, km, kv) + ) + remaining_ms -= expect_ms + if remaining_ms > 200: + time.sleep(0.4) +finally: + os.close(port_fd) + +lo = tx_sec * 1000 * 0.55 +hi = tx_sec * 1000 * 1.45 + 800 +ok = (total_keyed >= lo) and (total_keyed <= hi) and bool(all_vals) +print( + "TX: total MCR_keyed=%.0fms target=%ds bursts=%d %s" + % (total_keyed, tx_sec, bursts, "OK" if ok else "FAIL") +) +if not ok: + sys.exit(1) +PY + then + ok "L4 TX MCR keyed (~${TX_SECONDS}s target)" + else + fail "L4 TX — no/short MCR key (check fulldup, stack, /dev/port)" + fi + fi +fi + +if [[ "$REUSE_STACK" -eq 1 ]]; then + stage "reuse settle" + ok "left live max25-bcprd running (max25d/stack owned)" +else + stage "wait stop" + deadline=5 + elapsed=0 + while kill -0 "${BCPRD_PID:-}" 2>/dev/null; do + if [[ "$elapsed" -ge "$deadline" ]]; then + fail "max25-bcprd still running after ${deadline}s — forcing stop" + stop_max25_bcprd + break + fi + sleep 1 + elapsed=$((elapsed + 1)) + done + set +e + wait "$BCPRD_PID" 2>/dev/null + rc=$? + set -e + if ! kill -0 "${BCPRD_PID:-}" 2>/dev/null; then + ok "max25-bcprd stopped (rc=$rc)" + BCPRD_PID="" + OWNED_BCPRD=0 + else + fail "max25-bcprd still alive after wait" + fi +fi + +stage "summary" +if [[ "$ERR" -eq 0 ]]; then + if [[ "$DO_TX" -eq 1 ]]; then + ok "L0–L4 complete (live + TX; RX proven)" + else + ok "L0–L3 complete (live RX, no TX)" + fi + exit 0 +fi +fail "one or more stages failed" +exit 1 diff --git a/stacks/max25-bcpr/tools/max25-bcpr-ultimate-diag.sh b/stacks/max25-bcpr/tools/max25-bcpr-ultimate-diag.sh new file mode 100755 index 0000000..e9ade6e --- /dev/null +++ b/stacks/max25-bcpr/tools/max25-bcpr-ultimate-diag.sh @@ -0,0 +1,914 @@ +#!/usr/bin/env bash +# max25-bcpr-ultimate-diag.sh — interactive BayCom/based (bcpr) TX/RX diagnostic ladder +# Public mark: BayCom/based. Internal path: max25-bcpr. Never Konverter/converter. +# Target: AX25WRK1 intermittent "host keys, RF sometimes" (mic OK 4W; host MCR OK). +# +# Soft-TNC RE (2026-07-19) — operator warnings only (no MCR/code patch here): +# • FlexNet SER12 cal PTT watchdog: ~14.5 s keyed → ~500 ms unkey (discharge). +# Long continuous force-tx/cal: max25-bcprd ptt_wd drops RTS ~500 ms every ~14.5 s +# (FlexNet SER12 mirror; disable with ptt_wd=no / --no-ptt-wd). +# • TXD: default pulse THR 0x00; experiment txd_bias=steady (UART break ≈ TFPCX). +# • Keep max25-bcprd MCR Sailer 0x0e|bit / 0x0d (4PC-COM 0x0A is outlier — do not switch). +# +# Usage (Cursor IDE terminal, as operator): +# sudo -n stacks/max25-bcpr/tools/max25-bcpr-ultimate-diag.sh +# MAX25_BCPR_INI=/etc/max25/max25-bcpr.ini ./max25-bcpr-ultimate-diag.sh -c /etc/max25/max25-bcpr.ini +# ./max25-bcpr-ultimate-diag.sh --help +# ./max25-bcpr-ultimate-diag.sh --all # run phases 1–9 with pauses +# ./max25-bcpr-ultimate-diag.sh --menu # interactive menu (default) +# +# Needs: /usr/bin/sudo -n for ioport / live smoke / cal. Does NOT change MCR code. +# Does NOT use USB as product TX path. Does NOT kill max25d carelessly. +# +# Relies on: max25-bcpr-ctl, max25-bcpr-rxtx-smoke.sh (same directory). + +# Note: interactive prompts use read; keep pipefail but relax -e around optional probes. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." 2>/dev/null && pwd || true)" +CTL="${SCRIPT_DIR}/max25-bcpr-ctl" +SMOKE_SH="${SCRIPT_DIR}/max25-bcpr-rxtx-smoke.sh" +INI="${MAX25_BCPR_INI:-/etc/max25/max25-bcpr.ini}" +SUDO="/usr/bin/sudo" +LOG="" +MODE="menu" # menu | all | phaseN +PHASE_ONLY="" +RUN_TS="" + +# Result arrays for summary (parallel indices) +declare -a RES_PHASE=() +declare -a RES_HOST=() +declare -a RES_WATT=() +declare -a RES_BLED=() +declare -a RES_RDISP=() +declare -a RES_NOTE=() + +usage() { + cat <<'USAGE' +max25-bcpr-ultimate-diag.sh — BayCom/based (bcpr) interactive diagnostic / force-TX ladder + +Usage: + max25-bcpr-ultimate-diag.sh [-c INI] [--menu|--all|--phase N] [--help] + +Options: + -c INI bcpr.ini (default: $MAX25_BCPR_INI or /etc/max25/max25-bcpr.ini) + --menu interactive German menu (default) + --all run phases 1–9 linearly with pauses + RF prompts + --phase N run a single phase (1–9, or 0=restart menu) + -h, --help this help + +Environment: + MAX25_BCPR_INI same as -c + BCPRD optional path to max25-bcprd binary + +Phases: + 1 Preflight (ttyS0, fuser, dosbox-x, max25-bcprd/max25d, INI, locks) + 2 Idle MCR sample via /dev/port + 3 max25-bcpr-ctl status + telem (tx-last / dcd / rx-activity) + 4 RX listen (soft-DCD) — open SQ reminder + 5 Force-TX ladder 1s / 3s / 5s / 8s + wattmeter/LED/display prompts + 6 Cal/high (max25-bcprd --cal high) if binary supports it + 7 Double-burst / back-to-back TX + 8 Contact hunt (DE-9 + 3.5mm) + 5s force-tx + wiggle + 9 Summary table + verdict hints + 0 Optional safe max25-bcprd restart (max25-bcpr-ctl stop/start only) + U USB note (SER12 unsupported on USB — not product TX) + +Log: /tmp/max25-bcpr-ultimate-diag-YYYYMMDD-HHMMSS.log + +Examples: + sudo -n stacks/max25-bcpr/tools/max25-bcpr-ultimate-diag.sh --all + MAX25_BCPR_INI=/etc/max25/max25-bcpr.ini sudo -n ./max25-bcpr-ultimate-diag.sh --menu +USAGE +} + +# ---------- logging / UI ---------- + +log() { + local line + line="$(printf '%s' "$*")" + printf '%s\n' "$line" + if [[ -n "${LOG:-}" ]]; then + printf '%s\n' "$line" >>"$LOG" + fi +} + +log_raw() { + # stdin → stdout + log + if [[ -n "${LOG:-}" ]]; then + tee -a "$LOG" + else + cat + fi +} + +banner() { + log "" + log "════════════════════════════════════════════════════════════" + log "$*" + log "════════════════════════════════════════════════════════════" +} + +pause_enter() { + local msg="${1:-Weiter mit Enter …}" + printf '\n%s ' "$msg" >/dev/tty + # shellcheck disable=SC2034 + local _dummy + read -r _dummy </dev/tty || true + log "[pause] $msg" +} + +ask() { + # ask VAR "prompt" + local __var="$1" + local __prompt="$2" + local __ans="" + printf '%s ' "$__prompt" >/dev/tty + read -r __ans </dev/tty || true + printf -v "$__var" '%s' "$__ans" + log "[antwort] $__prompt → ${__ans}" +} + +ask_yn() { + # ask_yn VAR "prompt" → stores j/n/y/n normalized to j|n + local __var="$1" + local __prompt="$2" + local __ans="" + while true; do + printf '%s [j/n]: ' "$__prompt" >/dev/tty + read -r __ans </dev/tty || true + case "${__ans,,}" in + j|ja|y|yes) printf -v "$__var" 'j'; log "[antwort] $__prompt → j"; return 0 ;; + n|nein|no) printf -v "$__var" 'n'; log "[antwort] $__prompt → n"; return 0 ;; + *) printf 'Bitte j oder n.\n' >/dev/tty ;; + esac + done +} + +record_result() { + local phase="$1" host="$2" watt="$3" bled="$4" rdisp="$5" note="${6:-}" + RES_PHASE+=("$phase") + RES_HOST+=("$host") + RES_WATT+=("$watt") + RES_BLED+=("$bled") + RES_RDISP+=("$rdisp") + RES_NOTE+=("$note") +} + +need_sudo() { + if [[ ! -x "$SUDO" ]]; then + log "FEHLER: $SUDO fehlt — Live-Phasen brauchen sudo." + return 1 + fi + if ! "$SUDO" -n true 2>/dev/null; then + log "FEHLER: sudo -n fehlgeschlagen." + log " Bitte einmalig: sudo -v (oder NOPASSWD für diesen User)" + log " Dann Skript erneut starten." + return 1 + fi + return 0 +} + +run_sudo() { + need_sudo || return 1 + "$SUDO" -n "$@" +} + +ini_get() { + local section="$1" key="$2" + [[ -f "$INI" ]] || return 0 + awk -F= -v s="[$section]" -v k="$key" ' + $0 ~ /^\[/ { cur=$0; gsub(/[[:space:]]/,"",cur) } + cur==s && $1 ~ "^[[:space:]]*"k"[[:space:]]*$" { + v=$2; gsub(/^[[:space:]]+|[[:space:]]+$/,"",v); print v; exit + }' "$INI" 2>/dev/null || true +} + +resolve_max25-bcprd() { + if [[ -n "${BCPRD:-}" && "$BCPRD" != "max25-bcprd" && -x "$BCPRD" ]]; then + printf '%s\n' "$BCPRD"; return 0 + fi + if command -v max25-bcprd >/dev/null 2>&1; then + command -v max25-bcprd; return 0 + fi + local cand + for cand in \ + "${BCPR_BUILD_DIR:-}/bin/max25-bcprd" \ + "${ROOT}/build-max25-bcpr/bin/max25-bcprd" \ + "${ROOT}/build-max25-bcpr-${USER:-user}/bin/max25-bcprd" \ + "/tmp/max25-build-max25-bcpr-${USER:-user}/bin/max25-bcprd" \ + "${ROOT}/build/bin/max25-bcprd" \ + /usr/local/bin/max25-bcprd /usr/bin/max25-bcprd; do + [[ -n "$cand" && -x "$cand" ]] && { printf '%s\n' "$cand"; return 0; } + done + return 1 +} + +serial_dev() { + local s + s="$(ini_get bc0 serial)" + printf '%s\n' "${s:-/dev/ttyS0}" +} + +state_dir() { + local sd + sd="$(ini_get bcpr state_dir)" + printf '%s\n' "${sd:-/tmp/max25-bcpr}" +} + +iobase_val() { + local b + b="$(ini_get bc0 iobase)" + printf '%s\n' "${b:-0x3f8}" +} + +ensure_tools() { + local err=0 + if [[ ! -x "$CTL" ]]; then + log "FEHLER: max25-bcpr-ctl fehlt: $CTL"; err=1 + fi + if [[ ! -x "$SMOKE_SH" ]]; then + log "FEHLER: max25-bcpr-rxtx-smoke.sh fehlt: $SMOKE_SH"; err=1 + fi + return "$err" +} + +# ---------- Phase helpers ---------- + +# Last smoke host result (PASS|FAIL) — do not capture function stdout (log noise). +_LAST_HOST="—" + +smoke_force_tx() { + # smoke_force_tx SECONDS → sets _LAST_HOST + local secs="$1" + local listen=$((secs + 8)) + local rc=0 + [[ "$listen" -lt 12 ]] && listen=12 + log "→ L4 force-tx ${secs}s (smoke --live --tx --force-tx --tx-seconds ${secs})" + run_sudo "$CTL" -c "$INI" smoke --live --tx --force-tx --seconds "$listen" --tx-seconds "$secs" 2>&1 | log_raw + rc=${PIPESTATUS[0]} + if [[ "$rc" -eq 0 ]]; then + _LAST_HOST="PASS" + log "HOST: PASS (MCR keyed ~${secs}s target)" + else + _LAST_HOST="FAIL" + log "HOST: FAIL (smoke rc=$rc)" + fi +} + +prompt_rf_obs() { + # prompt_rf_obs PHASE_LABEL HOST_RESULT + # sets globals: _watt _bled _rdisp _extra + local label="$1" host="$2" + local watt bled rdisp extra + log "" + log "--- RF-Beobachtung: $label (Host=$host) ---" + log "Bitte Wattmeter / Board-LED / Radio-Display während des Keys prüfen." + ask watt "Wattmeter W (Zahl oder 0 / ?):" + ask_yn bled "Board-LED (PC-COM) an während Key?" + ask_yn rdisp "Radio-Display / TX-Anzeige an?" + ask extra "Kurznotiz (Enter = keine):" + _watt="$watt" + _bled="$bled" + _rdisp="$rdisp" + _extra="$extra" + record_result "$label" "$host" "$watt" "$bled" "$rdisp" "$extra" +} + +# ---------- Phases ---------- + +phase_preflight() { + banner "Phase 1 — Preflight" + local serial sd iobase irq fulldup dry + serial="$(serial_dev)" + sd="$(state_dir)" + iobase="$(iobase_val)" + irq="$(ini_get bc0 irq)" + fulldup="$(ini_get bc0 fulldup)" + dry="$(ini_get bcpr dry_run)" + + log "INI: $INI" + if [[ ! -f "$INI" ]]; then + log "FEHLER: INI fehlt: $INI" + log " Hinweis: Beispiel → stacks/max25-bcpr/share/bcpr.ini.example → /etc/max25/max25-bcpr.ini" + return 1 + fi + + log "--- INI Auszug [bcpr]/[bc0] ---" + log " dry_run=${dry:-?} state_dir=${sd}" + log " serial=${serial} iobase=${iobase} irq=${irq:-?} fulldup=${fulldup:-?}" + log " mode=$(ini_get bc0 mode) kiss_link=$(ini_get bc0 kiss_link)" + log " baud=$(ini_get bc0 baud) tx_delay=$(ini_get bc0 tx_delay)" + + log "--- Seriell ---" + if [[ -e "$serial" ]]; then + log "OK: $serial existiert" + ls -l "$serial" 2>&1 | log_raw || true + else + log "FEHLER: $serial fehlt" + fi + + log "--- fuser / lsof (wer hält Port?) ---" + if command -v fuser >/dev/null 2>&1; then + + run_sudo fuser -v "$serial" 2>&1 | log_raw + + else + log "WARN: fuser nicht installiert" + fi + if command -v lsof >/dev/null 2>&1; then + + run_sudo lsof "$serial" 2>&1 | log_raw + + fi + + log "--- dosbox-x / DOS-Gast ---" + if pgrep -a -f 'dosbox-x|dosbox' 2>/dev/null | log_raw; then + log "WARN: dosbox läuft — kann ttyS0/USB belegen (sniff/passthrough)." + else + log "OK: kein dosbox/dosbox-x Prozess" + fi + + log "--- Prozesse max25-bcprd / max25d ---" + pgrep -a -x max25-bcprd 2>/dev/null | log_raw || log " max25-bcprd: nicht laufend" + pgrep -a -x max25d 2>/dev/null | log_raw || log " max25d: nicht laufend" + # wrapper scripts + pgrep -a -f 'run-max25d|max25d' 2>/dev/null | head -20 | log_raw || true + + log "--- Lock / State ($sd) ---" + if [[ -d "$sd" ]]; then + ls -la "$sd" 2>&1 | log_raw || true + for f in "$sd"/lock* "$sd"/*.lock "$sd"/max25-bcprd.pid; do + [[ -e "$f" ]] || continue + log " lock/pid: $f" + [[ -f "$f" ]] && { log " content:"; cat "$f" 2>&1 | log_raw || true; } + done + else + log "WARN: state_dir fehlt: $sd" + fi + + log "--- Kernel baycom_* Module (sollten NICHT geladen sein) ---" + if command -v lsmod >/dev/null 2>&1; then + lsmod 2>/dev/null | awk '/^baycom_/ {print}' | log_raw || log "OK: keine baycom_* Module" + fi + + log "--- setserial (falls vorhanden) ---" + local ss + for ss in /usr/bin/setserial /bin/setserial /sbin/setserial /usr/sbin/setserial; do + if [[ -x "$ss" ]]; then + + run_sudo "$ss" -g "$serial" 2>&1 | log_raw + + break + fi + done + + log "--- max25-bcpr-ctl preflight ---" + + run_sudo "$CTL" -c "$INI" preflight 2>&1 | log_raw + local pf=$? + + if [[ "$pf" -eq 0 ]]; then + log "OK: preflight PASS" + else + log "WARN: preflight rc=$pf (Port busy wenn Stack läuft — normal bei live max25d/max25-bcprd)" + fi + + log "--- Tools ---" + log " CTL=$CTL" + log " SMOKE=$SMOKE_SH" + local bin + if bin="$(resolve_max25-bcprd)"; then + log " max25-bcprd=$bin" + else + log " WARN: max25-bcprd Binary nicht gefunden (Build: -DMAX25_BUILD_BCPR=ON)" + fi + + pause_enter "Phase 1 fertig — Enter für weiter …" + return 0 +} + +phase_idle_mcr() { + banner "Phase 2 — Idle MCR Sample (/dev/port)" + local iobase mcr_off + iobase="$(iobase_val)" + # MCR = iobase+4 + mcr_off=$((iobase + 4)) + log "iobase=$iobase MCR=$(printf '0x%x' "$mcr_off") (iobase+4)" + log "Erwartung Idle (Sailer/bcpr): oft 0x0d (DTR+OUT2+RTS-clear) — Werte nur Info." + + if ! need_sudo; then + log "überspringe MCR-Sample (kein sudo)" + pause_enter + return 0 + fi + + run_sudo python3 - "$iobase" <<'PY' 2>&1 | log_raw +import os, sys, time +iobase = int(sys.argv[1], 0) +mcr = iobase + 4 +fd = os.open("/dev/port", os.O_RDONLY) +try: + vals = [] + for i in range(20): + os.lseek(fd, mcr, os.SEEK_SET) + v = ord(os.read(fd, 1)) + vals.append(v) + time.sleep(0.05) + uniq = sorted(set(vals)) + print("Idle MCR samples (20×50ms): " + ",".join("0x%02x" % v for v in vals)) + print("Unique: " + ",".join("0x%02x" % v for v in uniq)) + rts = [(v & 0x02) != 0 for v in vals] + print("RTS asserted in any sample: %s" % ("YES" if any(rts) else "no")) + if all(v == 0 for v in vals): + print("WARN: all-zero — /dev/port may be inaccessible or wrong iobase") +finally: + os.close(fd) +PY + local rc=$? + + if [[ "$rc" -ne 0 ]]; then + log "WARN: Idle-MCR Sample fehlgeschlagen (rc=$rc) — CAP_SYS_RAWIO / iobase prüfen" + fi + pause_enter "Phase 2 fertig — Enter …" + return 0 +} + +phase_status() { + banner "Phase 3 — Status + Telemetrie" + + run_sudo "$CTL" -c "$INI" status 2>&1 | log_raw + + local sd + sd="$(state_dir)" + log "--- Telemetrie unter $sd ---" + for f in "$sd"/tx-last-bc* "$sd"/dcd-bc* "$sd"/rx-activity-bc* "$sd"/kiss-bc*; do + [[ -e "$f" ]] || continue + log "FILE: $f" + if [[ -L "$f" ]]; then + log " symlink → $(readlink -f "$f" 2>/dev/null || readlink "$f")" + elif [[ -f "$f" ]]; then + cat "$f" 2>&1 | log_raw || true + fi + done + pause_enter "Phase 3 fertig — Enter …" + return 0 +} + +phase_rx_listen() { + banner "Phase 4 — RX Listen (soft-DCD)" + log "OPERATOR: Squellch öffnen / Rauschen/SQ so dass Soft-DCD aktiv werden kann." + log " (§0.20 RX before TX — hier nur Listen, kein TX)" + pause_enter "SQ bereit? Enter startet ~12s RX listen …" + + run_sudo "$CTL" -c "$INI" smoke --live --seconds 12 2>&1 | log_raw + local rc=${PIPESTATUS[0]} + + if [[ "$rc" -eq 0 ]]; then + log "OK: RX-Listen smoke beendet (rc=0)" + else + log "WARN: RX-Listen smoke rc=$rc (siehe Log; Stack/INI prüfen)" + fi + + local sd + sd="$(state_dir)" + for f in "$sd"/dcd-bc0 "$sd"/rx-activity-bc0; do + [[ -f "$f" ]] || continue + log "Nach RX: $f" + cat "$f" 2>&1 | log_raw || true + done + pause_enter "Phase 4 fertig — Enter …" + return 0 +} + +phase_force_tx_ladder() { + banner "Phase 5 — Force-TX Ladder (1 / 3 / 5 / 8 s)" + log "WARNUNG: --force-tx ohne RX-Nachweis (§0.20 Override) — Debug / Intermittent-Hunt." + log "Wattmeter bereithalten. USB-Pfad ist KEIN Produkt-TX." + log "" + log "PTT-WATCHDOG (FlexNet SER12 / WD-Boards):" + log " Hardware kann PTT nach ~14,5 s Dauer-Key abwerfen (Discharge ~500 ms)." + log " Bei langen Läufen / vielen Keys hintereinander: zwischendurch UNKEY / Pause ~14 s." + log " Sonst: RF fällt trotz Host-MCR PASS (Watchdog, kein UART-Fehler)." + log "" + log "BEOBACHTUNGSHINWEIS TXD (Charge-Pump):" + log " max25-bcprd/Sailer: THR 0x00 gepulst · TFPCX: TXD oft steady +12 V." + log " Notiz wenn RF mitten im Key einbricht / flackert (Pump/Bias-Kandidat)." + pause_enter "Bereit für Ladder? Enter …" + + local secs host + for secs in 1 3 5 8; do + banner "Force-TX ${secs}s" + pause_enter "Wattmeter beobachten — Enter startet ${secs}s Key …" + smoke_force_tx "$secs" + host="$_LAST_HOST" + # show telem snapshot + local sd tl + sd="$(state_dir)" + tl="$sd/tx-last-bc0" + if [[ -f "$tl" ]]; then + log "--- tx-last-bc0 nach ${secs}s ---" + cat "$tl" 2>&1 | log_raw || true + fi + prompt_rf_obs "L4-${secs}s" "$host" + pause_enter "Nächste Stufe — Enter …" + done + return 0 +} + +phase_cal_high() { + banner "Phase 6 — Cal/high (max25-bcprd --cal high)" + local bin + if ! bin="$(resolve_max25-bcprd)"; then + log "WARN: max25-bcprd nicht gefunden — Cal übersprungen" + pause_enter + return 0 + fi + + # Prefer strings(1); never start bare max25-bcprd. Source contract: --cal high|low|alt. + if command -v strings >/dev/null 2>&1 && ! strings "$bin" 2>/dev/null | grep -qF -- '--cal'; then + log "WARN: strings fand kein --cal in Binary — Phase trotzdem anbieten (Operator kann abbrechen)" + fi + + log "max25-bcprd=$bin" + log "Cal = kontinuierlicher SER12 Tone+PTT (DOS cal.exe Stil), kein KISS." + log "Sicher: nur max25-bcprd stoppen via max25-bcpr-ctl — max25d Wrapper NICHT hart killen." + log "" + log "PTT-WATCHDOG: FlexNet cal pausiert alle ~14,5 s für ~500 ms (PTT-Discharge)." + log " Dieses Skript: --cal high ~10 s (unter WD). Längere manuelle Cal/" + log " Dauer-TX: UNKEY/Pause ~14 s einplanen — sonst RF-Tot auf WD-Boards." + log "TXD: max25-bcprd pulst THR 0x00 (nicht TFPCX-steady) — RF-Einbruch mid-cal notieren." + log "" + local do_cal + ask_yn do_cal "max25-bcprd stoppen und --cal high ~10s starten?" + if [[ "$do_cal" != "j" ]]; then + log "Cal übersprungen (Operator)" + pause_enter + return 0 + fi + + if ! need_sudo; then + pause_enter + return 0 + fi + + log "→ max25-bcpr-ctl stop" + + run_sudo "$CTL" -c "$INI" stop 2>&1 | log_raw + + sleep 1 + + # Ensure no stray max25-bcprd + if pgrep -x max25-bcprd >/dev/null 2>&1; then + log "WARN: max25-bcprd noch aktiv nach stop — pkill -x max25-bcprd (kein max25d)" + + run_sudo pkill -x max25-bcprd 2>&1 | log_raw + + sleep 1 + fi + + log "→ $bin -c $INI --cal high --seconds 10" + pause_enter "Wattmeter bereit — Enter startet cal high 10s …" + + run_sudo "$bin" -c "$INI" --cal high --seconds 10 2>&1 | log_raw + local rc=$? + + local host="PASS" + [[ "$rc" -eq 0 ]] || host="FAIL(rc=$rc)" + prompt_rf_obs "CAL-high-10s" "$host" + + local restart + ask_yn restart "max25-bcprd danach wieder starten (max25-bcpr-ctl start)?" + if [[ "$restart" == "j" ]]; then + + run_sudo "$CTL" -c "$INI" start 2>&1 | log_raw + + log "Hinweis: wenn max25d den Stack besitzt, ggf. max25d neu starten (Operator, nicht dieses Skript)." + fi + pause_enter "Phase 6 fertig — Enter …" + return 0 +} + +phase_double_burst() { + banner "Phase 7 — Double Burst (back-to-back TX)" + log "Zwei Force-TX 3s hintereinander (kurze Pause dazwischen)." + pause_enter "Enter startet Burst A …" + local host_a host_b + smoke_force_tx 3 + host_a="$_LAST_HOST" + sleep 1 + log "Burst B …" + smoke_force_tx 3 + host_b="$_LAST_HOST" + prompt_rf_obs "DoubleBurst-A+B" "${host_a}/${host_b}" + pause_enter "Phase 7 fertig — Enter …" + return 0 +} + +phase_contact_hunt() { + banner "Phase 8 — Contact Hunt (DE-9 + 3.5mm)" + log "OPERATOR-CHECKLISTE:" + log " 1) DE-9 (PC-COM ↔ Host) und 3.5mm (Modem ↔ Radio) fest stecken" + log " 2) Während dem nächsten Key leicht wackeln (Stecker/Kabel)" + log " 3) Wattmeter + Board-LED + Radio-Display beobachten" + log " Ziel: intermittenter Kontakt vs. dauerhaft tot unterscheiden" + pause_enter "Stecker fest? Enter startet 5s force-tx (währenddessen wackeln) …" + + local host + smoke_force_tx 5 + host="$_LAST_HOST" + local obs + ask obs "Beobachtung während Wackeln (z.B. 'kurz 2W' / 'immer 0' / 'LED flackert'):" + prompt_rf_obs "Contact-5s" "$host" + # overwrite last note with wiggle observation if empty note + if [[ -n "$obs" && ${#RES_NOTE[@]} -gt 0 ]]; then + RES_NOTE[$((${#RES_NOTE[@]} - 1))]="$obs | ${_extra:-}" + log "[contact-note] $obs" + fi + pause_enter "Phase 8 fertig — Enter …" + return 0 +} + +phase_usb_note() { + banner "Hinweis U — USB SER12 (kein Produkt-TX)" + log "USB-UART (z.B. /dev/ttyUSB0 / FTDI) ist KEIN unterstützter max25-bcprd SER12-Pfad." + log " max25-bcprd braucht ioperm|/dev/port + echte ISA/LPC iobase — USB hat das nicht." + log " Station: RF nur ttyS0+CB; USB0 = Modem ohne Radio → Wattmeter ungültig." + log " DOSBox Soft-TNC auf USB ≠ bcpr Produkt-TX." + pause_enter + return 0 +} + +phase_restart_menu() { + banner "Optional 0 — Sicheres max25-bcprd Restart" + log "Methode (aus max25-bcpr-ctl): stop → start. Killt NICHT max25d." + log "Wenn max25d den Stack besitzt: nach stop/start ggf. max25d-seitig neu binden." + log "" + log " Aktueller Status:" + + run_sudo "$CTL" -c "$INI" status 2>&1 | log_raw + + local act + ask act "Aktion: [s]top / [t]start / [r]estart / [a]bbruch:" + case "${act,,}" in + s|stop) + run_sudo "$CTL" -c "$INI" stop 2>&1 | log_raw + ;; + t|start) + run_sudo "$CTL" -c "$INI" start 2>&1 | log_raw + ;; + r|restart|re) + run_sudo "$CTL" -c "$INI" stop 2>&1 | log_raw + sleep 1 + run_sudo "$CTL" -c "$INI" start 2>&1 | log_raw + ;; + *) + log "Abbruch — keine Änderung" + ;; + esac + + run_sudo "$CTL" -c "$INI" status 2>&1 | log_raw + + pause_enter + return 0 +} + +phase_summary() { + banner "Phase 9 — Summary + Verdict" + log "Logdatei: $LOG" + log "" + log "┌──────────────────┬──────────┬──────────┬──────────┬──────────┐" + log "│ Phase │ Host │ Watt W │ BoardLED │ RadioTX │" + log "├──────────────────┼──────────┼──────────┼──────────┼──────────┤" + local i n + n=${#RES_PHASE[@]} + if [[ "$n" -eq 0 ]]; then + log "│ (keine TX-Phasen aufgezeichnet) │" + else + for ((i = 0; i < n; i++)); do + printf '│ %-16s │ %-8s │ %-8s │ %-8s │ %-8s │\n' \ + "${RES_PHASE[$i]:0:16}" \ + "${RES_HOST[$i]:0:8}" \ + "${RES_WATT[$i]:0:8}" \ + "${RES_BLED[$i]:0:8}" \ + "${RES_RDISP[$i]:0:8}" | log_raw + if [[ -n "${RES_NOTE[$i]:-}" ]]; then + log "│ note: ${RES_NOTE[$i]}" + fi + done + fi + log "└──────────────────┴──────────┴──────────┴──────────┴──────────┘" + + # Verdict heuristics + local any_host_pass=0 any_host_fail=0 any_rf=0 all_rf_zero=1 + for ((i = 0; i < n; i++)); do + case "${RES_HOST[$i]}" in + *PASS*) any_host_pass=1 ;; + *FAIL*) any_host_fail=1 ;; + esac + case "${RES_WATT[$i]}" in + ''|'0'|'0.0'|'?'|'n'|'nein') ;; + *) + # non-zero / non-empty that looks like power + if [[ "${RES_WATT[$i]}" =~ ^[0-9]*\.?[0-9]+$ ]]; then + if awk -v w="${RES_WATT[$i]}" 'BEGIN{exit !(w>0)}'; then + any_rf=1 + all_rf_zero=0 + fi + else + # free text — if contains number >0 heuristic + if [[ "${RES_WATT[$i]}" =~ [1-9] ]]; then + any_rf=1 + all_rf_zero=0 + fi + fi + ;; + esac + if [[ "${RES_BLED[$i]}" == "j" || "${RES_RDISP[$i]}" == "j" ]]; then + any_rf=1 + all_rf_zero=0 + fi + done + + log "" + log "=== Verdict-Hinweise (Heuristik, kein Automatik-Urteil) ===" + if [[ "$n" -eq 0 ]]; then + log "• Keine Ladder-Daten — nur Preflight/Status gelaufen." + elif [[ "$any_host_pass" -eq 1 && "$all_rf_zero" -eq 1 && "$any_rf" -eq 0 ]]; then + log "• HOST OK / RF tot oder 0 W: Fehlerklasse NACH UART" + log " → DE-9/3.5mm Kontakt, Charge-Pump/Vcc, PTT-Transistor, Mic-Buchse, Radio" + log " → Mic-alone 4 W OK stützt: Radio selbst ok; Pfad Modem↔Mic intermittierend" + elif [[ "$any_host_pass" -eq 1 && "$any_rf" -eq 1 ]]; then + log "• HOST OK / RF zeitweise sichtbar: INTERMITTENT RF (Kontakt/Pump/AF)" + log " → Contact-Hunt wiederholen; zwischen langen Keys Pause ~14 s (PTT-WD)" + log " → TXD pulse vs steady (TFPCX) als Beobachtung; Kabel/Stecker fest" + elif [[ "$any_host_fail" -eq 1 && "$any_host_pass" -eq 0 ]]; then + log "• HOST FAIL: zuerst Stack/KISS/fulldup/MCR — nicht primär Wattmeter" + log " → max25-bcpr-ctl status, kiss_link, fulldup=yes bei Soft-DCD, kein zweites max25-bcprd" + log " → MCR bleibt Sailer 0x0e|bit / 0x0d (nicht 4PC-COM 0x0A)" + else + log "• Gemischte Ergebnisse — Log + Notizen vergleichen; Phasen 5/8 wiederholen." + fi + log "" + log "SSoT: operator research notes (private)" + log " 2026-07-19-bcpr-mcr-ok-rf-zero-after-4w.md · winning-recipe · RF intermittent" + log " soft-TNC RE: 2026-07-19-baycom-soft-tnc-serial-ptt-re.md (WD · TXD · MCR)" + log "USB: kein Produkt-TX (Phase U)." + log "" + log "Log gespeichert: $LOG" + return 0 +} + +run_all() { + phase_preflight + phase_idle_mcr + phase_status + phase_rx_listen + phase_force_tx_ladder + phase_cal_high + phase_double_burst + phase_contact_hunt + phase_usb_note + phase_summary +} + +menu_loop() { + while true; do + banner "max25-bcpr Ultimate Diag — Menü" + log "INI=$INI" + log "Log=$LOG" + log "" + log " 1) Preflight" + log " 2) Idle MCR" + log " 3) Status + Telem" + log " 4) RX Listen" + log " 5) Force-TX Ladder 1/3/5/8s" + log " 6) Cal/high" + log " 7) Double Burst" + log " 8) Contact Hunt" + log " 9) Summary / Verdict" + log " A) Alle Phasen 1–9 (+ USB-Hinweis)" + log " 0) max25-bcprd stop/start (sicher)" + log " U) USB-Hinweis" + log " Q) Beenden" + log "" + local choice + ask choice "Wahl:" + case "${choice^^}" in + 1) phase_preflight ;; + 2) phase_idle_mcr ;; + 3) phase_status ;; + 4) phase_rx_listen ;; + 5) phase_force_tx_ladder ;; + 6) phase_cal_high ;; + 7) phase_double_burst ;; + 8) phase_contact_hunt ;; + 9) phase_summary ;; + A|ALL) run_all ;; + 0) phase_restart_menu ;; + U) phase_usb_note ;; + Q|X|EXIT|QUIT) + phase_summary + log "Ende." + return 0 + ;; + *) + log "Unbekannte Wahl: $choice" + ;; + esac + done +} + +# ---------- main ---------- + +while [[ $# -gt 0 ]]; do + case "$1" in + -c) + [[ $# -ge 2 ]] || { echo "ERROR: -c needs INI path"; exit 2; } + INI="$2" + shift 2 + ;; + --menu) MODE="menu"; shift ;; + --all) MODE="all"; shift ;; + --phase) + [[ $# -ge 2 ]] || { echo "ERROR: --phase needs N"; exit 2; } + MODE="phase" + PHASE_ONLY="$2" + shift 2 + ;; + -h|--help) usage; exit 0 ;; + *) + echo "ERROR: unknown arg: $1" + usage + exit 2 + ;; + esac +done + +RUN_TS="$(date +%Y%m%d-%H%M%S)" +LOG="/tmp/max25-bcpr-ultimate-diag-${RUN_TS}.log" +: >"$LOG" || { + echo "FEHLER: kann Log nicht schreiben: $LOG" + exit 1 +} + +# Interactive reads use /dev/tty; log()/log_raw append to $LOG (no exec-tee double). +banner "max25-bcpr Ultimate Diag — BayCom/based (bcpr)" +log "Start: $(date -R 2>/dev/null || date)" +log "Host: $(hostname 2>/dev/null || echo '?')" +log "User: $(id -un 2>/dev/null || echo '?') uid=$(id -u)" +log "INI: $INI (override: MAX25_BCPR_INI / -c)" +log "Log: $LOG" +log "Tools: $CTL" +log "" +log "Hinweis: PTT-Watchdog ~14,5 s / 500 ms — bei langen Keys unkey/pausieren." +log " TXD: max25-bcprd pulst (Sailer); TFPCX oft steady — RF mid-key notieren." +log " MCR: Sailer belassen (kein 4PC-COM 0x0A-Experiment in diesem Skript)." +log "" + +ensure_tools || { + log "Abbruch: Tools fehlen." + exit 1 +} + +# Soft check sudo early (warn only for menu; hard for --all live) +if ! need_sudo; then + log "WARN: ohne sudo -n sind Live/TX/Cal/MCR-Phasen blockiert." + if [[ "$MODE" == "all" ]]; then + log "Abbruch (--all braucht sudo -n)." + exit 1 + fi +fi + +case "$MODE" in + all) + run_all + ;; + phase) + case "$PHASE_ONLY" in + 1) phase_preflight ;; + 2) phase_idle_mcr ;; + 3) phase_status ;; + 4) phase_rx_listen ;; + 5) phase_force_tx_ladder ;; + 6) phase_cal_high ;; + 7) phase_double_burst ;; + 8) phase_contact_hunt ;; + 9) phase_summary ;; + 0) phase_restart_menu ;; + U|u) phase_usb_note ;; + *) + log "FEHLER: unbekannte Phase $PHASE_ONLY" + exit 2 + ;; + esac + ;; + menu|*) + menu_loop + ;; +esac + +log "" +log "Fertig. Log: $LOG" +exit 0 diff --git a/stacks/terminal/.gitignore b/stacks/terminal/.gitignore new file mode 100644 index 0000000..bb6aac1 --- /dev/null +++ b/stacks/terminal/.gitignore @@ -0,0 +1,9 @@ +# Built client artifacts (make -C stacks/terminal all) +max25-terminal +max25-client +*.o +amiga/max25-terminal +amiga/*.o + +# vault/agent — never ship +AGENT-INDEX.md diff --git a/stacks/terminal/CMakeLists.txt b/stacks/terminal/CMakeLists.txt new file mode 100644 index 0000000..d391b9e --- /dev/null +++ b/stacks/terminal/CMakeLists.txt @@ -0,0 +1,40 @@ +set(MAX25_TERMINAL_SRCS + max25_terminal.c + max25_proto.c + max25_ui.c +) + +add_executable(max25-terminal ${MAX25_TERMINAL_SRCS}) +target_include_directories(max25-terminal PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") + +find_package(Curses) +if(Curses_FOUND AND TARGET Curses::Curses) + target_compile_definitions(max25-terminal PRIVATE MAX25_HAVE_NCURSES) + target_link_libraries(max25-terminal PRIVATE Curses::Curses) +elseif(Curses_FOUND) + target_compile_definitions(max25-terminal PRIVATE MAX25_HAVE_NCURSES) + target_include_directories(max25-terminal PRIVATE ${CURSES_INCLUDE_DIR}) + target_link_libraries(max25-terminal PRIVATE ${CURSES_LIBRARIES}) +elseif(UNIX AND NOT APPLE) + target_compile_definitions(max25-terminal PRIVATE MAX25_HAVE_NCURSES) + target_link_libraries(max25-terminal PRIVATE ncurses tinfo) +endif() + +target_compile_options(max25-terminal PRIVATE -Wall -Wextra -std=c11 -O2) + +add_custom_command(TARGET max25-terminal POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E rm -f "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/max25-client" + COMMAND "${CMAKE_COMMAND}" -E create_symlink max25-terminal "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/max25-client" + COMMENT "max25-client -> max25-terminal" +) + +install(TARGETS max25-terminal RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}") + +install(CODE " + set(_dst \"\$ENV{DESTDIR}\${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_BINDIR}/max25-client\") + set(_src \"\$ENV{DESTDIR}\${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_BINDIR}/max25-terminal\") + if(EXISTS \"\${_dst}\") + file(REMOVE \"\${_dst}\") + endif() + execute_process(COMMAND \"${CMAKE_COMMAND}\" -E create_symlink max25-terminal \"\${_dst}\") +") diff --git a/stacks/terminal/README.md b/stacks/terminal/README.md new file mode 100644 index 0000000..6b0dea1 --- /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 | +|-----|----------| +| [docs/MAX25-CLIENT.md](../../docs/MAX25-CLIENT.md) | Developer — M25/1 binding | +| [docs/MAX25-TERMINAL.md](../../docs/MAX25-TERMINAL.md) | Operator — F10 menu, usage | + +## Build & test + +```bash +./scripts/build.sh +bash stacks/terminal/test-terminal.sh +``` + +Requires C11 compiler and ncurses (`libncurses-dev` on Linux). + +## Run (canonical) + +Prefer install/PATH or `build*/bin/max25-terminal`. Tree binary may live at `./max25-terminal` after copy (gitignored). + +```bash +max25-terminal -U /run/max25/modem.sock +# with [devices] default set in max25d.ini — no -d needed +# then: CONNECT (F10→6) → SEND … +``` + +Optional: `-d <id>` / `SET DEVICE` / F10→7 to override the default device. + +Daemon: [stacks/daemon/README.md](../daemon/README.md) — **not** under this directory. diff --git a/stacks/terminal/amiga/Makefile b/stacks/terminal/amiga/Makefile new file mode 100644 index 0000000..84cc53b --- /dev/null +++ b/stacks/terminal/amiga/Makefile @@ -0,0 +1,32 @@ +# MAX25 Terminal — AmigaOS 3.9+ cross-build (bebbo-gcc / clib2) + +AMIGA_SDK ?= /opt/amiga +CC := $(AMIGA_SDK)/bin/m68k-amigaos-gcc +CFLAGS ?= -Wall -Wextra -std=c11 -O2 -mcrt=clib2 +LDFLAGS ?= -mcrt=clib2 +LIBS := -lnet -lunix + +PROTO_DIR := .. +SRCS := max25_terminal_amiga.c $(PROTO_DIR)/max25_proto.c +OBJS := max25_terminal_amiga.o max25_proto.o +TARGET := max25-terminal + +.PHONY: all test clean + +all: $(TARGET) + +$(TARGET): $(OBJS) + $(CC) $(LDFLAGS) -o $@ $(OBJS) $(LIBS) + +max25_terminal_amiga.o: max25_terminal_amiga.c $(PROTO_DIR)/max25_proto.h + $(CC) $(CFLAGS) -DMAX25_AMIGA=1 -I$(PROTO_DIR) -c -o $@ max25_terminal_amiga.c + +max25_proto.o: $(PROTO_DIR)/max25_proto.c $(PROTO_DIR)/max25_proto.h + $(CC) $(CFLAGS) -DMAX25_AMIGA=1 -I$(PROTO_DIR) -c -o $@ $(PROTO_DIR)/max25_proto.c + +test: all + @test -x ./$(TARGET) + file ./$(TARGET) | grep -qiE 'amiga|68k|m68k' && echo "OK: Amiga m68k binary" + +clean: + rm -f $(OBJS) $(TARGET) diff --git a/stacks/terminal/amiga/max25_terminal_amiga.c b/stacks/terminal/amiga/max25_terminal_amiga.c new file mode 100644 index 0000000..3b0f513 --- /dev/null +++ b/stacks/terminal/amiga/max25_terminal_amiga.c @@ -0,0 +1,309 @@ +/* + * max25-terminal — AmigaOS 3.9+ reduced operator client (TCP/M25/1 only). + * + * HyBBX-style thin client: no ncurses/F10 menu; connects to Linux max25d. + */ +#define _DEFAULT_SOURCE 1 + +#include "../max25_proto.h" + +#include <errno.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> +#include <sys/select.h> + +#ifdef __AMIGA__ +#include <sys/socket.h> +#endif + +typedef struct { + char host[256]; + unsigned port; + char tcp_password[128]; + int ax25_ui; + int verbose; + int probe; +} amiga_config_t; + +static void config_defaults(amiga_config_t *cfg) +{ + const char *env; + + memset(cfg, 0, sizeof(*cfg)); + strncpy(cfg->host, "127.0.0.1", sizeof(cfg->host) - 1); + cfg->port = MAX25_DEFAULT_PORT; + cfg->ax25_ui = 1; + + env = getenv("MAX25_HOST"); + if (env != NULL && env[0] != '\0') { + strncpy(cfg->host, env, sizeof(cfg->host) - 1); + } + env = getenv("MAX25_PORT"); + if (env != NULL && env[0] != '\0') { + cfg->port = (unsigned)strtoul(env, NULL, 10); + } + env = getenv("MAX25_TCP_PASSWORD"); + if (env != NULL) { + strncpy(cfg->tcp_password, env, sizeof(cfg->tcp_password) - 1); + } +} + +static void usage(const char *prog) +{ + fprintf(stderr, + "MAX25 Terminal (AmigaOS) — reduced CLI client for max25d\n\n" + "Usage: %s [options] [host] [port]\n\n" + "Options:\n" + " -H HOST max25d TCP host (default 127.0.0.1)\n" + " -p PORT max25d TCP port (default %u)\n" + " -P PASSWORD TCP auth password (plain-text AUTH)\n" + " --ax25-ui AX.25 UI mode (default on)\n" + " --no-ax25-ui\n" + " -v Verbose protocol log on stderr\n" + " --probe Connect, print STATUS, exit\n" + " -h Help\n\n" + "Session: type a line to SEND; /status /callerid /callid /connect /quit\n" + "Environment: MAX25_HOST, MAX25_PORT, MAX25_TCP_PASSWORD\n\n", + prog, MAX25_DEFAULT_PORT); +} + +static int parse_args(int argc, char **argv, amiga_config_t *cfg) +{ + int i; + int positional = 0; + + for (i = 1; i < argc; i++) { + if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { + usage(argv[0]); + return 1; + } + if (strcmp(argv[i], "-H") == 0 && i + 1 < argc) { + strncpy(cfg->host, argv[++i], sizeof(cfg->host) - 1); + continue; + } + if (strcmp(argv[i], "-p") == 0 && i + 1 < argc) { + cfg->port = (unsigned)strtoul(argv[++i], NULL, 10); + continue; + } + if ((strcmp(argv[i], "-P") == 0 || strcmp(argv[i], "--password") == 0) && + i + 1 < argc) { + strncpy(cfg->tcp_password, argv[++i], sizeof(cfg->tcp_password) - 1); + continue; + } + if (strcmp(argv[i], "-v") == 0 || strcmp(argv[i], "--verbose") == 0) { + cfg->verbose = 1; + continue; + } + if (strcmp(argv[i], "--probe") == 0) { + cfg->probe = 1; + continue; + } + if (strcmp(argv[i], "--ax25-ui") == 0) { + cfg->ax25_ui = 1; + continue; + } + if (strcmp(argv[i], "--no-ax25-ui") == 0) { + cfg->ax25_ui = 0; + continue; + } + if (argv[i][0] == '-') { + fprintf(stderr, "%s: unknown option %s\n", argv[0], argv[i]); + return -1; + } + if (positional == 0) { + strncpy(cfg->host, argv[i], sizeof(cfg->host) - 1); + } else if (positional == 1) { + cfg->port = (unsigned)strtoul(argv[i], NULL, 10); + } + positional++; + } + return 0; +} + +static void print_rx(const char *line, int verbose) +{ + if (verbose) { + fprintf(stderr, "max25-terminal: << %s\n", line); + } + if (strncmp(line, "RX ", 3) == 0) { + printf("<< %s\n", line + 3); + } else if (strncmp(line, "ERR ", 4) == 0) { + fprintf(stderr, "!! %s\n", line); + } else if (strncmp(line, "EVENT ", 6) == 0) { + printf("-- %s\n", line); + } + fflush(stdout); + fflush(stderr); +} + +static int drain_socket(max25_client_t *client, int verbose) +{ + char line[MAX25_LINE_MAX]; + int got = 0; + + for (;;) { + fd_set rfds; + struct timeval tv; + int fd = max25_client_fd(client); + + FD_ZERO(&rfds); + FD_SET(fd, &rfds); + tv.tv_sec = 0; + tv.tv_usec = 0; + if (select(fd + 1, &rfds, NULL, NULL, &tv) <= 0) { + break; + } + if (max25_client_read_line(client, line, sizeof(line)) != 0) { + return -1; + } + print_rx(line, verbose); + got = 1; + if (strcmp(line, "OK") == 0) { + break; + } + } + return got ? 0 : 0; +} + +static int send_cmd_wait(max25_client_t *client, const char *cmd, int verbose) +{ + char line[MAX25_LINE_MAX]; + + if (verbose) { + fprintf(stderr, "max25-terminal: >> %s\n", cmd); + } + if (max25_client_write(client, cmd) != 0) { + return -1; + } + for (;;) { + if (max25_client_read_line(client, line, sizeof(line)) != 0) { + return -1; + } + print_rx(line, verbose); + if (strncmp(line, "STATUS ", 7) == 0) { + continue; + } + if (strncmp(line, "RX ", 3) == 0) { + continue; + } + if (strncmp(line, "EVENT ", 6) == 0) { + continue; + } + return strcmp(line, "OK") == 0 ? 0 : -1; + } +} + +static int run_session(max25_client_t *client, amiga_config_t *cfg, + max25_status_t *status) +{ + char line[MAX25_LINE_MAX]; + + printf("MAX25 Terminal (Amiga) — connected to %s:%u\n", cfg->host, cfg->port); + printf("Type text to SEND. Commands: /status /callerid X /callid X /connect /quit\n"); + + if (!status->connected) { + send_cmd_wait(client, "CONNECT", cfg->verbose); + status->connected = 1; + } + + while (fgets(line, sizeof(line), stdin) != NULL) { + line[strcspn(line, "\r\n")] = '\0'; + if (line[0] == '\0') { + continue; + } + if (strcmp(line, "/quit") == 0 || strcmp(line, "/exit") == 0) { + break; + } + if (strcmp(line, "/status") == 0) { + send_cmd_wait(client, "GET STATUS", cfg->verbose); + continue; + } + if (strcmp(line, "/connect") == 0) { + send_cmd_wait(client, "CONNECT", cfg->verbose); + status->connected = 1; + continue; + } + if (strcmp(line, "/disconnect") == 0) { + send_cmd_wait(client, "DISCONNECT", cfg->verbose); + status->connected = 0; + continue; + } + if (strncmp(line, "/callerid ", 10) == 0) { + char cmd[MAX25_LINE_MAX]; + if (!max25_valid_callsign(line + 10)) { + fprintf(stderr, "invalid CALLERID\n"); + continue; + } + snprintf(cmd, sizeof(cmd), "SET CALLERID %s", line + 10); + send_cmd_wait(client, cmd, cfg->verbose); + continue; + } + if (strncmp(line, "/callid ", 8) == 0) { + char cmd[MAX25_LINE_MAX]; + if (!max25_valid_callsign(line + 8)) { + fprintf(stderr, "invalid CALLID\n"); + continue; + } + snprintf(cmd, sizeof(cmd), "SET CALLID %s", line + 8); + send_cmd_wait(client, cmd, cfg->verbose); + continue; + } + { + char cmd[MAX25_LINE_MAX + 8]; + snprintf(cmd, sizeof(cmd), "SEND %s", line); + send_cmd_wait(client, cmd, cfg->verbose); + } + (void)drain_socket(client, cfg->verbose); + } + return 0; +} + +int main(int argc, char **argv) +{ + amiga_config_t cfg; + max25_client_t *client = NULL; + max25_status_t status; + char reply[MAX25_LINE_MAX]; + int rc; + + config_defaults(&cfg); + rc = parse_args(argc, argv, &cfg); + if (rc != 0) { + return rc < 0 ? EXIT_FAILURE : EXIT_SUCCESS; + } + + if (max25_client_connect(cfg.host, cfg.port, "", 1, cfg.tcp_password, + &client, &status) != 0) { + fprintf(stderr, "max25-terminal: connect failed (tcp %s:%u)\n", + cfg.host, cfg.port); + return EXIT_FAILURE; + } + + if (cfg.probe) { + printf("STATUS hardware=%s device=%s mode=%s callerid=%s callid=%s " + "ax25_ui=%s connected=%s stack=%s\n", + status.hardware, status.device, status.mode, + status.callerid, status.callid, + status.ax25_ui ? "on" : "off", + status.connected ? "yes" : "no", + status.stack); + max25_client_write(client, "DISCONNECT"); + max25_client_free(client); + return EXIT_SUCCESS; + } + + if (cfg.ax25_ui) { + max25_client_command(client, "SET AX25_UI on", reply, sizeof(reply)); + status.ax25_ui = 1; + } else { + max25_client_command(client, "SET AX25_UI off", reply, sizeof(reply)); + status.ax25_ui = 0; + } + + run_session(client, &cfg, &status); + max25_client_write(client, "DISCONNECT"); + max25_client_free(client); + return EXIT_SUCCESS; +} diff --git a/stacks/terminal/max25_proto.c b/stacks/terminal/max25_proto.c new file mode 100644 index 0000000..b4a9725 --- /dev/null +++ b/stacks/terminal/max25_proto.c @@ -0,0 +1,472 @@ +#define _POSIX_C_SOURCE 200809L + +#include "max25_proto.h" + +#if defined(__AMIGA__) || defined(__amigaos__) +#define MAX25_AMIGA 1 +#endif + +#include <ctype.h> +#include <errno.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <stdint.h> +#include <unistd.h> + +#ifndef MAX25_AMIGA +#include <netdb.h> +#include <sys/socket.h> +#include <sys/un.h> +#else +#include <sys/socket.h> +#include <netinet/in.h> +#include <arpa/inet.h> +#include <netdb.h> +#endif + +struct max25_client { + int fd; +}; + +static int read_line_fd(int fd, char *line, size_t line_sz) +{ + size_t pos = 0; + char ch; + + while (pos + 1 < line_sz) { + ssize_t n = read(fd, &ch, 1); + if (n < 0) { + if (errno == EINTR) { + continue; + } + return -1; + } + if (n == 0) { + return -1; + } + if (ch == '\n') { + line[pos] = '\0'; + return 0; + } + if (ch != '\r') { + line[pos++] = ch; + } + } + return -1; +} + +static int write_cmd(int fd, const char *cmd) +{ + char buf[MAX25_LINE_MAX + 2]; + int len = snprintf(buf, sizeof(buf), "%s\n", cmd); + ssize_t off = 0; + + if (len < 0 || (size_t)len >= sizeof(buf)) { + return -1; + } + while (off < len) { + ssize_t n = write(fd, buf + off, (size_t)len - (size_t)off); + if (n < 0) { + if (errno == EINTR) { + continue; + } + return -1; + } + off += n; + } + return 0; +} + +static int connect_tcp(const char *host, unsigned port) +{ +#ifdef MAX25_AMIGA + struct hostent *he; + struct sockaddr_in addr; + int fd; + + he = gethostbyname(host); + if (he == NULL || he->h_addr_list[0] == NULL) { + return -1; + } + + fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) { + return -1; + } + + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons((uint16_t)port); + memcpy(&addr.sin_addr, he->h_addr_list[0], (size_t)he->h_length); + + if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) { + close(fd); + return -1; + } + return fd; +#else + char portbuf[16]; + struct addrinfo hints; + struct addrinfo *res = NULL; + struct addrinfo *ai; + int fd = -1; + + snprintf(portbuf, sizeof(portbuf), "%u", port); + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + + if (getaddrinfo(host, portbuf, &hints, &res) != 0) { + return -1; + } + + for (ai = res; ai != NULL; ai = ai->ai_next) { + fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol); + if (fd < 0) { + continue; + } + if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) { + break; + } + close(fd); + fd = -1; + } + freeaddrinfo(res); + return fd; +#endif +} + +static int connect_unix(const char *path) +{ +#ifndef MAX25_AMIGA + struct sockaddr_un addr; + int fd; + + if (path == NULL || path[0] == '\0') { + return -1; + } + + fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) { + return -1; + } + + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1); + + if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) { + close(fd); + return -1; + } + return fd; +#else + (void)path; + return -1; +#endif +} + +static int tcp_authenticate(int fd, const char *password) +{ + char line[MAX25_LINE_MAX]; + char cmd[MAX25_LINE_MAX + 8]; + + if (password == NULL || password[0] == '\0') { + return -1; + } + snprintf(cmd, sizeof(cmd), "AUTH %s", password); + if (write_cmd(fd, cmd) != 0) { + return -1; + } + if (read_line_fd(fd, line, sizeof(line)) != 0) { + return -1; + } + return strcmp(line, "OK") == 0 ? 0 : -1; +} + +static int handshake(int fd, const char *tcp_password, max25_status_t *initial_status) +{ + char line[MAX25_LINE_MAX]; + + if (read_line_fd(fd, line, sizeof(line)) != 0) { + return -1; + } + if (strcmp(line, "AUTH required") == 0) { + if (tcp_authenticate(fd, tcp_password) != 0) { + return -1; + } + if (read_line_fd(fd, line, sizeof(line)) != 0 || strcmp(line, "OK") != 0) { + return -1; + } + } else if (strcmp(line, "OK") != 0) { + return -1; + } + + if (read_line_fd(fd, line, sizeof(line)) != 0 || + strncmp(line, "STATUS ", 7) != 0) { + return -1; + } + + if (initial_status != NULL) { + max25_client_parse_status(line, initial_status); + } + return 0; +} + +int max25_client_connect(const char *host, unsigned port, const char *unix_path, + int tcp_only, const char *tcp_password, + max25_client_t **out, max25_status_t *initial_status) +{ + max25_client_t *client; + int fd = -1; + + if (out == NULL) { + return -1; + } + + if (!tcp_only && unix_path != NULL && unix_path[0] != '\0') { +#ifndef MAX25_AMIGA + fd = connect_unix(unix_path); +#endif + } + if (fd < 0 && host != NULL && host[0] != '\0') { + fd = connect_tcp(host, port); + } + if (fd < 0) { + return -1; + } + + client = calloc(1, sizeof(*client)); + if (client == NULL) { + close(fd); + return -1; + } + client->fd = fd; + + if (handshake(fd, tcp_password, initial_status) != 0) { + max25_client_free(client); + return -1; + } + + *out = client; + return 0; +} + +void max25_client_free(max25_client_t *client) +{ + if (client == NULL) { + return; + } + if (client->fd >= 0) { + close(client->fd); + } + free(client); +} + +int max25_client_fd(const max25_client_t *client) +{ + return client != NULL ? client->fd : -1; +} + +int max25_client_write(max25_client_t *client, const char *cmd) +{ + if (client == NULL || cmd == NULL) { + return -1; + } + return write_cmd(client->fd, cmd); +} + +max25_line_kind_t max25_client_classify_line(const char *line) +{ + if (line == NULL || line[0] == '\0') { + return MAX25_LINE_IGNORE; + } + if (strcmp(line, "OK") == 0) { + return MAX25_LINE_OK; + } + if (strncmp(line, "ERR ", 4) == 0) { + return MAX25_LINE_ERR; + } + if (strncmp(line, "STATUS ", 7) == 0) { + return MAX25_LINE_STATUS; + } + if (strncmp(line, "RX ", 3) == 0) { + return MAX25_LINE_RX; + } + if (strncmp(line, "EVENT ", 6) == 0) { + return MAX25_LINE_EVENT; + } + if (strncmp(line, "DEVICE ", 7) == 0) { + return MAX25_LINE_DEVICE; + } + return MAX25_LINE_OTHER; +} + +int max25_client_command(max25_client_t *client, const char *cmd, + char *reply, size_t reply_sz) +{ + char line[MAX25_LINE_MAX]; + + if (client == NULL || cmd == NULL) { + return -1; + } + if (write_cmd(client->fd, cmd) != 0) { + return -1; + } + for (;;) { + if (read_line_fd(client->fd, line, sizeof(line)) != 0) { + return -1; + } + if (strncmp(line, "STATUS ", 7) == 0) { + if (reply != NULL && reply_sz > 0) { + strncpy(reply, line, reply_sz - 1); + reply[reply_sz - 1] = '\0'; + } + continue; + } + if (strncmp(line, "RX ", 3) == 0) { + continue; + } + if (strncmp(line, "EVENT ", 6) == 0) { + continue; + } + if (strncmp(line, "DEVICE ", 7) == 0) { + continue; + } + /* GET STATUS replies: STATUS … then OK — keep STATUS in reply. */ + if (strcmp(line, "OK") == 0) { + if (reply != NULL && reply_sz > 0 && + strncmp(reply, "STATUS ", 7) != 0) { + strncpy(reply, line, reply_sz - 1); + reply[reply_sz - 1] = '\0'; + } + return 0; + } + if (reply != NULL && reply_sz > 0) { + strncpy(reply, line, reply_sz - 1); + reply[reply_sz - 1] = '\0'; + } + return -1; + } +} + +int max25_client_read_line(max25_client_t *client, char *line, size_t line_sz) +{ + if (client == NULL) { + return -1; + } + return read_line_fd(client->fd, line, line_sz); +} + +static void copy_field(const char *src, char *dst, size_t dst_sz) +{ + if (dst == NULL || dst_sz == 0) { + return; + } + if (src == NULL) { + dst[0] = '\0'; + return; + } + strncpy(dst, src, dst_sz - 1); + dst[dst_sz - 1] = '\0'; +} + +int max25_client_apply_event(const char *line, max25_status_t *status) +{ + if (line == NULL || status == NULL || strncmp(line, "EVENT ", 6) != 0) { + return 0; + } + if (strcmp(line, "EVENT connected") == 0) { + status->connected = 1; + return 1; + } + if (strcmp(line, "EVENT disconnected") == 0) { + status->connected = 0; + return 1; + } + return 0; +} + +int max25_client_parse_status(const char *line, max25_status_t *status) +{ + char buf[MAX25_LINE_MAX]; + char *save = NULL; + char *tok; + + if (line == NULL || status == NULL || strncmp(line, "STATUS ", 7) != 0) { + return -1; + } + + strncpy(buf, line + 7, sizeof(buf) - 1); + buf[sizeof(buf) - 1] = '\0'; + + memset(status, 0, sizeof(*status)); + for (tok = strtok_r(buf, " ", &save); tok != NULL; + tok = strtok_r(NULL, " ", &save)) { + char *eq = strchr(tok, '='); + if (eq == NULL) { + continue; + } + *eq = '\0'; + if (strcmp(tok, "hardware") == 0) { + copy_field(eq + 1, status->hardware, sizeof(status->hardware)); + } else if (strcmp(tok, "device") == 0) { + copy_field(eq + 1, status->device, sizeof(status->device)); + } else if (strcmp(tok, "mode") == 0) { + copy_field(eq + 1, status->mode, sizeof(status->mode)); + } else if (strcmp(tok, "callerid") == 0) { + copy_field(eq + 1, status->callerid, sizeof(status->callerid)); + } else if (strcmp(tok, "callid") == 0) { + copy_field(eq + 1, status->callid, sizeof(status->callid)); + } else if (strcmp(tok, "ax25_ui") == 0) { + status->ax25_ui = (strcmp(eq + 1, "on") == 0); + } else if (strcmp(tok, "connected") == 0) { + status->connected = (strcmp(eq + 1, "yes") == 0); + } else if (strcmp(tok, "stack") == 0) { + copy_field(eq + 1, status->stack, sizeof(status->stack)); + } else if (strcmp(tok, "error") == 0) { + copy_field(eq + 1, status->error, sizeof(status->error)); + } else if (strcmp(tok, "voice") == 0) { + copy_field(eq + 1, status->voice, sizeof(status->voice)); + } + } + return 0; +} + +int max25_valid_callsign(const char *value) +{ + const char *dash; + size_t call_len; + unsigned long ssid; + char *end = NULL; + size_t i; + + if (value == NULL) { + return 0; + } + + dash = strchr(value, '-'); + if (dash != NULL) { + call_len = (size_t)(dash - value); + ssid = strtoul(dash + 1, &end, 10); + if (end == dash + 1 || ssid > 15) { + return 0; + } + } else { + call_len = strlen(value); + } + + if (call_len == 0 || call_len > 6) { + return 0; + } + + for (i = 0; i < call_len; i++) { + char c = (char)toupper((unsigned char)value[i]); + if (!((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'))) { + return 0; + } + } + + return 1; +} diff --git a/stacks/terminal/max25_proto.h b/stacks/terminal/max25_proto.h new file mode 100644 index 0000000..25ae5d5 --- /dev/null +++ b/stacks/terminal/max25_proto.h @@ -0,0 +1,55 @@ +#ifndef MAX25_PROTO_H +#define MAX25_PROTO_H + +#include <stddef.h> + +#define MAX25_DEFAULT_PORT 7325 +#define MAX25_DEFAULT_UNIX "/run/max25/modem.sock" +#define MAX25_LINE_MAX 512 +#define MAX25_CALLSIGN_MAX 16 + +typedef struct max25_client max25_client_t; + +typedef struct max25_status { + char hardware[32]; + char device[32]; + char mode[32]; + char callerid[MAX25_CALLSIGN_MAX]; + char callid[MAX25_CALLSIGN_MAX]; + int ax25_ui; + int connected; + int monitor; + char stack[32]; + char error[16]; + char voice[16]; +} max25_status_t; + +typedef enum { + MAX25_LINE_IGNORE = 0, + MAX25_LINE_OK, + MAX25_LINE_ERR, + MAX25_LINE_STATUS, + MAX25_LINE_RX, + MAX25_LINE_EVENT, + MAX25_LINE_DEVICE, + MAX25_LINE_OTHER +} max25_line_kind_t; + +int max25_client_connect(const char *host, unsigned port, const char *unix_path, + int tcp_only, const char *tcp_password, + max25_client_t **out, max25_status_t *initial_status); +void max25_client_free(max25_client_t *client); +int max25_client_fd(const max25_client_t *client); + +int max25_client_write(max25_client_t *client, const char *cmd); +int max25_client_command(max25_client_t *client, const char *cmd, + char *reply, size_t reply_sz); +int max25_client_read_line(max25_client_t *client, char *line, size_t line_sz); +max25_line_kind_t max25_client_classify_line(const char *line); +int max25_client_parse_status(const char *line, max25_status_t *status); +/** Apply EVENT connected / EVENT disconnected to status. Returns 1 if handled. */ +int max25_client_apply_event(const char *line, max25_status_t *status); + +int max25_valid_callsign(const char *value); + +#endif /* MAX25_PROTO_H */ diff --git a/stacks/terminal/max25_terminal.c b/stacks/terminal/max25_terminal.c new file mode 100644 index 0000000..8b4eb52 --- /dev/null +++ b/stacks/terminal/max25_terminal.c @@ -0,0 +1,767 @@ +#define _POSIX_C_SOURCE 200809L + +#include "max25_proto.h" +#include "max25_ui.h" + +#include <errno.h> +#include <getopt.h> +#include <poll.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <termios.h> +#include <unistd.h> + +#ifdef MAX25_HAVE_NCURSES +#include <ncurses.h> +#endif + +typedef struct { + char host[256]; + unsigned port; + char unix_path[256]; + char tcp_password[128]; + char default_device[32]; + int tcp_only; + int ax25_ui; + int verbose; + int probe; +} terminal_config_t; + +typedef struct { + int waiting_reply; + char last_err[MAX25_LINE_MAX]; +} session_io_t; + +static struct termios g_saved_tty; +static int g_tty_raw; + +static void config_defaults(terminal_config_t *cfg) +{ + const char *env; + + memset(cfg, 0, sizeof(*cfg)); + strncpy(cfg->host, "127.0.0.1", sizeof(cfg->host) - 1); + cfg->port = MAX25_DEFAULT_PORT; + strncpy(cfg->unix_path, MAX25_DEFAULT_UNIX, sizeof(cfg->unix_path) - 1); + cfg->ax25_ui = 1; + + env = getenv("MAX25_HOST"); + if (env != NULL && env[0] != '\0') { + strncpy(cfg->host, env, sizeof(cfg->host) - 1); + } + env = getenv("MAX25_PORT"); + if (env != NULL && env[0] != '\0') { + cfg->port = (unsigned)strtoul(env, NULL, 10); + } + env = getenv("MAX25_UNIX"); + if (env != NULL) { + strncpy(cfg->unix_path, env, sizeof(cfg->unix_path) - 1); + } + env = getenv("MAX25_TCP_PASSWORD"); + if (env != NULL) { + strncpy(cfg->tcp_password, env, sizeof(cfg->tcp_password) - 1); + } +} + +static void usage(const char *prog) +{ + fprintf(stderr, + "MAX25 Terminal — CLI operator client (max25d via TCP/M25/1)\n\n" + "Usage: %s [options] [host] [port]\n\n" + "Options:\n" + " -H, --host HOST max25d TCP host (default 127.0.0.1)\n" + " -p, --port PORT max25d TCP port (default %u)\n" + " -T, --tcp-only TCP only — do not try Unix socket\n" + " -U, --unix PATH Unix socket (default %s, before TCP)\n" + " --ax25-ui AX.25 UI mode (default on)\n" + " --no-ax25-ui Plain SEND lines\n" + " -P, --password PASS TCP auth password (plain-text M25/1 AUTH)\n" + " -d, --device ID SET DEVICE after connect (configured device id)\n" + " -v, --verbose Protocol log on stderr\n" + " --probe Connect, print STATUS, exit (CI)\n" + " -h, --help Show help\n\n" + "Remote example:\n" + " %s -T -H linux-host.local -p 7325\n\n" + "Environment: MAX25_HOST, MAX25_PORT, MAX25_UNIX, MAX25_TCP_PASSWORD\n" + "Symlink: max25-client -> max25-terminal\n\n", + prog, MAX25_DEFAULT_PORT, MAX25_DEFAULT_UNIX, prog); +} + +static int tty_enable_raw(void) +{ + struct termios tty; + + if (!isatty(STDIN_FILENO)) { + return 0; + } + if (tcgetattr(STDIN_FILENO, &g_saved_tty) != 0) { + return -1; + } + tty = g_saved_tty; + tty.c_lflag &= (tcflag_t)~(ICANON | ECHO); + tty.c_cc[VMIN] = 0; + tty.c_cc[VTIME] = 1; + if (tcsetattr(STDIN_FILENO, TCSANOW, &tty) != 0) { + return -1; + } + g_tty_raw = 1; + return 0; +} + +static void tty_restore(void) +{ + if (g_tty_raw) { + tcsetattr(STDIN_FILENO, TCSANOW, &g_saved_tty); + g_tty_raw = 0; + } +} + +static int begin_cmd(session_io_t *io) +{ + if (io == NULL) { + return -1; + } + io->waiting_reply = 1; + io->last_err[0] = '\0'; + return 0; +} + +static int send_cmd_async(max25_client_t *client, session_io_t *io, + const char *cmd) +{ + if (max25_client_write(client, cmd) != 0) { + return -1; + } + return begin_cmd(io); +} + +static int dispatch_daemon_line(const char *line, session_io_t *io, + max25_ui_t *ui, max25_status_t *status, + terminal_config_t *cfg, int *need_redraw) +{ + max25_line_kind_t kind = max25_client_classify_line(line); + + if (need_redraw != NULL) { + *need_redraw = 0; + } + + if (cfg->verbose) { + fprintf(stderr, "max25-terminal: << %s\n", line); + } + + if (kind == MAX25_LINE_RX) { + max25_ui_append_rx(ui, line + 3); + if (need_redraw != NULL) { + *need_redraw = 1; + } + return 0; + } + + /* CONNECT/DISCONNECT emit EVENT before OK — update header even while waiting. */ + if (kind == MAX25_LINE_EVENT) { + if (max25_client_apply_event(line, status)) { + max25_ui_append_rx(ui, line); + if (need_redraw != NULL) { + *need_redraw = 1; + } + } else if (cfg->verbose) { + max25_ui_append_rx(ui, line); + if (need_redraw != NULL) { + *need_redraw = 1; + } + } + if (io->waiting_reply) { + return 0; + } + return 0; + } + + if (io->waiting_reply) { + if (kind == MAX25_LINE_STATUS && status != NULL) { + char summary[MAX25_LINE_MAX]; + + max25_client_parse_status(line, status); + /* Show STATUS in RX pane (F10→3 and other GET STATUS callers). */ + snprintf(summary, sizeof(summary), + "STATUS device=%s stack=%s connected=%s callerid=%s callid=%s", + status->device[0] != '\0' ? status->device : "-", + status->stack[0] != '\0' ? status->stack : "-", + status->connected ? "yes" : "no", + status->callerid[0] != '\0' ? status->callerid : "-", + status->callid[0] != '\0' ? status->callid : "-"); + max25_ui_append_rx(ui, summary); + if (need_redraw != NULL) { + *need_redraw = 1; + } + return 0; + } + if (kind == MAX25_LINE_DEVICE) { + max25_ui_append_rx(ui, line); + if (need_redraw != NULL) { + *need_redraw = 1; + } + return 0; + } + if (kind == MAX25_LINE_OK) { + io->waiting_reply = 0; + if (need_redraw != NULL) { + *need_redraw = 1; + } + return 0; + } + if (kind == MAX25_LINE_ERR) { + strncpy(io->last_err, line, sizeof(io->last_err) - 1); + io->last_err[sizeof(io->last_err) - 1] = '\0'; + io->waiting_reply = 0; + max25_ui_append_rx(ui, line); + if (need_redraw != NULL) { + *need_redraw = 1; + } + return -1; + } + return 0; + } + + if (kind == MAX25_LINE_ERR) { + max25_ui_append_rx(ui, line); + if (need_redraw != NULL) { + *need_redraw = 1; + } + } + return 0; +} + + +static int send_callsign(max25_client_t *client, session_io_t *io, + const char *field, const char *value) +{ + char cmd[MAX25_LINE_MAX]; + + if (!max25_valid_callsign(value)) { + fprintf(stderr, "max25-terminal: invalid %s\n", field); + return -1; + } + snprintf(cmd, sizeof(cmd), "SET %s %s", field, value); + return send_cmd_async(client, io, cmd); +} + +static int send_line_async(max25_client_t *client, session_io_t *io, + const char *line) +{ + char cmd[MAX25_LINE_MAX + 8]; + size_t max_payload = sizeof(cmd) - 6; + + if (strlen(line) > max_payload) { + return -1; + } + snprintf(cmd, sizeof(cmd), "SEND %s", line); + return send_cmd_async(client, io, cmd); +} + +static int refresh_status(max25_client_t *client, max25_status_t *status) +{ + char reply[MAX25_LINE_MAX]; + + if (max25_client_command(client, "GET STATUS", reply, sizeof(reply)) != 0) { + return -1; + } + return max25_client_parse_status(reply, status); +} + +static int set_device_sync(max25_client_t *client, max25_status_t *status, + const char *device_id) +{ + char cmd[MAX25_LINE_MAX + 16]; + char reply[MAX25_LINE_MAX]; + + if (device_id == NULL || device_id[0] == '\0') { + return -1; + } + snprintf(cmd, sizeof(cmd), "SET DEVICE %s", device_id); + if (max25_client_command(client, cmd, reply, sizeof(reply)) != 0) { + return -1; + } + return refresh_status(client, status); +} + +static int fetch_devices(max25_client_t *client, max25_ui_t *ui) +{ + char line[MAX25_LINE_MAX]; + + if (max25_client_write(client, "GET DEVICES") != 0) { + return -1; + } + max25_ui_append_rx(ui, "-- max25d devices --"); + for (;;) { + if (max25_client_read_line(client, line, sizeof(line)) != 0) { + return -1; + } + if (strcmp(line, "OK") == 0) { + break; + } + if (strncmp(line, "DEVICE ", 7) == 0) { + max25_ui_append_rx(ui, line); + continue; + } + if (strncmp(line, "ERR ", 4) == 0) { + max25_ui_append_rx(ui, line); + return -1; + } + } + return 0; +} + +static int menu_action_needs_prompt(max25_menu_action_t action) +{ + return action == MAX25_MENU_CALLERID || action == MAX25_MENU_CALLID || + action == MAX25_MENU_SEND || action == MAX25_MENU_DEVICE; +} + +/* Returns 1 to quit session, 0 otherwise. Hides menu before action. */ +static int apply_menu_pick(max25_menu_action_t action, max25_client_t *client, + session_io_t *io, max25_ui_t *ui, + max25_status_t *status, const char *input_line) +{ + char buf[MAX25_LINE_MAX]; + char cmd[64]; + + if (action == MAX25_MENU_NONE) { + return 0; + } + if (action == MAX25_MENU_QUIT) { + return 1; + } + + /* Leave overlay so prompts / STATUS RX / header redraw are visible. */ + max25_ui_hide_menu(ui); + max25_ui_draw_screen(ui, status, input_line != NULL ? input_line : ""); + + switch (action) { + case MAX25_MENU_CALLERID: + if (max25_ui_prompt(ui, "CALLERID", buf, sizeof(buf)) > 0) { + send_callsign(client, io, "CALLERID", buf); + } + break; + case MAX25_MENU_CALLID: + if (max25_ui_prompt(ui, "CALLID", buf, sizeof(buf)) > 0) { + send_callsign(client, io, "CALLID", buf); + } + break; + case MAX25_MENU_STATUS: + send_cmd_async(client, io, "GET STATUS"); + break; + case MAX25_MENU_SEND: + if (max25_ui_prompt(ui, "Send", buf, sizeof(buf)) > 0) { + send_line_async(client, io, buf); + } + break; + case MAX25_MENU_MONITOR: + snprintf(cmd, sizeof(cmd), "MONITOR %s", + status->monitor ? "off" : "on"); + status->monitor = !status->monitor; + send_cmd_async(client, io, cmd); + max25_ui_append_rx(ui, status->monitor ? "MONITOR on" : "MONITOR off"); + max25_ui_draw_screen(ui, status, input_line != NULL ? input_line : ""); + break; + case MAX25_MENU_CONNECT: + send_cmd_async(client, io, + status->connected ? "DISCONNECT" : "CONNECT"); + break; + case MAX25_MENU_DEVICE: + (void)fetch_devices(client, ui); + if (max25_ui_prompt(ui, "DEVICE id", buf, sizeof(buf)) > 0) { + if (set_device_sync(client, status, buf) != 0) { + max25_ui_append_rx(ui, "ERR SET DEVICE failed"); + } + max25_ui_draw_screen(ui, status, + input_line != NULL ? input_line : ""); + } + break; + case MAX25_MENU_DEVICES: + (void)fetch_devices(client, ui); + max25_ui_draw_screen(ui, status, input_line != NULL ? input_line : ""); + break; + default: + break; + } + + if (!menu_action_needs_prompt(action) && action != MAX25_MENU_MONITOR) { + max25_ui_draw_screen(ui, status, input_line != NULL ? input_line : ""); + } + return 0; +} + +#ifdef MAX25_HAVE_NCURSES +static int read_key_ncurses(void) +{ + int ch = getch(); + + if (ch == KEY_F(10)) { + return 999; + } + return ch; +} +#endif + +static int read_plain_key(void) +{ + unsigned char buf[8]; + ssize_t n; + + n = read(STDIN_FILENO, buf, sizeof(buf)); + if (n <= 0) { + if (n < 0 && errno == EAGAIN) { + return -1; + } + return 0; + } + + if (buf[0] == 0x1b && n >= 3 && buf[1] == '[') { + if (n >= 4 && buf[2] == '2' && buf[3] == '1' && (n < 5 || buf[4] == '~')) { + return 999; + } + } + + return (int)buf[0]; +} + +static int run_session(max25_client_t *client, max25_ui_t *ui, + terminal_config_t *cfg, max25_status_t *status) +{ + struct pollfd pfds[2]; + session_io_t io; + char line[MAX25_LINE_MAX]; + char input[MAX25_UI_INPUT_MAX]; + size_t input_len = 0; + int running = 1; + int use_ncurses = max25_ui_has_ncurses(ui); + int use_plain_raw = 0; + + memset(&io, 0, sizeof(io)); + input[0] = '\0'; + + if (!use_ncurses) { + max25_ui_apply_palette(); + } + max25_ui_reset_rx(ui); + max25_ui_draw_screen(ui, status, input); + + /* Arm TX session before accepting Enter/F10 SEND (daemon also auto-arms + * on SEND; sync CONNECT here so header connected=yes before first key). */ + if (!status->connected) { + char creply[MAX25_LINE_MAX]; + + if (max25_client_command(client, "CONNECT", creply, sizeof(creply)) == 0) { + status->connected = 1; + max25_ui_append_rx(ui, "EVENT connected"); + } else { + max25_ui_append_rx(ui, creply[0] ? creply : "ERR CONNECT failed"); + } + max25_ui_draw_screen(ui, status, input); + } + + (void)fetch_devices(client, ui); + max25_ui_draw_screen(ui, status, input); + + if (!use_ncurses && isatty(STDIN_FILENO)) { + use_plain_raw = (tty_enable_raw() == 0); + } + + while (running) { + if (!io.waiting_reply) { + max25_ui_draw_screen(ui, status, input); + } + + pfds[0].fd = STDIN_FILENO; + pfds[0].events = POLLIN; + pfds[0].revents = 0; + pfds[1].fd = max25_client_fd(client); + pfds[1].events = POLLIN; + pfds[1].revents = 0; + + if (poll(pfds, 2, 200) < 0) { + if (errno == EINTR) { + continue; + } + break; + } + + if ((pfds[1].revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) { + max25_ui_append_rx(ui, "max25d connection lost"); + break; + } + + if ((pfds[1].revents & POLLIN) != 0) { + int need_redraw = 0; + + if (max25_client_read_line(client, line, sizeof(line)) != 0) { + max25_ui_append_rx(ui, "max25d connection closed"); + break; + } + dispatch_daemon_line(line, &io, ui, status, cfg, &need_redraw); + if (io.last_err[0] != '\0' && cfg->verbose) { + fprintf(stderr, "max25-terminal: %s\n", io.last_err); + } + if (need_redraw && !max25_ui_menu_visible(ui)) { + max25_ui_draw_screen(ui, status, input); + } + continue; + } + + if ((pfds[0].revents & POLLIN) == 0) { + continue; + } + +#ifdef MAX25_HAVE_NCURSES + if (use_ncurses) { + int ch = read_key_ncurses(); + if (ch == -1) { + continue; + } + if (ch == 999) { + if (max25_ui_menu_visible(ui)) { + max25_ui_hide_menu(ui); + max25_ui_draw_screen(ui, status, input); + } else { + max25_ui_show_menu(ui, status); + } + continue; + } + if (max25_ui_menu_visible(ui)) { + if (ch == 27) { + max25_ui_hide_menu(ui); + max25_ui_draw_screen(ui, status, input); + continue; + } + if (apply_menu_pick(max25_ui_menu_pick(ui, ch), client, &io, ui, + status, input) != 0) { + running = 0; + } + continue; + } + if (ch >= 32 && ch < 127) { + if (input_len + 1 < sizeof(input)) { + input[input_len++] = (char)ch; + input[input_len] = '\0'; + } + } else if (ch == '\n' || ch == KEY_ENTER || ch == '\r') { + if (input_len > 0) { + send_line_async(client, &io, input); + input_len = 0; + input[0] = '\0'; + } + } else if (ch == KEY_BACKSPACE || ch == 127 || ch == '\b') { + if (input_len > 0) { + input[--input_len] = '\0'; + } + } + continue; + } +#endif + + if (use_plain_raw) { + int ch = read_plain_key(); + if (ch <= 0) { + continue; + } + if (ch == 999) { + if (max25_ui_menu_visible(ui)) { + max25_ui_hide_menu(ui); + max25_ui_draw_screen(ui, status, input); + } else { + max25_ui_show_menu(ui, status); + } + continue; + } + if (max25_ui_menu_visible(ui)) { + if (ch == 27) { + max25_ui_hide_menu(ui); + max25_ui_draw_screen(ui, status, input); + continue; + } + if (apply_menu_pick(max25_ui_menu_pick(ui, ch), client, &io, ui, + status, input) != 0) { + running = 0; + } + continue; + } + if (ch >= 32 && ch < 127) { + if (input_len + 1 < sizeof(input)) { + input[input_len++] = (char)ch; + input[input_len] = '\0'; + } + } else if (ch == '\n' || ch == '\r') { + if (input_len > 0) { + send_line_async(client, &io, input); + input_len = 0; + input[0] = '\0'; + } + } else if (ch == 127 || ch == '\b') { + if (input_len > 0) { + input[--input_len] = '\0'; + } + } + continue; + } + + if (fgets(input, sizeof(input), stdin) == NULL) { + running = 0; + } else { + input[strcspn(input, "\r\n")] = '\0'; + if (max25_ui_menu_visible(ui)) { + if (apply_menu_pick(max25_ui_menu_pick(ui, input[0]), client, + &io, ui, status, "") != 0) { + running = 0; + } + } else if (input[0] != '\0') { + send_line_async(client, &io, input); + } + input[0] = '\0'; + input_len = 0; + } + } + + tty_restore(); + return 0; +} + +int main(int argc, char *argv[]) +{ + terminal_config_t cfg; + max25_client_t *client = NULL; + max25_ui_t *ui = NULL; + max25_status_t status; + char reply[MAX25_LINE_MAX]; + int opt; + int argi; + static const struct option long_opts[] = { + {"host", required_argument, NULL, 'H'}, + {"port", required_argument, NULL, 'p'}, + {"unix", required_argument, NULL, 'U'}, + {"tcp-only", no_argument, NULL, 'T'}, + {"ax25-ui", no_argument, NULL, 1000}, + {"no-ax25-ui", no_argument, NULL, 1001}, + {"password", required_argument, NULL, 'P'}, + {"device", required_argument, NULL, 'd'}, + {"verbose", no_argument, NULL, 'v'}, + {"probe", no_argument, NULL, 1002}, + {"help", no_argument, NULL, 'h'}, + {NULL, 0, NULL, 0} + }; + + config_defaults(&cfg); + + while ((opt = getopt_long(argc, argv, "H:p:TU:P:d:v:h", long_opts, NULL)) != -1) { + switch (opt) { + case 'H': + strncpy(cfg.host, optarg, sizeof(cfg.host) - 1); + break; + case 'p': + cfg.port = (unsigned)strtoul(optarg, NULL, 10); + break; + case 'T': + cfg.tcp_only = 1; + break; + case 'U': + strncpy(cfg.unix_path, optarg, sizeof(cfg.unix_path) - 1); + break; + case 'P': + strncpy(cfg.tcp_password, optarg, sizeof(cfg.tcp_password) - 1); + break; + case 'd': + strncpy(cfg.default_device, optarg, sizeof(cfg.default_device) - 1); + break; + case 'v': + cfg.verbose = 1; + break; + case 1002: + cfg.probe = 1; + cfg.tcp_only = 1; + break; + case 'h': + usage(argv[0]); + return EXIT_SUCCESS; + case 1000: + cfg.ax25_ui = 1; + break; + case 1001: + cfg.ax25_ui = 0; + break; + default: + usage(argv[0]); + return EXIT_FAILURE; + } + } + + argi = optind; + if (argi < argc) { + strncpy(cfg.host, argv[argi++], sizeof(cfg.host) - 1); + } + if (argi < argc) { + cfg.port = (unsigned)strtoul(argv[argi++], NULL, 10); + } + + if (max25_client_connect(cfg.host, cfg.port, + cfg.tcp_only ? "" : cfg.unix_path, + cfg.tcp_only, cfg.tcp_password, + &client, &status) != 0) { + fprintf(stderr, "max25-terminal: connect failed"); + if (cfg.tcp_only) { + fprintf(stderr, " (tcp %s:%u)", cfg.host, cfg.port); + } else { + fprintf(stderr, " (unix %s / tcp %s:%u)", + cfg.unix_path, cfg.host, cfg.port); + } + fputc('\n', stderr); + return EXIT_FAILURE; + } + + if (cfg.probe) { + printf("STATUS hardware=%s device=%s mode=%s callerid=%s callid=%s " + "ax25_ui=%s connected=%s stack=%s\n", + status.hardware, status.device, status.mode, + status.callerid, status.callid, + status.ax25_ui ? "on" : "off", + status.connected ? "yes" : "no", + status.stack); + max25_client_write(client, "DISCONNECT"); + max25_client_free(client); + return EXIT_SUCCESS; + } + + if (max25_ui_init(&ui) != 0) { + max25_client_free(client); + return EXIT_FAILURE; + } + + if (cfg.default_device[0] != '\0') { + if (set_device_sync(client, &status, cfg.default_device) != 0) { + fprintf(stderr, "max25-terminal: SET DEVICE %s failed\n", + cfg.default_device); + max25_client_free(client); + return EXIT_FAILURE; + } + } + + if (cfg.ax25_ui) { + max25_client_command(client, "SET AX25_UI on", reply, sizeof(reply)); + status.ax25_ui = 1; + } else { + max25_client_command(client, "SET AX25_UI off", reply, sizeof(reply)); + status.ax25_ui = 0; + } + + if (cfg.verbose) { + fprintf(stderr, "max25-terminal: connected %s:%u tcp_only=%s ax25-ui=%s\n", + cfg.host, cfg.port, cfg.tcp_only ? "yes" : "no", + status.ax25_ui ? "on" : "off"); + } + + run_session(client, ui, &cfg, &status); + + max25_client_write(client, "DISCONNECT"); + max25_ui_shutdown(ui); + max25_client_free(client); + return EXIT_SUCCESS; +} diff --git a/stacks/terminal/max25_ui.c b/stacks/terminal/max25_ui.c new file mode 100644 index 0000000..91cc977 --- /dev/null +++ b/stacks/terminal/max25_ui.c @@ -0,0 +1,407 @@ +#include "max25_ui.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> + +#ifdef MAX25_HAVE_NCURSES +#include <ncurses.h> +#endif + +struct max25_ui { + int menu_open; + char rx_lines[MAX25_UI_RX_LINES][MAX25_LINE_MAX]; + int rx_count; + int rx_head; +#ifdef MAX25_HAVE_NCURSES + int ncurses_on; +#endif +}; + +#ifdef MAX25_HAVE_NCURSES +static int term_ok_for_ncurses(void) +{ + const char *term = getenv("TERM"); + + if (term == NULL || term[0] == '\0') { + return 0; + } + if (strcmp(term, "dumb") == 0) { + return 0; + } + if (strncmp(term, "unknown", 7) == 0) { + return 0; + } + return 1; +} +#endif + +int max25_ui_init(max25_ui_t **ui) +{ + max25_ui_t *u; + + if (ui == NULL) { + return -1; + } + + u = calloc(1, sizeof(*u)); + if (u == NULL) { + return -1; + } + +#ifdef MAX25_HAVE_NCURSES + if (isatty(STDOUT_FILENO) && isatty(STDIN_FILENO) && term_ok_for_ncurses() + && initscr() != NULL) { + cbreak(); + noecho(); + keypad(stdscr, TRUE); + nodelay(stdscr, TRUE); + if (has_colors()) { + start_color(); + init_pair(1, COLOR_WHITE, COLOR_BLACK); + } + bkgd(COLOR_PAIR(1)); + curs_set(0); + clear(); + refresh(); + u->ncurses_on = 1; + } +#endif + + *ui = u; + return 0; +} + +void max25_ui_shutdown(max25_ui_t *ui) +{ + if (ui == NULL) { + return; + } +#ifdef MAX25_HAVE_NCURSES + if (ui->ncurses_on) { + endwin(); + } +#endif + free(ui); +} + +void max25_ui_apply_palette(void) +{ + fputs(MAX25_TERM_SGR, stdout); + fflush(stdout); +} + +void max25_ui_clear_screen(void) +{ + fputs(MAX25_TERM_CLEAR, stdout); + fputs(MAX25_TERM_SGR, stdout); + fflush(stdout); +} + +int max25_ui_has_ncurses(const max25_ui_t *ui) +{ +#ifdef MAX25_HAVE_NCURSES + return ui != NULL && ui->ncurses_on; +#else + (void)ui; + return 0; +#endif +} + +void max25_ui_reset_rx(max25_ui_t *ui) +{ + if (ui == NULL) { + return; + } + ui->rx_count = 0; + ui->rx_head = 0; + memset(ui->rx_lines, 0, sizeof(ui->rx_lines)); +} + +void max25_ui_append_rx(max25_ui_t *ui, const char *text) +{ + int slot; + + if (ui == NULL || text == NULL) { + return; + } + + slot = ui->rx_head; + strncpy(ui->rx_lines[slot], text, MAX25_LINE_MAX - 1); + ui->rx_lines[slot][MAX25_LINE_MAX - 1] = '\0'; + ui->rx_head = (ui->rx_head + 1) % MAX25_UI_RX_LINES; + if (ui->rx_count < MAX25_UI_RX_LINES) { + ui->rx_count++; + } +} + +static void draw_header_plain(const max25_status_t *status) +{ + printf("%sMAX25 Terminal DEVICE: %-12s CALLERID: %-9s CALLID: %-9s ax25-ui: %s connected: %s F10=Menu\n", + MAX25_TERM_SGR, + status->device[0] != '\0' ? status->device : "-", + status->callerid, status->callid, + status->ax25_ui ? "on" : "off", + status->connected ? "yes" : "no"); + fflush(stdout); +} + +static void draw_rx_plain(const max25_ui_t *ui) +{ + int i; + int start; + int idx; + + if (ui == NULL) { + return; + } + + start = ui->rx_count < MAX25_UI_RX_LINES ? 0 : ui->rx_head; + for (i = 0; i < ui->rx_count; i++) { + idx = (start + i) % MAX25_UI_RX_LINES; + printf("%s%s\n", MAX25_TERM_SGR, ui->rx_lines[idx]); + } + fflush(stdout); +} + +void max25_ui_draw_screen(max25_ui_t *ui, const max25_status_t *status, + const char *input_line) +{ + int i; + int start; + int idx; + int rows; + int y; + + if (ui == NULL || status == NULL) { + return; + } + +#ifdef MAX25_HAVE_NCURSES + if (ui->ncurses_on && !ui->menu_open) { + getmaxyx(stdscr, rows, y); + (void)y; + if (rows < 4) { + rows = 24; + } + + attron(COLOR_PAIR(1)); + mvprintw(0, 0, + "MAX25 Terminal DEVICE: %-12s CALLERID: %-9s CALLID: %-9s ax25-ui: %s connected: %s F10=Menu", + status->device[0] != '\0' ? status->device : "-", + status->callerid, status->callid, + status->ax25_ui ? "on" : "off", + status->connected ? "yes" : "no"); + clrtoeol(); + + start = ui->rx_count < MAX25_UI_RX_LINES ? 0 : ui->rx_head; + for (i = 0; i < ui->rx_count && i + 2 < rows - 2; i++) { + idx = (start + i) % MAX25_UI_RX_LINES; + mvprintw(i + 2, 0, "%.*s", (int)(rows > 0 ? 200 : 80), ui->rx_lines[idx]); + clrtoeol(); + } + + mvprintw(rows - 2, 0, "%s", "--------------------------------------------------------"); + clrtoeol(); + mvprintw(rows - 1, 0, "> %s", input_line != NULL ? input_line : ""); + clrtoeol(); + attroff(COLOR_PAIR(1)); + refresh(); + return; + } +#endif + + if (!ui->menu_open) { + fputs(MAX25_TERM_CLEAR, stdout); + fputs(MAX25_TERM_SGR, stdout); + draw_header_plain(status); + draw_rx_plain(ui); + printf("%s> %s\n", MAX25_TERM_SGR, input_line != NULL ? input_line : ""); + fflush(stdout); + } +} + +static void draw_menu_plain(const max25_status_t *status) +{ + puts(""); + puts("+- MAX25 Terminal ---------------------+"); + printf("| DEVICE: %-12s |\n", + status->device[0] != '\0' ? status->device : "-"); + printf("| CALLERID: %-9s CALLID: %-9s |\n", + status->callerid, status->callid); + puts("+---------------------------------------+"); + puts("| 1 Change CALLERID (live) |"); + puts("| 2 Change CALLID (live) |"); + puts("| 3 Status |"); + puts("| 4 Send line |"); + puts("| 5 RX only (Monitor) |"); + puts("| 6 Connection on/off |"); + puts("| 7 Change DEVICE (TX target) |"); + puts("| 8 List DEVICES (max25d) |"); + puts("| 0 Quit client |"); + puts("+---------------------------------------+"); + puts("Pick a number - F10 closes"); + fflush(stdout); +} + +#ifdef MAX25_HAVE_NCURSES +static void draw_menu_ncurses(const max25_status_t *status, int y0) +{ + attron(COLOR_PAIR(1)); + mvprintw(y0, 0, "+- MAX25 Terminal ---------------------+"); + mvprintw(y0 + 1, 0, "| DEVICE: %-12s |", + status->device[0] != '\0' ? status->device : "-"); + mvprintw(y0 + 2, 0, "| CALLERID: %-9s CALLID: %-9s |", + status->callerid, status->callid); + mvprintw(y0 + 3, 0, "+---------------------------------------+"); + mvprintw(y0 + 4, 0, "| 1 Change CALLERID (live) |"); + mvprintw(y0 + 5, 0, "| 2 Change CALLID (live) |"); + mvprintw(y0 + 6, 0, "| 3 Status |"); + mvprintw(y0 + 7, 0, "| 4 Send line |"); + mvprintw(y0 + 8, 0, "| 5 RX only (Monitor) |"); + mvprintw(y0 + 9, 0, "| 6 Connection on/off |"); + mvprintw(y0 + 10, 0, "| 7 Change DEVICE (TX target) |"); + mvprintw(y0 + 11, 0, "| 8 List DEVICES (max25d) |"); + mvprintw(y0 + 12, 0, "| 0 Quit client |"); + mvprintw(y0 + 13, 0, "+---------------------------------------+"); + mvprintw(y0 + 14, 0, "Pick a number - F10 closes"); + attroff(COLOR_PAIR(1)); +} +#endif + +void max25_ui_show_menu(max25_ui_t *ui, const max25_status_t *status) +{ + if (ui == NULL || status == NULL) { + return; + } + ui->menu_open = 1; + +#ifdef MAX25_HAVE_NCURSES + if (ui->ncurses_on) { + clear(); + bkgd(COLOR_PAIR(1)); + draw_menu_ncurses(status, 2); + refresh(); + return; + } +#endif + fputs(MAX25_TERM_CLEAR, stdout); + fputs(MAX25_TERM_SGR, stdout); + draw_menu_plain(status); +} + +void max25_ui_hide_menu(max25_ui_t *ui) +{ + if (ui == NULL) { + return; + } + ui->menu_open = 0; +#ifdef MAX25_HAVE_NCURSES + if (ui->ncurses_on) { + clear(); + bkgd(COLOR_PAIR(1)); + refresh(); + } +#endif +} + +int max25_ui_menu_visible(const max25_ui_t *ui) +{ + return ui != NULL && ui->menu_open; +} + +max25_menu_action_t max25_ui_menu_pick(max25_ui_t *ui, int ch) +{ + (void)ui; + switch (ch) { + case '1': + return MAX25_MENU_CALLERID; + case '2': + return MAX25_MENU_CALLID; + case '3': + return MAX25_MENU_STATUS; + case '4': + return MAX25_MENU_SEND; + case '5': + return MAX25_MENU_MONITOR; + case '6': + return MAX25_MENU_CONNECT; + case '7': + return MAX25_MENU_DEVICE; + case '8': + return MAX25_MENU_DEVICES; + case '0': + return MAX25_MENU_QUIT; + default: + return MAX25_MENU_NONE; + } +} + +int max25_ui_prompt(max25_ui_t *ui, const char *label, char *buf, size_t buf_sz) +{ + if (label == NULL || buf == NULL || buf_sz == 0) { + return -1; + } + +#ifdef MAX25_HAVE_NCURSES + if (ui != NULL && ui->ncurses_on) { + int ch; + size_t len = 0; + int rows; + + getmaxyx(stdscr, rows, ch); + (void)rows; + echo(); + curs_set(1); + attron(COLOR_PAIR(1)); + mvprintw(14, 0, "%s: ", label); + clrtoeol(); + refresh(); + buf[0] = '\0'; + + nodelay(stdscr, FALSE); + while (len + 1 < buf_sz) { + ch = getch(); + if (ch == '\n' || ch == KEY_ENTER || ch == '\r') { + break; + } + if (ch == 27) { + nodelay(stdscr, TRUE); + noecho(); + curs_set(0); + return -1; + } + if (ch == KEY_BACKSPACE || ch == 127 || ch == '\b') { + if (len > 0) { + len--; + buf[len] = '\0'; + mvprintw(14, (int)strlen(label) + 2, "%s ", buf); + clrtoeol(); + refresh(); + } + continue; + } + if (ch >= 32 && ch < 127) { + buf[len++] = (char)ch; + buf[len] = '\0'; + mvprintw(14, (int)strlen(label) + 2, "%s", buf); + refresh(); + } + } + nodelay(stdscr, TRUE); + noecho(); + curs_set(0); + attroff(COLOR_PAIR(1)); + return (int)len; + } +#endif + + printf("%s%s: ", MAX25_TERM_SGR, label); + fflush(stdout); + if (fgets(buf, (int)buf_sz, stdin) == NULL) { + return -1; + } + buf[strcspn(buf, "\r\n")] = '\0'; + return (int)strlen(buf); +} diff --git a/stacks/terminal/max25_ui.h b/stacks/terminal/max25_ui.h new file mode 100644 index 0000000..9c1928d --- /dev/null +++ b/stacks/terminal/max25_ui.h @@ -0,0 +1,45 @@ +#ifndef MAX25_UI_H +#define MAX25_UI_H + +#include "max25_proto.h" + +#define MAX25_TERM_SGR "\033[37;40m" +#define MAX25_TERM_CLEAR "\033[2J\033[H" +#define MAX25_UI_RX_LINES 12 +#define MAX25_UI_INPUT_MAX 256 + +typedef struct max25_ui max25_ui_t; + +typedef enum { + MAX25_MENU_NONE = 0, + MAX25_MENU_CALLERID, + MAX25_MENU_CALLID, + MAX25_MENU_STATUS, + MAX25_MENU_SEND, + MAX25_MENU_MONITOR, + MAX25_MENU_CONNECT, + MAX25_MENU_DEVICE, + MAX25_MENU_DEVICES, + MAX25_MENU_QUIT +} max25_menu_action_t; + +int max25_ui_init(max25_ui_t **ui); +void max25_ui_shutdown(max25_ui_t *ui); + +void max25_ui_apply_palette(void); +void max25_ui_clear_screen(void); + +int max25_ui_has_ncurses(const max25_ui_t *ui); + +void max25_ui_reset_rx(max25_ui_t *ui); +void max25_ui_append_rx(max25_ui_t *ui, const char *text); +void max25_ui_draw_screen(max25_ui_t *ui, const max25_status_t *status, + const char *input_line); +void max25_ui_show_menu(max25_ui_t *ui, const max25_status_t *status); +void max25_ui_hide_menu(max25_ui_t *ui); +int max25_ui_menu_visible(const max25_ui_t *ui); + +max25_menu_action_t max25_ui_menu_pick(max25_ui_t *ui, int ch); +int max25_ui_prompt(max25_ui_t *ui, const char *label, char *buf, size_t buf_sz); + +#endif /* MAX25_UI_H */ diff --git a/stacks/terminal/test-terminal.sh b/stacks/terminal/test-terminal.sh new file mode 100755 index 0000000..6a510f3 --- /dev/null +++ b/stacks/terminal/test-terminal.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# max25-terminal offline probe — binary + TCP connect test. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "${ROOT}/../.." && pwd)" +TERM="${MAX25_TERMINAL:-${REPO}/build/bin/max25-terminal}" + +[[ -x "${TERM}" ]] || { echo "FAIL: ${TERM} missing" >&2; exit 1; } + +CLIENT="${REPO}/build/bin/max25-client" +if [[ -L "${CLIENT}" ]] || [[ -x "${CLIENT}" ]]; then + : +else + echo "WARN: max25-client symlink missing until cmake --install" >&2 +fi + +"${TERM}" --help >/dev/null + +# Unit: EVENT connected/disconnected updates status (header bug regression) +UNIT="${ROOT}/.test_apply_event" +gcc -std=c11 -Wall -Wextra -O2 -I"${ROOT}" \ + -o "${UNIT}" "${ROOT}/test_apply_event.c" "${ROOT}/max25_proto.c" +"${UNIT}" +rm -f "${UNIT}" + +MAX25_TERMINAL="${TERM}" python3 "${ROOT}/test_connect.py" +echo "OK: max25-terminal" diff --git a/stacks/terminal/test_apply_event.c b/stacks/terminal/test_apply_event.c new file mode 100644 index 0000000..36e10f0 --- /dev/null +++ b/stacks/terminal/test_apply_event.c @@ -0,0 +1,30 @@ +/* Offline unit test: EVENT connected/disconnected → status.connected */ +#include "max25_proto.h" + +#include <stdio.h> +#include <string.h> + +int main(void) +{ + max25_status_t st; + + memset(&st, 0, sizeof(st)); + if (max25_client_apply_event("EVENT connected", &st) != 1 || !st.connected) { + fprintf(stderr, "FAIL: EVENT connected\n"); + return 1; + } + if (max25_client_apply_event("EVENT disconnected", &st) != 1 || st.connected) { + fprintf(stderr, "FAIL: EVENT disconnected\n"); + return 1; + } + if (max25_client_apply_event("EVENT device=max25e0 serial=ready", &st) != 0) { + fprintf(stderr, "FAIL: unrelated EVENT should be ignored\n"); + return 1; + } + if (max25_client_apply_event("OK", &st) != 0) { + fprintf(stderr, "FAIL: non-EVENT\n"); + return 1; + } + puts("OK: apply_event"); + return 0; +} diff --git a/stacks/terminal/test_connect.py b/stacks/terminal/test_connect.py new file mode 100644 index 0000000..6496b69 --- /dev/null +++ b/stacks/terminal/test_connect.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""TCP connect smoke test for max25-terminal --probe.""" +import socket +import subprocess +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +DAEMON = ROOT / "stacks/daemon/max25d" +INI = ROOT / "share/max25/max25d.ini.example" + + +def terminal_binary() -> Path: + import os + + env = os.environ.get("MAX25_TERMINAL") + if env: + return Path(env) + for candidate in ( + ROOT / "build/bin/max25-terminal", + ROOT / "stacks/terminal/max25-terminal", + ): + if candidate.is_file(): + return candidate + return ROOT / "build/bin/max25-terminal" + + +def free_port() -> int: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + return port + + +def main() -> int: + terminal = terminal_binary() + if not terminal.is_file(): + print( + f"FAIL: max25-terminal missing — run cmake --build build --target max25-terminal " + f"(looked for {terminal})", + file=sys.stderr, + ) + return 1 + + port = free_port() + proc = subprocess.Popen( + [str(DAEMON), "--no-stack", "-c", str(INI), "--tcp-port", str(port)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + time.sleep(0.6) + try: + out = subprocess.check_output( + [str(terminal), "-T", "-H", "127.0.0.1", "-p", str(port), "--probe"], + text=True, + timeout=10, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: + proc.terminate() + proc.wait(timeout=5) + print(f"FAIL: terminal probe: {exc}", file=sys.stderr) + return 1 + finally: + proc.terminate() + proc.wait(timeout=5) + + if "STATUS hardware=" not in out or "callerid=" not in out: + print(f"FAIL: unexpected probe output: {out!r}", file=sys.stderr) + return 1 + + print("OK: max25-terminal TCP probe") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stacks/tncs/CMakeLists.txt b/stacks/tncs/CMakeLists.txt new file mode 100644 index 0000000..3bc43ce --- /dev/null +++ b/stacks/tncs/CMakeLists.txt @@ -0,0 +1,7 @@ +add_executable(tnc2c-probe tnc2c-probe.c) +target_compile_options(tnc2c-probe PRIVATE -std=c99 -Wall -Wextra -O2) + +add_executable(tnc2c-find tnc2c-find.c) +target_compile_options(tnc2c-find PRIVATE -std=c99 -Wall -Wextra -O2) + +install(TARGETS tnc2c-probe tnc2c-find RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}") diff --git a/stacks/tncs/README.md b/stacks/tncs/README.md new file mode 100644 index 0000000..e07a9ac --- /dev/null +++ b/stacks/tncs/README.md @@ -0,0 +1,40 @@ +# stacks/tncs — Serial TNC tools + +Operator tools and docs for **Landolt TNC2C** and **PK-TNC2** (TheFirmware TNC-2 class). HyBBX attaches via `packet_radio` after MAX25 prep. + +**Recovery:** software-first (no power cycle in normal ops). Power-cycle boot-wait is rescue only. See [docs/TNC-RECOVERY.md](docs/TNC-RECOVERY.md). + +## Quick start + +```bash +./scripts/build.sh +./scripts/max25-ctl start --hardware tncs --device tnc2c +``` + +Adjust serial port in `tnc2c-serial.env` before first use. + +## Documentation + +| Doc | Content | +|-----|---------| +| [docs/TNC-RECOVERY.md](docs/TNC-RECOVERY.md) | Software ladder, rescue power-cycle | +| [docs/TNC2C-OPERATIONS.md](docs/TNC2C-OPERATIONS.md) | Boot-wait, health, HyBBX handoff | +| [docs/TNC2C-REFERENCE.md](docs/TNC2C-REFERENCE.md) | Parameters, tools, example profile | +| [docs/HYBBX-TNC2C.md](docs/HYBBX-TNC2C.md) | HyBBX Secondary attach | + +HyBBX INI: [`hybbx-tnc2c.ini`](hybbx-tnc2c.ini) (onboard ttyS4 example) · shipped copy: `../../share/hybbx/tnc2c-host.ini.example` · dual TNC: [`hybbx-dual.ini`](hybbx-dual.ini) + +## Tools + +| Tool | Purpose | +|------|---------| +| `tnc2c-host-reset.sh` | Software recovery (no power cycle) | +| `tnc2c-boot-wait.sh` | DTR during power-on — **rescue**; `--recover-only` for ladder only | +| `pktnc2-boot-wait.sh` | PK-TNC2 wrapper (9600 8N1); supports `--recover-only` | +| `tnc2c-integration-test.sh` | HyBBX-ready check | +| `tnc2c-health.sh` | Serial/TNC check (no TX) | +| `tnc2c-probe` | Scan serial ports for TNC response | + +## Safety + +KISS and TX tests can key the radio. Use a dummy load for transmit tests. diff --git a/stacks/tncs/RS232A259.jpg b/stacks/tncs/RS232A259.jpg Binary files differnew file mode 100644 index 0000000..c1dfe04 --- /dev/null +++ b/stacks/tncs/RS232A259.jpg diff --git a/stacks/tncs/TNC2C.pdf b/stacks/tncs/TNC2C.pdf Binary files differnew file mode 100644 index 0000000..d9f7b9d --- /dev/null +++ b/stacks/tncs/TNC2C.pdf diff --git a/stacks/tncs/docs/HYBBX-TNC2C.md b/stacks/tncs/docs/HYBBX-TNC2C.md new file mode 100644 index 0000000..8d2207f --- /dev/null +++ b/stacks/tncs/docs/HYBBX-TNC2C.md @@ -0,0 +1,54 @@ +# HyBBX + TNC2C + +MAX25 prepares the TNC (recovery + KISS entry); HyBBX attaches with **`kiss_entry = none`**. + +## Flow + +``` +max25d auto_start / max25-ctl start --hardware tncs --device tnc2c + ↓ (software recovery + kiss on) +HyBBX Secondary starts packet_radio plugin (attach-only) + ↓ +KISS on serial → AX.25 sessions +``` + +Software recovery without power cycle: [TNC-RECOVERY.md](TNC-RECOVERY.md). + +## Preparation (MAX25) + +```bash +./scripts/max25-ctl start --hardware tncs --device tnc2c +# Production: max25d with [stack] auto_start = yes +``` + +One process per serial port — stop other userspace serial owners before HyBBX starts. + +## HyBBX INI + +Merge `share/hybbx/tnc2c-host.ini.example` or `stacks/tncs/hybbx-tnc2c.ini` into Secondary `hybbx.ini`: + +```ini +[transport.packet_radio1] +tnc = tnc2c +device = /dev/ttyUSB0 +baud = 19200 +serial_line = 8n1 +rts_dtr = yes +kiss_entry = none +persist = 255 + +[max25] +check = yes +``` + +Set `device`, callsign, and frequency for your station. + +For HyBBX **without** max25d (standalone), use `kiss_entry = kiss_on` or `auto` instead — HyBBX runs its own recovery before KISS entry. + +## Checks + +- MAX25 prep returned host/KISS ready (boot-wait OK or max25d `ready`) +- `fuser` on device is empty before HyBBX start (max25d holds port in production) +- Log shows KISS active on the expected port + +Full contract: [docs/HYBBX.md](../../docs/HYBBX.md) diff --git a/stacks/tncs/docs/TNC-RECOVERY.md b/stacks/tncs/docs/TNC-RECOVERY.md new file mode 100644 index 0000000..2e36357 --- /dev/null +++ b/stacks/tncs/docs/TNC-RECOVERY.md @@ -0,0 +1,108 @@ +# TNC recovery (software-first) + +TheFirmware / TNC-2 class devices (Landolt TNC2C, PK-TNC2, TAPR TNC-2) can usually return to **host command mode** and **KISS** without a power cycle. Power-cycle with DTR held high is a **rescue fallback** only. + +Shared implementation: `stacks/tncs/tnc_serial_recovery.py` (used by `tnc2c-host-reset`, `tnc2c-boot-wait`, and `max25d` `kiss_bridge`). + +## When to use what + +| Situation | First action | Rescue (if software fails) | +|-----------|--------------|----------------------------| +| Echo-only (`INFO` → `INFO`, no banner) | `max25d` serial watch / `./tnc2c-host-reset.sh` | `./tnc2c-boot-wait.sh` only if cold-boot without DTR | +| After `max25d` prep `error-host` | Auto boot-wait escalate (power OFF 10s → ON) | `./tnc2c-boot-wait.sh` if escalate disabled | +| PK-TNC2 (9600 8N1) | `./pktnc2-boot-wait.sh --recover-only` | `./pktnc2-boot-wait.sh` + power cycle | +| Cold boot, never saw `cmd:` | `./tnc2c-boot-wait.sh` (DTR before power-on) | Fix wiring (DTR/RTS, CTS bridge) | + +## Software recovery ladder (TheFirmware TF 2.7 native) + +Run with **DTR+RTS high** and the port open: + +1. Passive listen (~1.5 s) + **DTR settle 2 s** after open (max25d) +2. KISS return `0xC0 0xFF 0xC0` (firmware reset → banner) +3. Buffer flush `^Q^X` + JHOST 0 (leave host mode) +4. **ESC V** — version / terminal probe +5. **ESC QRES** — software cold boot (no mains power cycle; DTR must stay high) +6. ESC E 0 + second ESC QRES +7. Legacy TAPR `kiss off` + `INFO` (last resort only) + +Success markers: `TheFirmware`, `NORD`, `Version 2.7`, `Checksum`, `cmd:`. + +Then enter KISS: **ESC `@K`** (`0x1B 40 4B`). MYCALL: **ESC I** `<call>`. + +Native TF sequence: see [Software recovery ladder](#software-recovery-ladder-thefirmware-tf-27-native) above. + +## Operator commands + +```bash +cd stacks/tncs + +# Software recovery (no power cycle) +./tnc2c-host-reset.sh # terminal mode +./tnc2c-host-reset.sh --kiss # terminal + KISS +./tnc2c-boot-wait.sh --recover-only + +# PK-TNC2 +./pktnc2-boot-wait.sh --recover-only + +# Rescue: power cycle with DTR held (Landolt TNC2C cold-boot requirement) +./tnc2c-boot-wait.sh # power OFF → ON while script runs +``` + +## MAX25 + HyBBX contract + +| Layer | Responsibility | +|-------|----------------| +| `max25d` / boot-wait | DTR sequencing, software recovery, `kiss on` / `auto` | +| HyBBX `packet_radio` | Attach only — `kiss_entry = none` | +| Power cycle | Rescue when DTR was low at cold boot or hardware hang | + +HyBBX INI (production): `kiss_entry = none`, `persist = 255` (CB CSMA), optional `[max25] check = yes`. + +max25d runs `recover_terminal()` in `kiss_bridge.stabilize_session()` before `MYCALL` and KISS entry. The KISS RX thread starts **only after** successful stabilization (avoids a race where RX consumes recovery replies). + +### max25d serial watch (automatic) + +When `[stack] serial_watch = yes` and `stack_recover_only = yes` (default), max25d **opens the TNC serial port itself** — no `boot-wait` subprocess (avoids port conflict). Recovery runs inline via `stabilize_session`. + +| INI key | Default | Role | +|---------|---------|------| +| `serial_watch` | yes | Enable periodic probe + auto-repair | +| `serial_watch_interval` | 60 | Seconds between health probes | +| `serial_repair_cooldown` | 20 | Minimum gap between repair attempts | +| `stack_recover_only` | yes | Daemon start uses `--recover-only` (no power cycle) | +| `stack_retry_interval` | 120 | Retry failed stack prep with recover-only | +| `serial_bootwait_escalate` | yes | Escalate to boot-wait after repeated inline failures | +| `serial_bootwait_escalate_after` | 3 | Inline `error-host` failures before boot-wait | +| `serial_bootwait_escalate_cooldown` | 300 | Minimum seconds between boot-wait escalations | + +Triggers: initial prep `error-host` (immediate boot-wait escalate), `error-host` / `error-kiss` / `error-tx` / `error-io`, failed TX (one auto-retry). Serial watch still escalates after `serial_bootwait_escalate_after` consecutive inline failures if prep did not already escalate. + +**Healthy KISS:** when the backend is `ready`, serial watch does **not** tear down KISS on the periodic interval — repair runs only when status is in the error set above (not a periodic `kiss off` / probe on a working session). + +## Firmware RX diagnostics (max25d / boot-wait) + +On each recovery step, logs include byte count, matched firmware markers (`TheFirmware`, `cmd:`, …), and a printable preview. On failure: + +- `recovery: firmware assessment — …` — classified state (silent, echo-only, binary/KISS, non-banner text) +- `recovery: RX capture — …` — full accumulated RX summary with hex snippet + +Use these lines to distinguish **DTR/cold-boot** (echo-only, 0 B passive) from **wrong baud/line** (garbage/binary) from **stuck KISS** (binary frames, no `cmd:`). + +Use when: + +- Software ladder ends in echo-only or silence +- TNC was powered on **without** DTR high (Landolt TNC2C terminal detection) +- Suspected hardware hang + +```bash +./tnc2c-boot-wait.sh +# While script listens: power OFF 10 s, power ON — keep script running (DTR stays high) +``` + +Do **not** close the serial port between boot-wait and HyBBX/max25d attach — DTR drop can return the TNC to echo mode. + +## See also + +- [TNC2C-OPERATIONS.md](TNC2C-OPERATIONS.md) — daily ops +- [HYBBX-TNC2C.md](HYBBX-TNC2C.md) — HyBBX attach +- [../../docs/PACKET-RADIO.md](../../docs/PACKET-RADIO.md) — serial profiles, CSMA diff --git a/stacks/tncs/docs/TNC2C-OPERATIONS.md b/stacks/tncs/docs/TNC2C-OPERATIONS.md new file mode 100644 index 0000000..aa9b8ef --- /dev/null +++ b/stacks/tncs/docs/TNC2C-OPERATIONS.md @@ -0,0 +1,66 @@ +# TNC2C operations + +Landolt TNC2C on a serial port — software recovery, boot-wait rescue, verify, HyBBX attach. + +## Prerequisites + +- Port free: no HyBBX or other userspace serial owner on the TNC serial device +- `tnc2c-serial.env` matches your wiring (device, baud, line format) +- DTR+RTS asserted while talking to the TNC (boot-wait and host-reset do this) + +## Normal prep (max25d / HyBBX) + +Production path: `max25d` with `auto_start` runs boot-wait and software recovery before KISS attach. + +```bash +./scripts/max25-ctl start --hardware tncs --device tnc2c +# or: sudo max25d -c /etc/max25/max25d.ini +``` + +Manual recovery without power cycle: + +```bash +cd stacks/tncs +./tnc2c-host-reset.sh --kiss +# or: ./tnc2c-boot-wait.sh --recover-only +``` + +Full ladder and rescue rules: [TNC-RECOVERY.md](TNC-RECOVERY.md). + +## Boot-wait (rescue — power cycle) + +Use when software recovery fails or the TNC was cold-started without DTR high: + +```bash +cd stacks/tncs +./tnc2c-boot-wait.sh +``` + +Power cycle the TNC while the script holds DTR+RTS. Expect `OK: HOST` when successful. + +## Verify + +```bash +./tnc2c-integration-test.sh +./tnc2c-health.sh +``` + +Integration test runs boot-wait + verify in one process so DTR does not drop between steps. + +## HyBBX + +1. MAX25 prep complete (boot-wait or `max25d` auto_start with recovery). +2. Merge `share/hybbx/tnc2c-host.ini.example` into Secondary `hybbx.ini` — **`kiss_entry = none`** (MAX25 owns KISS entry). +3. Do not run another userspace serial client on the same device while HyBBX is active. + +See [HYBBX-TNC2C.md](HYBBX-TNC2C.md) and [../../docs/HYBBX.md](../../docs/HYBBX.md). + +## Recovery + +| Symptom | Action | +|---------|--------| +| Echo only, no `cmd:` | `./tnc2c-host-reset.sh` then `./tnc2c-boot-wait.sh --recover-only` | +| Still echo-only / silent | **Rescue:** `./tnc2c-boot-wait.sh` + power cycle (DTR high) | +| `max25d` reports `error-host` | Stop other serial owners; run `--recover-only`; restart max25d | +| Port busy | Stop HyBBX or other serial owner; check `fuser` on device | +| Wrong baud | Fix `tnc2c-serial.env` and retry | diff --git a/stacks/tncs/docs/TNC2C-REFERENCE.md b/stacks/tncs/docs/TNC2C-REFERENCE.md new file mode 100644 index 0000000..774248c --- /dev/null +++ b/stacks/tncs/docs/TNC2C-REFERENCE.md @@ -0,0 +1,43 @@ +# TNC2C reference (example profile) + +Example profile for a Landolt TNC2C with **19200 8N1** host link and **2400** radio AFSK. Adjust for your hardware. + +## Serial (host) + +| Parameter | Example | +|-----------|---------| +| Device | `/dev/ttyUSB0` | +| Baud | 19200 | +| Format | 8N1 | +| RTS/DTR | Required for boot-wait | + +## Radio (typical CB packet) + +| Parameter | Example | +|-----------|---------| +| Modem | TCM3105 | +| Radio baud | 2400 | +| Band | CB | +| Duplex | Half | + +## Config file + +`stacks/tncs/tnc2c-serial.env` — copy and edit device/baud for your station. + +## Tool matrix + +| Tool | Use | +|------|-----| +| `tnc2c-boot-wait.sh` | Host mode after power-on (rescue) | +| `tnc2c-host-reset.sh` | Software recovery without power cycle | +| `tnc2c-autotest.sh` | Quick 19200-8N1 probe | +| `tnc2c-health.sh` | Full check, no TX | +| `tnc2c-integration-test.sh` | Post boot-wait HyBBX gate | +| `tnc2c-listen.sh` | Passive monitor | +| `tnc2c-probe` | Port scan utility | + +## HyBBX INI snippet + +Use `share/hybbx/tnc2c-host.ini.example` — set `device`, `mycall`, and RF fields for your station. + +HyBBX TNC profiles: [hyBBX docs/TNCS.md](https://github.com/ngteq/hyBBX/blob/main/docs/TNCS.md) diff --git a/stacks/tncs/hybbx-ax25-beacon-once.py b/stacks/tncs/hybbx-ax25-beacon-once.py new file mode 100755 index 0000000..192d986 --- /dev/null +++ b/stacks/tncs/hybbx-ax25-beacon-once.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""One-shot AX.25 UI beacon matching HyBBX [broadcast] INI (HyBBX must stay running). + +Connects to the internal circuit hub and sends an HBX AX25_UI frame with TX flag +the same way hybbx_broadcast_ax25() does. Requires packet_radio1 linked with +link_password on the Main. +""" +import argparse +import socket +import struct +import sys +import time + +MAGIC = b"HBX" +VERSION = 0x01 +HEADER_SIZE = 12 +PROTO_LINK_AUTH = 0x03 +PROTO_LINK_AUTH_ACK = 0x04 +PROTO_AX25_UI = 0x02 +FLAG_TX = 0x0002 +FLAG_PATH = 0x0004 +ADDR_LEN = 7 + + +def be16(v): + return struct.pack(">H", v) + + +def be32(v): + return struct.pack(">I", v) + + +def encode_frame(proto, flags, payload): + return bytes([ord("H"), ord("B"), ord("X"), VERSION, proto]) + be16(flags) + be32(len(payload)) + payload + + +def encode_addr(call, ssid=0, last=False): + call = call.upper().ljust(6)[:6] + raw = bytes((ord(c) << 1) for c in call) + ssid_b = ((ssid & 0x0F) << 1) | (0x60 if not last else 0x61) + return raw + bytes([ssid_b]) + + +def pack_ax25_ui(dest, src, message): + body = bytes([0]) # digi count + body += encode_addr(dest, last=False) + body += encode_addr(src, last=True) + body += message.encode("ascii", errors="replace") + return encode_frame(PROTO_AX25_UI, FLAG_TX | FLAG_PATH, body) + + +def link_auth_payload(password, link_id, role, baud=2400, duplex="half", bandwidth="low", mhz="27.235"): + lines = [ + f"password={password}", + f"role={role}", + f"id={link_id}", + f"baud={baud}", + f"duplex={duplex}", + f"bandwidth={bandwidth}", + f"frequency_mhz={mhz}", + ] + return "\n".join(lines).encode("ascii") + + +def recv_frames(sock, timeout=5.0): + sock.settimeout(timeout) + buf = b"" + try: + while True: + chunk = sock.recv(4096) + if not chunk: + break + buf += chunk + except socket.timeout: + pass + return buf + + +def main(): + ap = argparse.ArgumentParser(description="HyBBX one-shot AX.25 RF beacon via circuit hub") + ap.add_argument("--host", default="127.0.0.1") + ap.add_argument("--port", type=int, default=7323) + ap.add_argument("--password", default="changeme") + ap.add_argument("--link-id", default="beacon-once") + ap.add_argument("--mycall", default="CB-0") + ap.add_argument("--dest", default="*") + ap.add_argument("--message", default="Broadcast: CB-0 online") + args = ap.parse_args() + + sock = socket.create_connection((args.host, args.port), timeout=5) + auth = link_auth_payload(args.password, args.link_id, "gateway") + sock.sendall(encode_frame(PROTO_LINK_AUTH, 0, auth)) + ack = recv_frames(sock, 3.0) + if b"ok=yes" not in ack: + print("LINK_AUTH failed:", ack[:200], file=sys.stderr) + sys.exit(1) + print(f"LINK_AUTH ok — TX {args.mycall} -> {args.dest}: {args.message!r}") + sock.sendall(pack_ax25_ui(args.dest, args.mycall, args.message)) + time.sleep(2) + sock.close() + print("Beacon frame sent to circuit hub.") + + +if __name__ == "__main__": + main() diff --git a/stacks/tncs/hybbx-dual.ini b/stacks/tncs/hybbx-dual.ini new file mode 100644 index 0000000..5e7e753 --- /dev/null +++ b/stacks/tncs/hybbx-dual.ini @@ -0,0 +1,62 @@ +; TNCs-Stack — HyBBX service mode dual TNC (packet_radio) +; For two serial TNCs on unique UARTs. Do not use when BayCom already owns ttyS0. +; Prerequisite: max25d [devices] with tnc2c + pktnc2, boot-wait / recovery per port. +; BayCom dual-radio HyBBX: share/hybbx/service-dual.ini.example instead. +; +; Example (both TNCs active, no BayCom on ttyS0): +; tnc2c → /dev/ttyS4 +; pktnc2 → /dev/ttyS5 +; +; MAX25 owns KISS entry on both ports — kiss_entry = none on each transport. + +[networks] +ax25 = yes + +[transport.packet_radio1] +enabled = yes +tnc = tnc2c +protocol = kiss +device_type = serial +device = /dev/ttyS4 +baud = 19200 +serial_line = 8n1 +rts_dtr = yes +kiss_entry = none +modem = tcm3105 +radio_baud = 2400 +radio_band = cb +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 + +[transport.packet_radio2] +enabled = yes +tnc = pktnc2 +protocol = kiss +device_type = serial +device = /dev/ttyS5 +baud = 9600 +serial_line = 8n1 +rts_dtr = no +kiss_entry = none +modem = tcm3105 +radio_baud = 1200 +radio_band = cb +frequency_mhz = 27.205 +txdelay = 50 +persist = 255 +slot = 10 +circuit_host = main.example.com +circuit_port = 7323 +link_id = max25-pktnc2 +link_password = changeme +mycall = CB-1 + +[max25] +check = yes diff --git a/stacks/tncs/hybbx-tnc2c.ini b/stacks/tncs/hybbx-tnc2c.ini new file mode 100644 index 0000000..e1d0faa --- /dev/null +++ b/stacks/tncs/hybbx-tnc2c.ini @@ -0,0 +1,38 @@ +; TNCs-Stack — HyBBX Secondary host (TNC2C / packet_radio) +; Canonical shipped copy: share/hybbx/tnc2c-host.ini.example +; Merge into HyBBX hybbx.ini on Secondary node. +; Prerequisite: max25d auto_start or ./scripts/max25-ctl start --hardware tncs --device tnc2c +; +; Example onboard layout: TNC2C on /dev/ttyS4 (ttyS0 = BayCom, ttyS5 = PK-TNC2). +; USB Linux hosts: set device = /dev/ttyUSB0 or /dev/serial/by-id/... + +[networks] +ax25 = yes + +[transport.packet_radio1] +enabled = yes +tnc = tnc2c +protocol = kiss +device_type = serial +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/stacks/tncs/load-serial-env.sh b/stacks/tncs/load-serial-env.sh new file mode 100755 index 0000000..a7f0e76 --- /dev/null +++ b/stacks/tncs/load-serial-env.sh @@ -0,0 +1,71 @@ +# shellcheck shell=bash +# Load station serial env for TNC boot-wait (tnc2c | pktnc2). +# Sourced by tnc2c-boot-wait.sh / pktnc2-boot-wait.sh — do not execute directly. +# +# Search order (first match wins): +# /etc/max25/<id>-serial.env +# $MAX25_ROOT/local/<id>-serial.env +# $PWD/local/<id>-serial.env (cwd when start script runs) +# <checkout>/local/<id>-serial.env (walk up from script dir) +# $PREFIX/share/max25/stacks/tncs/<id>-serial.env + +load_serial_env() { + local device_id="${1:?device_id required}" + local script_dir="${2:?script_dir required}" + local env_name="${device_id}-serial.env" + local candidates=() + local p dir + + candidates+=("/etc/max25/${env_name}") + + if [[ -n "${MAX25_ROOT:-}" ]]; then + candidates+=("${MAX25_ROOT}/local/${env_name}") + candidates+=("${MAX25_ROOT}/stacks/tncs/${env_name}") + fi + + if [[ -n "${PWD:-}" ]]; then + candidates+=("${PWD}/local/${env_name}") + candidates+=("${PWD}/stacks/tncs/${env_name}") + fi + + dir="${script_dir}" + while [[ "${dir}" != "/" ]]; do + candidates+=("${dir}/local/${env_name}") + candidates+=("${dir}/stacks/tncs/${env_name}") + local parent + parent="$(dirname "${dir}")" + [[ "${parent}" == "${dir}" ]] && break + dir="${parent}" + done + + candidates+=("${script_dir}/${env_name}") + + local prefix="${script_dir}" + for _ in 1 2 3 4; do + prefix="$(dirname "${prefix}")" + candidates+=("${prefix}/share/max25/stacks/tncs/${env_name}") + done + + for p in "${candidates[@]}"; do + if [[ -f "${p}" ]]; then + # shellcheck source=/dev/null + source "${p}" + SERIAL_ENV_FILE="${p}" + return 0 + fi + done + SERIAL_ENV_FILE="" + return 1 +} + +apply_tnc2c_env() { + export TNC_DEV="${TNC2C_DEV:-${TNC_DEV:-/dev/ttyS4}}" + export TNC_BAUD="${TNC2C_BAUD:-${TNC_BAUD:-19200}}" + export TNC_LINE="${TNC2C_LINE:-${TNC_LINE:-8n1}}" +} + +apply_pktnc2_env() { + export TNC_DEV="${PKTNC2_DEV:-${TNC_DEV:-/dev/ttyS5}}" + export TNC_BAUD="${PKTNC2_BAUD:-${TNC_BAUD:-9600}}" + export TNC_LINE="${PKTNC2_LINE:-${TNC_LINE:-8n1}}" +} diff --git a/stacks/tncs/pktnc2-boot-wait.sh b/stacks/tncs/pktnc2-boot-wait.sh new file mode 100755 index 0000000..965fac8 --- /dev/null +++ b/stacks/tncs/pktnc2-boot-wait.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# PK-TNC2 boot-wait — TheFirmware TNC-2 class, typically 9600 8N1 +# +# Software recovery (no power cycle): +# ./pktnc2-boot-wait.sh --recover-only +# Rescue (DTR + power cycle while script runs): +# ./pktnc2-boot-wait.sh +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=load-serial-env.sh +source "${ROOT}/load-serial-env.sh" +load_serial_env pktnc2 "${ROOT}" || true +apply_pktnc2_env +if [[ -n "${SERIAL_ENV_FILE:-}" ]]; then + echo "Using serial env: ${SERIAL_ENV_FILE}" >&2 +else + echo "WARN: no pktnc2-serial.env found — using ${TNC_DEV} ${TNC_BAUD} ${TNC_LINE}" >&2 +fi +exec python3 "${ROOT}/tnc2c-boot-wait.py" "${TNC_DEV}" --baud "${TNC_BAUD}" --line "${TNC_LINE}" "$@" diff --git a/stacks/tncs/pktnc2-serial.env b/stacks/tncs/pktnc2-serial.env new file mode 100644 index 0000000..046e6d0 --- /dev/null +++ b/stacks/tncs/pktnc2-serial.env @@ -0,0 +1,4 @@ +# PK-TNC2 serial — planned; probe port after hardware delivery +PKTNC2_DEV=/dev/ttyUSB1 +PKTNC2_BAUD=9600 +PKTNC2_LINE=8n1 diff --git a/stacks/tncs/pktnc2-to-hybbx.sh b/stacks/tncs/pktnc2-to-hybbx.sh new file mode 100755 index 0000000..0444b5f --- /dev/null +++ b/stacks/tncs/pktnc2-to-hybbx.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# AX.25 UI frame PK-TNC2 -> HyBBX/TNC2C over the air. +# HyBBX must be running on Unit A (ttyS4). PK-TNC2: boot-wait first. +# +# Usage: ./pktnc2-to-hybbx.sh [message] [src] [dst] +# Default: "CONNECT" CB-0-2 CB-0 + +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=/dev/null +source "$ROOT/pktnc2-serial.env" + +MSG="${1:-CONNECT}" +SRC="${2:-CB-0-2}" +DST="${3:-CB-0}" + +if pgrep -x hybbx >/dev/null && fuser -s "$TNC_DEV" 2>/dev/null; then + echo "WARN: HyBBX may hold ttyS5 - only ttyS4 should be active" >&2 +fi + +echo "ON-AIR TX: $SRC -> $DST: $MSG" +echo "Radio: both on same MHz (e.g. 27.235 K24), SQ closed before TX" +export TNC2C_DEV="$TNC_DEV" TNC2C_BAUD="$TNC_BAUD" TNC2C_LINE="$TNC_LINE" +exec "$ROOT/tnc2c-send-test.sh" "$MSG" "$SRC" "$DST" diff --git a/stacks/tncs/test_tnc_serial_recovery.py b/stacks/tncs/test_tnc_serial_recovery.py new file mode 100644 index 0000000..99c9476 --- /dev/null +++ b/stacks/tncs/test_tnc_serial_recovery.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Offline unit tests for tnc_serial_recovery (no serial hardware).""" +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location( + "tnc_serial_recovery", ROOT / "tnc_serial_recovery.py" +) +assert spec and spec.loader +mod = importlib.util.module_from_spec(spec) +spec.loader.exec_module(mod) + + +class MockPort: + def __init__(self) -> None: + self.written: list[bytes] = [] + self.responses: list[bytes] = [] + self._read_idx = 0 + + def write_flush(self, data: bytes) -> None: + self.written.append(data) + + def read_for(self, seconds: float) -> bytes: + del seconds + if self._read_idx < len(self.responses): + chunk = self.responses[self._read_idx] + self._read_idx += 1 + return chunk + return b"" + + +def test_has_banner() -> None: + assert mod.has_banner(b"NORD><LINK TheFirmware Version 2.7") + assert mod.has_banner(b"cmd: ") + assert not mod.has_banner(b"INFO\r\nINFO") + + +def test_format_rx_brief_and_classify() -> None: + assert mod.format_rx_brief(b"") == "0 B silent" + brief = mod.format_rx_brief(b"kiss off\rINFO\r") + assert "14 B" in brief + assert "markers=none" in brief + assert "echo-only" in mod.classify_recovery_failure(b"kiss off\rINFO\r") + assert "silent" in mod.classify_recovery_failure(b"") + diag = mod.classify_recovery_failure(b"TheFirmware cmd:") + assert "banner" in diag + + +def test_probe_tf_terminal() -> None: + port = MockPort() + port.responses = [b"TheFirmware Version 2.7 DAMA\r\nChecksum"] + ok, combined, only_echo = mod.probe_tf_terminal(port.write_flush, port.read_for) + assert ok + assert not only_echo + assert port.written[0] == mod.TF_ESC_V + + +def test_tf_mycall_frame() -> None: + assert mod.tf_mycall_frame("cb-0") == b"\x1bI CB-0\r" + + +def test_recover_terminal_ok_on_passive_banner() -> None: + port = MockPort() + port.responses = [b"NORD TheFirmware Version 2.7\r\n"] + ok, rx = mod.recover_terminal( + port.write_flush, port.read_for, skip_kiss_frame=True, log=lambda _m: None + ) + assert ok + assert b"TheFirmware" in rx + assert b"\xc0\xff\xc0" not in port.written + + +def test_recover_terminal_ok_after_kiss_return() -> None: + port = MockPort() + port.responses = [ + b"", + b"TheFirmware Version 2.7\r\nChecksum (0000) = DC0A\r\n", + ] + ok, rx = mod.recover_terminal( + port.write_flush, port.read_for, skip_kiss_frame=False, log=lambda _m: None + ) + assert ok + assert b"TheFirmware" in rx + assert port.written[0] == b"\xc0\xff\xc0" + + +def test_recover_terminal_sends_qres_on_failure() -> None: + port = MockPort() + port.responses = [b""] * 40 + ok, _ = mod.recover_terminal( + port.write_flush, port.read_for, skip_kiss_frame=True, log=lambda _m: None + ) + assert not ok + assert mod.TF_ESC_QRES in port.written + assert b"RESTART\r" not in port.written + + +def test_enter_kiss_native() -> None: + port = MockPort() + port.responses = [b""] + mod.enter_kiss(port.write_flush, port.read_for, entry="kiss_on") + assert port.written[0] == mod.TF_ESC_AT_K + + +def main() -> int: + tests = [ + test_has_banner, + test_format_rx_brief_and_classify, + test_probe_tf_terminal, + test_tf_mycall_frame, + test_recover_terminal_ok_on_passive_banner, + test_recover_terminal_ok_after_kiss_return, + test_recover_terminal_sends_qres_on_failure, + test_enter_kiss_native, + ] + for fn in tests: + fn() + print(f"OK {fn.__name__}") + print(f"All {len(tests)} tests passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stacks/tncs/tnc2c-2.jpg b/stacks/tncs/tnc2c-2.jpg Binary files differnew file mode 100644 index 0000000..29c70c4 --- /dev/null +++ b/stacks/tncs/tnc2c-2.jpg diff --git a/stacks/tncs/tnc2c-autotest.py b/stacks/tncs/tnc2c-autotest.py new file mode 100755 index 0000000..4470695 --- /dev/null +++ b/stacks/tncs/tnc2c-autotest.py @@ -0,0 +1,925 @@ +#!/usr/bin/env python3 +""" +tnc2c-autotest - safe, exhaustive Landolt TNC2C serial discovery. + +Tests baud/parity combinations, modem lines, host vs echo vs garbage, +firmware banner, and (optionally) KISS without RF frames. + +Does NOT send KISS UI frames or key PTT unless --tx is passed. + +Usage: + ./tnc2c-autotest.sh + ./tnc2c-autotest.sh /dev/ttyS4 + ./tnc2c-autotest.sh --quick # likely bauds only (~45s) + ./tnc2c-autotest.sh --write-env # update tnc2c-serial.env if confident + ./tnc2c-autotest.sh --json out.json + ./tnc2c-autotest.sh --host-check # after power reset: 19200 8N1 only (~6s) + ./tnc2c-autotest.sh --host-check --write-env +""" + +from __future__ import annotations + +import argparse +import json +import os +import struct +import subprocess +import sys +import time +import termios +import fcntl +from dataclasses import asdict, dataclass, field +from typing import Optional + +BAUD_MAP: dict[int, int] = { + 300: termios.B300, + 600: termios.B600, + 1200: termios.B1200, + 2400: termios.B2400, + 4800: termios.B4800, + 9600: termios.B9600, + 19200: termios.B19200, +} + +BAUDS_FULL = (300, 600, 1200, 2400, 4800, 9600, 19200) +BAUDS_QUICK = (19200, 2400, 4800, 9600) +HOST_CHECK_LINES = ("8N1",) + +LINE_FORMATS = ( + ("8N1", 8, "none", 1), + ("7E1", 7, "even", 1), + ("8E1", 8, "even", 1), +) + +LINE_PREFERENCE = {"8N1": 3, "7E1": 2, "8E1": 1} + +FIRMWARE_MARKERS = ( + b"TheFirmware", + b"NORD", + b"Version 2.7", + b"Checksum", + b"Copyright", + b"DAMA", + b"SMACK", +) + +HOST_MARKERS = ( + b"cmd:", + b"CMD:", + b"help", + b"HELP", + b"MYCALL", + b"KISS was", + b"KISS is", + b"KISS now", + b"Invalid", + b"Unknown", +) + + +@dataclass +class ProfileResult: + name: str + baud: int + line: str + rts: bool = False + cts: bool = False + dsr: bool = False + dcd: bool = False + passive_bytes: int = 0 + boot_bytes: int = 0 + info_reply: bytes = b"" + help_reply: bytes = b"" + kiss_reply: bytes = b"" + combined_reply: bytes = b"" + printable_ratio: float = 0.0 + host_score: int = 0 + echo_only: bool = False + garbage: bool = False + has_cmd: bool = False + has_firmware: bool = False + notes: list[str] = field(default_factory=list) + + def classify(self) -> str: + if ( + self.has_firmware + or self.has_cmd + or has_firmware_banner(self.info_reply) + ): + return "HOST" + if self.garbage: + return "GARBAGE" + if self.echo_only or ( + is_echo(b"INFO\r", self.info_reply) and is_echo(b"HELP\r", self.help_reply) + ): + return "ECHO" + if self.combined_reply: + return "PARTIAL" + return "SILENT" + + +def has_firmware_banner(data: bytes) -> bool: + if not data: + return False + lower = data.lower() + return any(m.lower() in lower for m in FIRMWARE_MARKERS) + + +def order_bauds(bauds: tuple[int, ...]) -> tuple[int, ...]: + """Test 19200 first so wrong bauds don't disturb a working host session.""" + rest = tuple(b for b in bauds if b != 19200) + return (19200, *rest) if 19200 in bauds else bauds + + +def line_format(line: str) -> tuple[int, str, int]: + for name, data_bits, parity, stop_bits in LINE_FORMATS: + if name == line: + return data_bits, parity, stop_bits + raise ValueError(f"unknown line format {line}") + + +def timings_for(baud: int, gentle: bool) -> tuple[float, float, float]: + """Returns listen_s, cmd_wait, info_read.""" + if gentle or baud == 19200: + return (2.0 if gentle else 1.5, 0.9 if gentle else 0.7, 3.0 if gentle else 3.0) + return (1.2, 0.55, 1.6) + + +def finalize_profile( + res: ProfileResult, + info_cmd: bytes, + info: bytes, + help_cmd: bytes, + help: bytes, + kiss_on: bytes, + combined: bytes, +) -> None: + res.info_reply = info + res.help_reply = help + res.kiss_reply = kiss_on + res.combined_reply = combined + res.printable_ratio = printable_ratio(combined) + + s1, e1, c1, f1 = score_replies(info_cmd, info) + s2, e2, c2, f2 = score_replies(help_cmd, help) + s3, _, c3, f3 = score_replies(b"kiss on\r", kiss_on) + res.host_score = max(s1, s2, s3) + res.has_cmd = c1 or c2 or c3 + res.has_firmware = ( + f1 or f2 or f3 + or has_firmware_banner(info) + or has_firmware_banner(help) + ) + if res.has_firmware: + res.host_score = max(res.host_score, 55) + res.echo_only = False + elif is_echo(info_cmd, info) and (not help or is_echo(help_cmd, help)): + res.echo_only = True + else: + res.echo_only = (e1 and e2) and res.host_score < 15 + res.garbage = ( + bool(combined) + and res.printable_ratio < 0.55 + and not res.has_firmware + and res.host_score < 20 + ) + if res.passive_bytes and res.has_firmware: + res.notes.append("boot_banner") + if kiss_on and not is_echo(b"kiss on\r", kiss_on): + res.notes.append("kiss_ack") + + +def rank_key(res: ProfileResult) -> tuple: + """Higher is better. Prefer real host, then 8N1/7E1 over 8E1.""" + return ( + int(res.has_firmware), + int(res.has_cmd), + int(not res.echo_only), + res.host_score, + res.printable_ratio, + LINE_PREFERENCE.get(res.line, 0), + len(res.combined_reply), + ) + + +def load_env(path: str) -> dict[str, str]: + env: dict[str, str] = {} + if not os.path.isfile(path): + return env + with open(path, encoding="utf-8") as f: + for raw in f: + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + env[k.strip()] = v.strip() + return env + + +def printable_ratio(data: bytes) -> float: + if not data: + return 0.0 + good = sum(1 for b in data if b in (9, 10, 13) or 32 <= b < 127) + return good / len(data) + + +def open_serial( + dev: str, + baud: int, + data_bits: int, + parity: str, + stop_bits: int, + rts: bool = True, + dtr: bool = True, +) -> int: + if baud not in BAUD_MAP: + raise ValueError(f"unsupported baud {baud}") + fd = os.open(dev, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK) + t = termios.tcgetattr(fd) + t[0] = t[1] = 0 + cflag = termios.CLOCAL | termios.CREAD + if data_bits == 7: + cflag |= termios.CS7 + else: + cflag |= termios.CS8 + if parity == "even": + cflag |= termios.PARENB + elif parity == "odd": + cflag |= termios.PARENB | termios.PARODD + t[2] = cflag + speed = BAUD_MAP[baud] + 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 rts: + flags |= 0x004 + else: + flags &= ~0x004 + if dtr: + flags |= 0x002 + else: + flags &= ~0x002 + fcntl.ioctl(fd, 0x5416, struct.pack("I", flags)) + return fd + + +def modem_lines(fd: int) -> tuple[bool, bool, bool, bool]: + flags = struct.unpack("I", fcntl.ioctl(fd, 0x5415, struct.pack("I", 0)))[0] + return ( + bool(flags & 0x004), + bool(flags & 0x008), + bool(flags & 0x100), + bool(flags & 0x080), + ) + + +def read_for(fd: int, seconds: float) -> bytes: + end = time.time() + seconds + chunks: list[bytes] = [] + while time.time() < end: + try: + b = os.read(fd, 4096) + if b: + chunks.append(b) + except BlockingIOError: + time.sleep(0.02) + return b"".join(chunks) + + +def write_flush(fd: int, data: bytes) -> None: + os.write(fd, data) + termios.tcdrain(fd) + + +def is_echo(cmd: bytes, reply: bytes) -> bool: + if not reply: + return False + c = cmd.strip(b"\r\n") + r = reply.strip() + return r in (c, c + b"\r", c + b"\n", c + b"\r\n") + + +def score_replies(cmd: bytes, *chunks: bytes) -> tuple[int, bool, bool, bool]: + reply = b"".join(chunks) + if not reply: + return 0, False, False, False + + score = 0 + lower = reply.lower() + has_cmd = b"cmd:" in lower + has_fw = any(m.lower() in lower for m in FIRMWARE_MARKERS) + + if has_cmd: + score += 60 + if has_fw: + score += 50 + for m in HOST_MARKERS: + if m.lower() in lower: + score += 8 + if len(reply) > len(cmd) + 8 and not is_echo(cmd, reply): + score += 10 + if b"\n" in reply and len(reply) > 30: + score += 5 + + echo = is_echo(cmd, reply) and score < 20 + garbage = printable_ratio(reply) < 0.55 and len(reply) >= 4 + return score, echo, has_cmd, has_fw + + +def profile_name(baud: int, line: str) -> str: + return f"{baud}-{line}" + + +def test_profile( + dev: str, + baud: int, + line: str, + data_bits: int, + parity: str, + stop_bits: int, + gentle: bool = False, +) -> ProfileResult: + listen_s, cmd_wait, info_read = timings_for(baud, gentle) + res = ProfileResult(name=profile_name(baud, line), baud=baud, line=line) + fd = open_serial(dev, baud, data_bits, parity, stop_bits) + res.rts, res.cts, res.dsr, res.dcd = modem_lines(fd) + + passive = read_for(fd, listen_s) + res.passive_bytes = len(passive) + + if not gentle: + write_flush(fd, b"\x03\x1a") + time.sleep(0.15) + read_for(fd, 0.2) + + write_flush(fd, b"\r\r") + time.sleep(cmd_wait) + wake = read_for(fd, listen_s) + + write_flush(fd, b"kiss off\r") + time.sleep(cmd_wait) + kiss_off = read_for(fd, listen_s) + + info_cmd = b"INFO\r" + write_flush(fd, info_cmd) + time.sleep(cmd_wait) + info = read_for(fd, info_read) + + help_cmd = b"HELP\r" + write_flush(fd, help_cmd) + time.sleep(cmd_wait) + help = read_for(fd, listen_s + 0.3) + + write_flush(fd, b"kiss on\r") + time.sleep(cmd_wait) + kiss_on = read_for(fd, listen_s) + + write_flush(fd, b"kiss off\r") + time.sleep(0.2) + read_for(fd, 0.2) + + finalize_profile( + res, info_cmd, info, help_cmd, help, kiss_on, + passive + wake + kiss_off + info + help + kiss_on, + ) + os.close(fd) + return res + + +def test_host_check( + dev: str, + baud: int, + line: str, + dtr_cycle: bool = False, + no_kiss: bool = False, +) -> ProfileResult: + """Gentle single-profile check - DTR high, optional KISS-reset, kiss off, INFO.""" + data_bits, parity, stop_bits = line_format(line) + res = ProfileResult(name=profile_name(baud, line), baud=baud, line=line) + + prelude = b"" + if dtr_cycle: + boot = boot_listen(dev, baud, line, data_bits, parity, stop_bits) + res.boot_bytes = len(boot) + prelude = boot + if has_firmware_banner(boot): + res.has_firmware = True + res.notes.append("dtr_boot_banner") + + fd = open_serial(dev, baud, data_bits, parity, stop_bits) + res.rts, res.cts, res.dsr, res.dcd = modem_lines(fd) + + passive = read_for(fd, 1.0) + res.passive_bytes = len(passive) + prelude += passive + if has_firmware_banner(passive): + res.has_firmware = True + res.notes.append("passive_boot_banner") + + if not no_kiss: + write_flush(fd, b"\xc0\xff\xc0") + time.sleep(0.8) + prelude += read_for(fd, 0.8) + res.notes.append("kiss_reset") + + write_flush(fd, b"kiss off\r") + time.sleep(0.4) + kiss_off = read_for(fd, 0.8) + + info_cmd = b"INFO\r" + write_flush(fd, info_cmd) + time.sleep(0.3) + info = read_for(fd, 3.5) + + finalize_profile(res, info_cmd, info, b"HELP\r", b"", b"", prelude + kiss_off + info) + os.close(fd) + return res + + +def boot_listen(dev: str, baud: int, line: str, data_bits: int, parity: str, stop_bits: int) -> bytes: + fd = open_serial(dev, baud, data_bits, parity, stop_bits) + flags = struct.unpack("I", fcntl.ioctl(fd, 0x5415, struct.pack("I", 0)))[0] + flags &= ~0x002 + fcntl.ioctl(fd, 0x5416, struct.pack("I", flags)) + time.sleep(0.8) + flags |= 0x002 | 0x004 + fcntl.ioctl(fd, 0x5416, struct.pack("I", flags)) + data = read_for(fd, 4.0) + os.close(fd) + return data + + +def deep_test(dev: str, res: ProfileResult) -> ProfileResult: + line = res.line + data_bits, parity, stop_bits = line_format(line) + gentle = res.baud == 19200 + + fd = open_serial(dev, res.baud, data_bits, parity, stop_bits) + res.rts, res.cts, res.dsr, res.dcd = modem_lines(fd) + + write_flush(fd, b"kiss off\r") + time.sleep(0.6 if gentle else 0.4) + read_for(fd, 0.3) + + for _ in range(4): + write_flush(fd, b"\r") + time.sleep(0.35) + enters = read_for(fd, 1.5) + if b"cmd:" in enters.lower(): + res.has_cmd = True + res.host_score += 20 + res.notes.append("cmd_after_enter") + + write_flush(fd, b"MYCALL TEST-0\r") + time.sleep(0.6) + mycall = read_for(fd, 1.0) + if mycall and not is_echo(b"MYCALL TEST-0\r", mycall): + lower = mycall.lower() + if b"cmd:" in lower or has_firmware_banner(mycall): + res.host_score += 15 + res.notes.append("mycall_ack") + + if not res.has_firmware: + boot = boot_listen(dev, res.baud, line, data_bits, parity, stop_bits) + res.boot_bytes = len(boot) + if has_firmware_banner(boot): + res.has_firmware = True + res.host_score = max(res.host_score, 55) + res.echo_only = False + res.notes.append("dtr_boot_banner") + + os.close(fd) + return res + + +def port_holders(dev: str) -> list[str]: + holders: list[str] = [] + try: + out = subprocess.run( + ["fuser", "-v", dev], + capture_output=True, + text=True, + timeout=3, + ) + if out.returncode == 0 and out.stderr: + for line in out.stderr.splitlines(): + line = line.strip() + if line and not line.startswith("USER"): + holders.append(line) + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + return holders + + +def preflight(dev: str) -> list[str]: + issues: list[str] = [] + if os.system("pgrep -x hybbx >/dev/null 2>&1") == 0: + issues.append("hybbx is running - stop it first") + if not os.path.exists(dev): + issues.append(f"{dev} does not exist") + elif not os.access(dev, os.R_OK | os.W_OK): + issues.append(f"no access to {dev} (dialout group?)") + holders = port_holders(dev) + if holders: + issues.append(f"port in use: {', '.join(holders[:3])}") + return issues + + +def quartz_hint(baud: int) -> str: + hints = [] + term_98304 = {9600: "TERM Pos.1", 4800: "TERM Pos.2", 2400: "TERM Pos.3", 1200: "TERM Pos.4"} + term_49152 = {4800: "TERM Pos.1", 2400: "TERM Pos.2", 1200: "TERM Pos.3", 600: "TERM Pos.4"} + if baud in term_98304: + hints.append(f"with 9.8304 MHz crystal: {term_98304[baud]}") + if baud in term_49152: + hints.append(f"with 4.9152 MHz crystal: {term_49152[baud]}") + if baud == 19200: + hints.append("19200 is in the manual only with RADIO bridge (2.4576 MHz Pos.5), not TERM") + hints.append("-> possible mod, extended firmware, or different crystal") + return "; ".join(hints) if hints else "-" + + +def suggest_env(best: ProfileResult) -> dict[str, str]: + line = best.line.lower() + return { + "TNC2C_DEV": "", # filled by caller + "TNC2C_BAUD": str(best.baud), + "TNC2C_LINE": line, + "TNC2C_RADIO_BAUD": "2400", + "TNC2C_MODEM": "tcm3105", + "HYBBX_SERIAL_LINE": line, + } + + +def write_env_file(path: str, dev: str, best: ProfileResult) -> None: + vals = suggest_env(best) + vals["TNC2C_DEV"] = dev + lines = [ + f"# Auto-generated by tnc2c-autotest {time.strftime('%Y-%m-%d %H:%M:%S')}", + f"# Best profile: {best.name} ({best.classify()}, score={best.host_score})", + ] + for k, v in vals.items(): + lines.append(f"{k}={v}") + lines.append("") + with open(path, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + + +# --- optional TX (from health check) --- + +def kiss_escape(data: bytes) -> bytes: + out = bytearray() + for b in data: + if b == 0xC0: + out.extend((0xDB, 0xDC)) + elif b == 0xDB: + out.extend((0xDB, 0xDD)) + else: + out.append(b) + return bytes(out) + + +def ax25_addr(call: str, ssid: int, last: bool) -> bytes: + call = call.upper().ljust(6)[:6] + raw = bytes((ord(c) << 1) for c in call) + ssid_b = ((ssid & 0x0F) << 1) | (0x01 if last else 0x00) + return raw + bytes([ssid_b]) + + +def ax25_crc(data: bytes) -> int: + crc = 0xFFFF + for b in data: + crc ^= b + for _ in range(8): + crc = (crc >> 1) ^ 0x8408 if crc & 1 else crc >> 1 + return crc ^ 0xFFFF + + +def ax25_ui(src: str, dst: str, info: bytes) -> bytes: + body = ax25_addr(dst, 0, False) + ax25_addr(src, 0, True) + b"\x03\xF0" + info + crc = ax25_crc(body) + return body + bytes((crc & 0xFF, crc >> 8)) + + +def run_tx_test(dev: str, best: ProfileResult) -> dict: + fmt = next(f for f in LINE_FORMATS if f[0] == best.line) + _, data_bits, parity, stop_bits = fmt + fd = open_serial(dev, best.baud, data_bits, parity, stop_bits) + write_flush(fd, b"kiss off\r") + time.sleep(0.4) + read_for(fd, 0.3) + write_flush(fd, b"kiss on\r") + time.sleep(0.5) + read_for(fd, 0.3) + payload = ax25_ui("TEST-0", "CQ", b"AUTOTEST") + pkt = b"\xC0" + bytes([0]) + kiss_escape(payload) + b"\xC0" + write_flush(fd, pkt) + time.sleep(3.0) + rx = read_for(fd, 1.0) + write_flush(fd, b"kiss off\r") + os.close(fd) + return { + "sent": len(pkt), + "rx_len": len(rx), + "echoed": pkt in rx or payload in rx, + } + + +def preview(data: bytes, limit: int = 72) -> str: + if not data: + return "(empty)" + s = data[:limit].decode("ascii", errors="replace") + s = s.replace("\r", "\\r").replace("\n", "\\n") + if len(data) > limit: + s += "..." + return s + + +def print_header(title: str) -> None: + print(f"\n{'=' * 64}") + print(title) + print("=" * 64) + + +def print_result_summary( + best: ProfileResult, + dev: str, + ranked: list[ProfileResult], + root: str, + args: argparse.Namespace, + suggested: dict[str, str], + tx_result: Optional[dict], + t0: float, +) -> int: + host_profiles = [r for r in ranked if r.classify() == "HOST"] + echo_profiles = [r for r in ranked if r.classify() == "ECHO"] + garbage_profiles = [r for r in ranked if r.classify() == "GARBAGE"] + + print_header("Result") + print(f" Best profile: {best.name}") + print(f" Class: {best.classify()}") + print(f" Host score: {best.host_score}") + print(f" Modem: RTS={int(best.rts)} CTS={int(best.cts)} DSR={int(best.dsr)}") + print(f" INFO: {preview(best.info_reply, 120)}") + if best.has_firmware or has_firmware_banner(best.info_reply): + print(" Firmware banner: YES") + if best.has_cmd: + print(" cmd: prompt: YES") + print(f" Crystal/bridge: {quartz_hint(best.baud)}") + + if host_profiles: + print(f"\n HOST profiles ({len(host_profiles)}): {', '.join(r.name for r in host_profiles[:5])}") + if echo_profiles and not host_profiles: + print(f"\n ECHO only ({len(echo_profiles)}) - TNC mirrors commands, does not process them.") + print(" -> Power-reset the TNC, then: ./tnc2c-autotest.sh --host-check --write-env") + if garbage_profiles: + top_g = sorted(garbage_profiles, key=lambda r: r.printable_ratio, reverse=True)[:3] + print(f" GARBAGE profiles: {', '.join(r.name for r in top_g)} (wrong baud/parity)") + + print("\n Recommended settings:") + print(f" max25-terminal or tnc2c-host-reset @ {dev} -b {best.baud} (# {best.line})") + for k in ("TNC2C_BAUD", "TNC2C_LINE", "HYBBX_SERIAL_LINE"): + print(f" {k}={suggested[k]}") + + if args.write_env and best.classify() == "HOST": + env_path = os.path.join(root, "tnc2c-serial.env") + write_env_file(env_path, dev, best) + print(f"\n Written: {env_path}") + elif args.write_env: + print("\n --write-env skipped (no reliable HOST profile)") + + print_header("KISS-TX") + if not args.tx: + print(" Skipped (safe). Optional: --tx (PTT may key!)") + else: + print(" WARNING: sends AX.25 UI frame - check PTT LED!") + tx_result = run_tx_test(dev, best) + print(f" Sent {tx_result['sent']} B, RX {tx_result['rx_len']} B, Echo={tx_result['echoed']}") + + elapsed = time.time() - t0 + status = "OK" if best.classify() == "HOST" else ( + "DEGRADED" if best.classify() in ("ECHO", "PARTIAL") else "FAIL" + ) + + print_header("SUMMARY") + print(f" Status: {status}") + print(f" Device: {dev}") + print(f" Profile: {best.name} ({best.classify()})") + print(f" Duration: {elapsed:.0f}s") + + if args.json: + report = { + "device": dev, + "status": status, + "elapsed_s": round(elapsed, 1), + "best": asdict(best), + "ranking": [asdict(r) for r in ranked[:12]], + "suggested_env": suggested, + "tx": tx_result, + } + + def fix(obj): + if isinstance(obj, dict): + return {k: fix(v) for k, v in obj.items()} + if isinstance(obj, list): + return [fix(v) for v in obj] + if isinstance(obj, bytes): + return obj.decode("ascii", errors="replace") + return obj + + with open(args.json, "w", encoding="utf-8") as f: + json.dump(fix(report), f, indent=2, ensure_ascii=False) + print(f" JSON: {args.json}") + + return 0 if status == "OK" else (1 if status == "DEGRADED" else 2) + + +def run_host_check( + dev: str, + root: str, + env: dict[str, str], + args: argparse.Namespace, +) -> int: + t0 = time.time() + print_header("TNC2C HOST-CHECK (DTR high - no DTR drop, no baud sweep)") + print(f"Device: {dev}") + print(f"Started: {time.strftime('%Y-%m-%d %H:%M:%S')}") + if args.dtr_cycle: + print("Note: --dtr-cycle active (DTR drop - may trigger echo mode)") + + print_header("1) Preflight") + issues = preflight(dev) + if issues: + for i in issues: + print(f" FAIL: {i}") + print("\nAbort - clear port/background services and retry.") + return 2 + print(" OK: port free, hybbx inactive, access OK") + + kiss_step = "kiss off -> INFO" if args.no_kiss else "KISS-reset -> kiss off -> INFO" + print_header(f"2) Host-Check 19200-8N1 (passive -> {kiss_step})") + results: list[ProfileResult] = [] + for line in HOST_CHECK_LINES: + name = profile_name(19200, line) + if not args.quiet: + print(f" {name:12} ...", end="", flush=True) + try: + r = test_host_check( + dev, 19200, line, dtr_cycle=args.dtr_cycle, no_kiss=args.no_kiss + ) + results.append(r) + if not args.quiet: + print( + f" {r.classify():7} score={r.host_score:3} fw={int(r.has_firmware)} " + f"INFO={preview(r.info_reply, 50)!r}" + ) + except OSError as e: + if not args.quiet: + print(f" ERROR: {e}") + + if not results: + print(" No profiles tested.") + return 2 + + ranked = sorted(results, key=rank_key, reverse=True) + best = ranked[0] + suggested = suggest_env(best) + suggested["TNC2C_DEV"] = dev + return print_result_summary(best, dev, ranked, root, args, suggested, None, t0) + + +def main() -> int: + parser = argparse.ArgumentParser(description="TNC2C safe exhaustive autotest") + parser.add_argument("device", nargs="?", default=None) + parser.add_argument( + "--host-check", + action="store_true", + help="gentle 19200-8N1 only - after power reset (~10s)", + ) + parser.add_argument( + "--dtr-cycle", + action="store_true", + help="brief DTR drop (legacy; may trigger echo mode - not for host-check)", + ) + parser.add_argument( + "--no-kiss", + action="store_true", + help="host-check: skip KISS-reset frame", + ) + parser.add_argument("--quick", action="store_true", help="19200 first, then 2400/4800/9600") + parser.add_argument("--write-env", action="store_true", help="write tnc2c-serial.env if HOST found") + parser.add_argument("--json", metavar="FILE", help="write JSON report") + parser.add_argument("--tx", action="store_true", help="KISS TX test - keys PTT!") + parser.add_argument("-q", "--quiet", action="store_true") + args = parser.parse_args() + + root = os.path.dirname(os.path.abspath(__file__)) + env = load_env(os.path.join(root, "tnc2c-serial.env")) + dev = args.device or env.get("TNC2C_DEV", "/dev/ttyS4") + + if args.host_check: + return run_host_check(dev, root, env, args) + + bauds = order_bauds(BAUDS_QUICK if args.quick else BAUDS_FULL) + total = len(bauds) * len(LINE_FORMATS) + + t0 = time.time() + print_header("TNC2C AUTOTEST (safe - no PTT unless --tx)") + print(f"Device: {dev}") + print(f"Mode: {'quick' if args.quick else 'full'} ({total} profiles, 19200 first)") + print(f"Started: {time.strftime('%Y-%m-%d %H:%M:%S')}") + + print_header("1) Preflight") + issues = preflight(dev) + if issues: + for i in issues: + print(f" FAIL: {i}") + print("\nAbort - clear port/background services and retry.") + return 2 + print(" OK: port free, hybbx inactive, access OK") + + print_header("2) Host precheck 19200-8N1") + precheck = test_host_check(dev, 19200, "8N1", dtr_cycle=args.dtr_cycle) + print( + f" {precheck.name:12} {precheck.classify():7} fw={int(precheck.has_firmware)} " + f"INFO={preview(precheck.info_reply, 60)!r}" + ) + + print_header("3) Profile sweep (19200 first, no Ctrl+C at 19200)") + results: list[ProfileResult] = [precheck] + seen = {precheck.name} + n = 0 + for baud in bauds: + for line, data_bits, parity, stop_bits in LINE_FORMATS: + name = profile_name(baud, line) + if name in seen: + continue + n += 1 + if not args.quiet: + print(f" [{n:2}/{total - 1}] {name:12} ...", end="", flush=True) + try: + gentle = baud == 19200 + r = test_profile(dev, baud, line, data_bits, parity, stop_bits, gentle=gentle) + results.append(r) + seen.add(name) + if not args.quiet: + print( + f" {r.classify():7} score={r.host_score:3} " + f"CTS={int(r.cts)} pr={r.printable_ratio:.0%} " + f"INFO={preview(r.info_reply, 40)!r}" + ) + except OSError as e: + if not args.quiet: + print(f" ERROR: {e}") + + ranked = sorted(results, key=rank_key, reverse=True) + best = ranked[0] if ranked else None + + print_header("4) Ranking (Top 8)") + for r in ranked[:8]: + print( + f" {r.name:12} {r.classify():7} score={r.host_score:3} " + f"cmd={int(r.has_cmd)} fw={int(r.has_firmware)} echo={int(r.echo_only)} " + f"CTS={int(r.cts)}" + ) + + print_header("5) Deep test (Top 3)") + deep_targets = [r for r in ranked[:3] if r.host_score > 0 or r.combined_reply] + if not deep_targets and ranked: + deep_targets = ranked[:1] + for r in deep_targets: + if r.name == precheck.name and precheck.classify() == "HOST": + continue + print(f" -> {r.name} ...", flush=True) + deep_test(dev, r) + + ranked = sorted(results, key=rank_key, reverse=True) + best = ranked[0] + + if best and best.classify() in ("ECHO", "PARTIAL") and env.get("TNC2C_BAUD"): + saved_line = env.get("TNC2C_LINE", "8n1").upper() + if "7" in saved_line and "E" in saved_line: + saved_line = "7E1" + elif saved_line.startswith("8") and "E" in saved_line: + saved_line = "8E1" + else: + saved_line = "8N1" + saved_name = profile_name(int(env["TNC2C_BAUD"]), saved_line) + saved = next((r for r in results if r.name == saved_name), None) + if saved and saved.classify() != "HOST": + print(f" (Note: saved profile {saved_name} - use --host-check after power reset)") + + if best is None: + print(" No response on any profile.") + return 2 + + suggested = suggest_env(best) + suggested["TNC2C_DEV"] = dev + return print_result_summary(best, dev, ranked, root, args, suggested, None, t0) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stacks/tncs/tnc2c-autotest.sh b/stacks/tncs/tnc2c-autotest.sh new file mode 100755 index 0000000..d0f119d --- /dev/null +++ b/stacks/tncs/tnc2c-autotest.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Exhaustive safe TNC2C autotest — no PTT unless --tx. +# Usage: +# ./tnc2c-autotest.sh --host-check # after power reset (~6s, DTR stays high) +# ./tnc2c-autotest.sh --host-check --write-env +# ./tnc2c-autotest.sh --quick # full sweep, 19200 first (~2 min) +# ./tnc2c-autotest.sh --write-env + +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +exec python3 "$ROOT/tnc2c-autotest.py" "$@" diff --git a/stacks/tncs/tnc2c-boot-wait.py b/stacks/tncs/tnc2c-boot-wait.py new file mode 100644 index 0000000..ba24e26 --- /dev/null +++ b/stacks/tncs/tnc2c-boot-wait.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +""" +Hold /dev/ttyS4 open with DTR+RTS during TNC power-on, then native TF probe (ESC V). + +Usage: + 1. Run this FIRST (waits for port): + ./tnc2c-boot-wait.sh + 2. While it says "waiting", power-cycle the TNC (off 10s, on). + 3. Script detects banner or runs KISS return + ESC V automatically. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import fcntl +import os +import struct +import sys +import time +import termios + +def load_recovery(): + root = os.path.dirname(os.path.abspath(__file__)) + path = os.path.join(root, "tnc_serial_recovery.py") + 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 + + +FIRMWARE_MARKERS = ( + b"TheFirmware", + b"NORD", + b"Version 2.7", + b"Checksum", + b"Copyright", +) + + +def has_banner(data: bytes) -> bool: + lower = data.lower() + return any(m.lower() in lower for m in FIRMWARE_MARKERS) + + +def parse_line(line: str) -> tuple[int, int]: + line = line.lower() + if line == "7e1": + return termios.CS7, termios.PARENB + if line == "8n1": + return termios.CS8, 0 + raise ValueError(f"unsupported serial line: {line}") + + +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] + + +def open_serial(dev: str, baud: int, line: str) -> int: + speed = parse_baud(baud) + databits, parity = parse_line(line) + 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] + flags |= 0x004 | 0x002 + fcntl.ioctl(fd, 0x5416, struct.pack("I", flags)) + return fd + + +def read_for(fd: int, seconds: float) -> bytes: + end = time.time() + seconds + chunks: list[bytes] = [] + while time.time() < end: + try: + b = os.read(fd, 4096) + if b: + chunks.append(b) + except BlockingIOError: + time.sleep(0.02) + return b"".join(chunks) + + +def write_flush(fd: int, data: bytes) -> None: + os.write(fd, data) + termios.tcdrain(fd) + + +def show(data: bytes) -> None: + if data: + print(data.decode("ascii", errors="replace")) + + +def is_cmd_echo(cmd: bytes, reply: bytes) -> bool: + c = cmd.strip(b"\r\n") + r = reply.strip() + return r in (c, c + b"\r", c + b"\n", c + b"\r\n") + + +def run_tx_rx(fd: int) -> None: + """TX KISS frame + passive RX on same open fd (before port close).""" + print("\n--- TX (KISS TEST-0 -> CQ, TXRX) ---") + write_flush(fd, b"\x1b@K") + time.sleep(0.5) + read_for(fd, 0.3) + + def kiss_escape(data: bytes) -> bytes: + out = bytearray() + for b in data: + if b == 0xC0: + out.extend((0xDB, 0xDC)) + elif b == 0xDB: + out.extend((0xDB, 0xDD)) + else: + out.append(b) + return bytes(out) + + def ax25_addr(call: str, last: bool) -> bytes: + call = call.upper().ljust(6)[:6] + raw = bytes((ord(c) << 1) for c in call) + ssid = ((0 & 0x0F) << 1) | (0x01 if last else 0x00) + return raw + bytes([ssid]) + + def ax25_crc(data: bytes) -> int: + crc = 0xFFFF + for b in data: + crc ^= b + for _ in range(8): + crc = (crc >> 1) ^ 0x8408 if crc & 1 else crc >> 1 + return crc ^ 0xFFFF + + body = ax25_addr("CQ", False) + ax25_addr("TEST-0", True) + b"\x03\xF0TXRX" + crc = ax25_crc(body) + payload = body + bytes((crc & 0xFF, crc >> 8)) + pkt = b"\xC0\x00" + kiss_escape(payload) + b"\xC0" + write_flush(fd, pkt) + time.sleep(3.0) + tx_rx = read_for(fd, 1.0) + print(f" Sent {len(pkt)} B, serial RX {len(tx_rx)} B") + print(" -> CHECK: LED2 PTT + modem tone on 2m CB") + + write_flush(fd, b"\xc0\xff\xc0") + time.sleep(1.0) + read_for(fd, 0.5) + + print("\n--- RX (passive 10s) ---") + print(" Listening ... (empty band = 0 bytes OK; LED3 CD with noise?)") + rx = read_for(fd, 10.0) + print(f" Passive RX: {len(rx)} bytes") + if rx and b"\xc0" in rx: + print(" OK: KISS frame received") + + +def verify_hybbx_ready(fd: int) -> tuple[bool, bytes, bool]: + """ESC V on open fd. Returns (banner_in_reply, data, only_echo).""" + write_flush(fd, b"\x1bV\r") + time.sleep(0.4) + info = read_for(fd, 5.0) + only_echo = is_cmd_echo(b"\x1bV\r", info) + return has_banner(info), info, only_echo + + +def finish_host(fd: int, hybbx_ready: bool, do_tx_rx: bool) -> int: + if hybbx_ready: + print("\n--- HyBBX-Verify (ESC V) ---") + ok, verify, only_echo = verify_hybbx_ready(fd) + show(verify) + host_ok = ok or only_echo + if not host_ok: + if do_tx_rx: + run_tx_rx(fd) + os.close(fd) + print("\nWARN: unexpected ESC V response") + return 1 + else: + host_ok = True + + if do_tx_rx: + run_tx_rx(fd) + + os.close(fd) + if host_ok: + print("\nOK: HOST - HyBBX-ready" + (" + TX/RX tested" if do_tx_rx else "")) + return 0 + return 1 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Hold DTR during TNC boot") + default_dev = ( + os.environ.get("TNC_DEV") + or os.environ.get("TNC2C_DEV") + or os.environ.get("PKTNC2_DEV") + or "/dev/ttyS4" + ) + default_baud = int( + os.environ.get("TNC_BAUD") + or os.environ.get("TNC2C_BAUD") + or os.environ.get("PKTNC2_BAUD") + or "19200" + ) + default_line = ( + os.environ.get("TNC_LINE") + or os.environ.get("TNC2C_LINE") + or os.environ.get("PKTNC2_LINE") + or "8n1" + ) + + parser.add_argument("device", nargs="?", default=default_dev) + parser.add_argument("--baud", type=int, default=default_baud) + parser.add_argument("--line", default=default_line, choices=("8n1", "7e1")) + parser.add_argument("--wait", type=int, default=45, help="seconds to listen for boot") + parser.add_argument( + "--no-hybbx-ready", + action="store_true", + help="only detect boot banner, skip ESC V verify", + ) + parser.add_argument( + "--tx-rx", + action="store_true", + help="TX KISS + passive RX on same port before close (with power cycle)", + ) + parser.add_argument( + "--recover-only", + action="store_true", + help="skip boot listen; run software recovery ladder only (no power cycle)", + ) + args = parser.parse_args() + hybbx_ready = not args.no_hybbx_ready + do_tx_rx = args.tx_rx + + if not os.access(args.device, os.R_OK | os.W_OK): + print(f"FAIL: no access to {args.device}", file=sys.stderr) + return 2 + + fd = open_serial(args.device, args.baud, args.line) + + if args.recover_only: + print(f"=== TNC recover-only @ {args.device} {args.baud} {args.line.upper()} ===") + rec = load_recovery() + if rec is None: + print("FAIL: tnc_serial_recovery.py not found", file=sys.stderr) + os.close(fd) + return 2 + + def wf(data: bytes) -> None: + write_flush(fd, data) + + def rf(seconds: float) -> bytes: + return read_for(fd, seconds) + + ok, rx = rec.recover_terminal(wf, rf, log=lambda m: print(f" {m}")) + if ok: + show(rx[-800:] if len(rx) > 800 else rx) + return finish_host(fd, hybbx_ready, do_tx_rx) + os.close(fd) + print("\nFAIL: software recovery — try power-cycle with DTR high (re-run without --recover-only)") + return 1 + + print(f"=== TNC boot-wait @ {args.device} {args.baud} {args.line.upper()} ===") + print("DTR+RTS HIGH - power OFF the TNC now (10s), then power ON.") + print("CB: squelch CLOSED (CD off) - otherwise TX/boot may be disturbed.") + print(f"Listening {args.wait}s for boot banner ...\n") + + buf = read_for(fd, args.wait) + + if has_banner(buf): + print("--- Boot banner (passive) ---") + show(buf) + return finish_host(fd, hybbx_ready, do_tx_rx) + + print(f"(no banner in {args.wait}s - trying KISS return + ESC V)\n") + write_flush(fd, b"\xc0\xff\xc0") + time.sleep(1.0) + buf += read_for(fd, 2.5) + write_flush(fd, b"\x1bV\r") + time.sleep(0.4) + info = read_for(fd, 4.0) + buf += info + + print("--- KISS return + ESC V ---") + show(info if info else buf[-500:]) + + if has_banner(buf): + return finish_host(fd, hybbx_ready, do_tx_rx) + + rec = load_recovery() + if rec is not None: + print("\n--- software recovery ladder ---") + + def wf(data: bytes) -> None: + write_flush(fd, data) + + def rf(seconds: float) -> bytes: + return read_for(fd, seconds) + + ok, rx = rec.recover_terminal(wf, rf, log=lambda m: print(f" {m}")) + buf += rx + if ok: + show(rx[-800:] if len(rx) > 800 else rx) + return finish_host(fd, hybbx_ready, do_tx_rx) + + os.close(fd) + print("\nDEGRADED: try ./tnc2c-host-reset.sh or power-cycle with this script running") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stacks/tncs/tnc2c-boot-wait.sh b/stacks/tncs/tnc2c-boot-wait.sh new file mode 100755 index 0000000..ac101c1 --- /dev/null +++ b/stacks/tncs/tnc2c-boot-wait.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# TNC2C boot-wait — Landolt TNC2C, typically 19200 8N1, DTR during power-on +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=load-serial-env.sh +source "${ROOT}/load-serial-env.sh" +load_serial_env tnc2c "${ROOT}" || true +apply_tnc2c_env +if [[ -n "${SERIAL_ENV_FILE:-}" ]]; then + echo "Using serial env: ${SERIAL_ENV_FILE}" >&2 +else + echo "WARN: no tnc2c-serial.env found — using ${TNC_DEV} ${TNC_BAUD} ${TNC_LINE}" >&2 +fi +exec python3 "${ROOT}/tnc2c-boot-wait.py" "${TNC_DEV}" --baud "${TNC_BAUD}" --line "${TNC_LINE}" "$@" diff --git a/stacks/tncs/tnc2c-chat.sh b/stacks/tncs/tnc2c-chat.sh new file mode 100755 index 0000000..9323b5e --- /dev/null +++ b/stacks/tncs/tnc2c-chat.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Quick TNC2C terminal check on /dev/ttyS4 (no RF required). +# WARNING: kiss on / MONitor can KEY PTT and transmit on the radio! +# For passive RX only use: ./tnc2c-listen.sh +# Usage: ./tnc2c-chat.sh [baud] [line] +# line: 7e1 | 8n1 (default from $TNC2C_LINE or 9600/7e1) + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=/dev/null +[[ -f "$ROOT/tnc2c-serial.env" ]] && source "$ROOT/tnc2c-serial.env" + +DEV="${TNC2C_DEV:-/dev/ttyS4}" +BAUD="${1:-${TNC2C_BAUD:-19200}}" +LINE="${2:-${TNC2C_LINE:-7e1}}" + +case "$LINE" in +7e1|7E1) STTY="cs7 parenb -cstopb" ;; +8n1|8N1) STTY="cs8 -parenb -cstopb" ;; +*) echo "Unknown line format: $LINE (use 7e1 or 8n1)" >&2; exit 1 ;; +esac + +if [[ ! -r "$DEV" || ! -w "$DEV" ]]; then + echo "Cannot access $DEV (dialout group?)" >&2 + exit 1 +fi + +echo "TNC2C chat: $DEV @ ${BAUD} ${LINE}" +stty -F "$DEV" "$BAUD" $STTY raw -echo -icanon min 0 time 10 + +exec 3<>"$DEV" + +printf '\r' >&3 +sleep 0.5 +timeout 2 cat <&3 | xxd -g1 | head -8 || true + +# Do NOT send kiss on — keys PTT. Only safe host echo probes: +for cmd in '' INFO HELP; do + echo "--- $cmd ---" + if [[ -n "$cmd" ]]; then + printf '%s\r' "$cmd" >&3 + else + printf '\r' >&3 + fi + sleep 1 + timeout 4 cat <&3 | tee /tmp/tnc2c-last.bin | xxd -g1 | head -12 + strings -n 3 /tmp/tnc2c-last.bin || true +done + +exec 3<&- +exec 3>&- diff --git a/stacks/tncs/tnc2c-check.sh b/stacks/tncs/tnc2c-check.sh new file mode 100755 index 0000000..c0ab705 --- /dev/null +++ b/stacks/tncs/tnc2c-check.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# Preflight: HyBBX off? handshake OK? quick echo on TNC2C. +# Usage: ./tnc2c-check.sh [device] + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=/dev/null +[[ -f "$ROOT/tnc2c-serial.env" ]] && source "$ROOT/tnc2c-serial.env" + +DEV="${1:-${TNC2C_DEV:-/dev/ttyS4}}" +BAUD="${TNC2C_BAUD:-19200}" +LINE="${TNC2C_LINE:-7e1}" + +echo "=== TNC2C check: $DEV (${BAUD} ${LINE}) ===" + +if pgrep -x hybbx >/dev/null 2>&1; then + echo "WARN: hybbx is running ($(pgrep -x hybbx | tr '\n' ' ')) - port may be in use" +fi + +if fuser -s "$DEV" 2>/dev/null; then + echo "WARN: $DEV in use:" + fuser -v "$DEV" 2>&1 || true +else + echo "OK: $DEV not in use" +fi + +if [[ ! -r "$DEV" || ! -w "$DEV" ]]; then + echo "FAIL: no access to $DEV (dialout group?)" >&2 + exit 1 +fi + +case "$LINE" in +7e1|7E1) STTY="cs7 parenb -cstopb" ;; +8n1|8N1) STTY="cs8 -parenb -cstopb" ;; +*) echo "Unknown line: $LINE" >&2; exit 1 ;; +esac + +stty -F "$DEV" "$BAUD" $STTY raw -echo min 0 time 5 + +python3 <<PY +import fcntl, os, struct, time + +TIOCMGET = 0x5415 +fd = os.open("$DEV", os.O_RDWR | os.O_NOCTTY) +flags = struct.unpack("I", fcntl.ioctl(fd, TIOCMGET, struct.pack("I", 0)))[0] +names = [(0x004, "RTS"), (0x008, "CTS"), (0x002, "DTR"), (0x100, "DSR")] +for bit, name in names: + print(f" {name}={'1' if flags & bit else '0'}") +os.write(fd, b"INFO\r") +time.sleep(0.8) +data = os.read(fd, 512) +os.close(fd) +print(f" RX {len(data)} bytes:", data[:80]) +PY + +if [[ -x "$ROOT/tnc2c-probe" ]]; then + echo "--- tnc2c-probe (single port) ---" + "$ROOT/tnc2c-probe" "$DEV" 2>&1 | tail -20 +fi + +echo "=== done ===" diff --git a/stacks/tncs/tnc2c-expect.exp b/stacks/tncs/tnc2c-expect.exp new file mode 100755 index 0000000..eaef9c0 --- /dev/null +++ b/stacks/tncs/tnc2c-expect.exp @@ -0,0 +1,80 @@ +#!/usr/bin/expect -f +# Auto-detect TNC2C host line on a serial device. +# Usage: ./tnc2c-expect.exp /dev/ttyS4 + +set timeout 6 +if {[llength $argv] < 1} { + puts "Usage: $argv0 <device>" + exit 1 +} +set dev [lindex $argv 0] + +proc try_line {dev baud parity} { + global timeout + if {$parity eq "7E1"} { + spawn stty -F $dev $baud cs7 parenb -cstopb raw -echo min 0 time 10 + expect eof + } else { + spawn stty -F $dev $baud cs8 -parenb -cstopb raw -echo min 0 time 10 + expect eof + } + + spawn cu -l $dev -s $baud + if {[catch {expect { + -re "." { } + timeout { } + }}]} { + catch {close} + return 0 + } + + set score 0 + set sample "" + + send "\r\r" + sleep 1 + send "INFO\r" + sleep 3 + + if {[catch {set sample $expect_out(buffer)}]} { + set sample "" + } + + if {[string match -nocase *cmd:* $sample]} { incr score 300 } + if {[string match -nocase *MYCALL* $sample]} { incr score 150 } + if {[string match -nocase *TXDELAY* $sample]} { incr score 100 } + if {[string match -nocase *INFO* $sample]} { incr score 20 } + incr score [string length $sample] + + puts " $baud $parity score=$score" + if {$sample ne ""} { + set short [string range $sample 0 180] + regsub -all {\r} $short {\\r} short + regsub -all {\n} $short {\\n} short + puts " $short" + } + + send "~.\r" + catch {close} + return $score +} + +puts "TNC2C expect scan on $dev" +set best 0 +set bestcfg "" + +foreach baud {9600 2400 4800 1200 19200 600 300} { + foreach parity {7E1 8N1} { + set s [try_line $dev $baud $parity] + if {$s > $best} { + set best $s + set bestcfg "$baud $parity" + } + } +} + +puts "\nBest: $bestcfg (score=$best)" +if {$best >= 200} { + exit 0 +} +exit 2 diff --git a/stacks/tncs/tnc2c-find.c b/stacks/tncs/tnc2c-find.c new file mode 100644 index 0000000..fe26a1b --- /dev/null +++ b/stacks/tncs/tnc2c-find.c @@ -0,0 +1,306 @@ +/* + * tnc2c-find — detect host baud/format for Landolt TNC2C on a serial port. + * Tries TERM jumper speeds (300..9600) with 7E1 and 8N1, scores cmd:/INFO replies. + * + * Build: make find + * Run: ./tnc2c-find /dev/ttyS4 + */ + +#if defined(__linux__) || defined(__GLIBC__) +#define _DEFAULT_SOURCE 1 +#define _GNU_SOURCE 1 +#endif + +#include <errno.h> +#include <fcntl.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/ioctl.h> +#include <termios.h> +#include <time.h> +#include <unistd.h> + +typedef struct line_profile { + const char *label; + speed_t speed; + unsigned int baud; + tcflag_t databits; + tcflag_t parity; +} line_profile_t; + +static const line_profile_t PROFILES[] = { + { "19200-8N1", B19200, 19200, CS8, 0 }, + { "19200-7E1", B19200, 19200, CS7, PARENB }, + { "9600-7E1", B9600, 9600, CS7, PARENB }, + { "2400-7E1", B2400, 2400, CS7, PARENB }, + { "4800-7E1", B4800, 4800, CS7, PARENB }, + { "1200-7E1", B1200, 1200, CS7, PARENB }, + { "9600-8N1", B9600, 9600, CS8, 0 }, + { "2400-8N1", B2400, 2400, CS8, 0 }, + { "4800-8N1", B4800, 4800, CS8, 0 }, + { "1200-8N1", B1200, 1200, CS8, 0 }, + { "600-7E1", B600, 600, CS7, PARENB }, + { "300-7E1", B300, 300, CS7, PARENB }, + { "600-8N1", B600, 600, CS8, 0 }, + { "300-8N1", B300, 300, CS8, 0 }, +}; + +static int sleep_ms(unsigned ms) +{ + struct timespec ts; + + ts.tv_sec = (time_t)(ms / 1000u); + ts.tv_nsec = (long)(ms % 1000u) * 1000000L; + return nanosleep(&ts, NULL); +} + +static int set_modem_lines(int fd) +{ +#ifdef TIOCMGET + int flags; + + if (ioctl(fd, TIOCMGET, &flags) != 0) { + return -1; + } + flags |= TIOCM_RTS | TIOCM_DTR; + return ioctl(fd, TIOCMSET, &flags); +#else + (void)fd; + return 0; +#endif +} + +static int open_line(const char *device, const line_profile_t *profile) +{ + struct termios tty; + int fd; + + fd = open(device, O_RDWR | O_NOCTTY | O_NONBLOCK); + if (fd < 0) { + return -1; + } + + if (tcgetattr(fd, &tty) != 0) { + close(fd); + return -1; + } + + cfmakeraw(&tty); + tty.c_cflag |= (CLOCAL | CREAD); + tty.c_cflag &= ~(CSIZE | PARENB | PARODD | CSTOPB); + tty.c_cflag |= profile->databits; + if (profile->parity != 0) { + tty.c_cflag |= profile->parity; + } + tty.c_cc[VMIN] = 0; + tty.c_cc[VTIME] = 10; + +#if defined(__linux__) || defined(__GLIBC__) + if (cfsetspeed(&tty, profile->speed) != 0) { + cfsetispeed(&tty, profile->speed); + cfsetospeed(&tty, profile->speed); + } +#else + cfsetispeed(&tty, profile->speed); + cfsetospeed(&tty, profile->speed); +#endif + + if (tcsetattr(fd, TCSANOW, &tty) != 0) { + close(fd); + return -1; + } + + tcflush(fd, TCIOFLUSH); + (void)set_modem_lines(fd); + return fd; +} + +static size_t read_for(int fd, unsigned char *buf, size_t cap, unsigned ms) +{ + size_t total = 0; + unsigned elapsed = 0; + + while (elapsed < ms && total < cap) { + ssize_t n = read(fd, buf + total, cap - total); + + if (n > 0) { + total += (size_t)n; + elapsed = 0; + continue; + } + if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK) { + break; + } + sleep_ms(40); + elapsed += 40; + } + + return total; +} + +static int score_reply(const unsigned char *buf, size_t len) +{ + size_t i; + int score = 0; + int printable = 0; + + if (len == 0) { + return 0; + } + + for (i = 0; i < len; i++) { + unsigned char c = buf[i]; + + if (c >= 0x20 && c < 0x7f) { + printable++; + } + } + + score += printable; + if (memmem(buf, len, "cmd:", 4) != NULL || + memmem(buf, len, "CMD:", 4) != NULL) { + score += 200; + } + if (memmem(buf, len, "MYCALL", 6) != NULL || + memmem(buf, len, "mycall", 6) != NULL) { + score += 120; + } + if (memmem(buf, len, "TXDELAY", 7) != NULL) { + score += 80; + } + if (memmem(buf, len, "KISS", 4) != NULL) { + score += 40; + } + if (len > 40) { + score += 60; + } else if (len > 15) { + score += 20; + } + + /* Pure echo of a short command is weak. */ + if (len <= 12 && printable > 0 && printable == (int)len) { + score -= 10; + } + + return score; +} + +static void dump_ascii(const unsigned char *buf, size_t len) +{ + size_t i; + + for (i = 0; i < len; i++) { + unsigned char c = buf[i]; + + if (c == '\r') { + fputs("\\r", stdout); + } else if (c == '\n') { + fputs("\\n", stdout); + } else if (c >= 0x20 && c < 0x7f) { + putchar((int)c); + } else { + printf("\\x%02x", c); + } + } +} + +static int try_profile(const char *device, const line_profile_t *profile, + int *best_score, char *best_label, size_t label_cap, + unsigned char *best_buf, size_t *best_len, + size_t best_cap) +{ + int fd; + unsigned char buf[4096]; + size_t len; + int score; + const char *seq[] = { "\r\r\r", "INFO\r" }; + size_t s; + + fd = open_line(device, profile); + if (fd < 0) { + return -1; + } + + sleep_ms(200); + len = 0; + for (s = 0; s < sizeof(seq) / sizeof(seq[0]); s++) { + (void)write(fd, seq[s], strlen(seq[s])); + sleep_ms(400); + len += read_for(fd, buf + len, sizeof(buf) - len, 3500); + } + + close(fd); + score = score_reply(buf, len); + + if (score > *best_score) { + *best_score = score; + snprintf(best_label, label_cap, "%s", profile->label); + if (len > best_cap) { + len = best_cap; + } + memcpy(best_buf, buf, len); + *best_len = len; + } + + if (score >= 30) { + printf(" %-12s score=%4d len=%4zu ", + profile->label, score, len); + dump_ascii(buf, len < 120 ? len : 120); + putchar('\n'); + } + + return score; +} + +int main(int argc, char **argv) +{ + const char *device; + size_t i; + int best_score = -1; + char best_label[32] = ""; + unsigned char best_buf[4096]; + size_t best_len = 0; + + if (argc != 2) { + fprintf(stderr, "Usage: %s <serial-device>\n", argv[0]); + return 1; + } + + device = argv[1]; + printf("TNC2C auto-detect on %s\n", device); + printf("(RTS/DTR on, probe cmd:/INFO; best TERM jumper speeds)\n\n"); + + for (i = 0; i < sizeof(PROFILES) / sizeof(PROFILES[0]); i++) { + (void)try_profile(device, &PROFILES[i], &best_score, best_label, + sizeof(best_label), best_buf, &best_len, + sizeof(best_buf)); + } + + printf("\n"); + if (best_score < 0) { + printf("No profile could open %s\n", device); + return 2; + } + + printf("Best profile: %s (score=%d, %zu bytes)\n", + best_label[0] ? best_label : "?", best_score, best_len); + if (best_len > 0) { + printf("Sample: "); + dump_ascii(best_buf, best_len < 200 ? best_len : 200); + printf("\n"); + } + + if (best_score >= 200) { + printf("\nHost mode OK — use baud/serial_line from profile above in HyBBX.\n"); + return 0; + } + + if (best_score >= 30) { + printf("\nPartial link — check TERM jumper; try: tnc2c-host-reset %s\n", device); + return 3; + } + + printf("\nNo usable TNC dialog detected.\n"); + return 4; +} diff --git a/stacks/tncs/tnc2c-health.py b/stacks/tncs/tnc2c-health.py new file mode 100644 index 0000000..642b6cf --- /dev/null +++ b/stacks/tncs/tnc2c-health.py @@ -0,0 +1,445 @@ +#!/usr/bin/env python3 +""" +tnc2c-health - full Landolt TNC2C health check (serial, host, KISS, optional TX). + +Does NOT replace visual LED checks (Power/PTT/CD/Connected/Status). + +Usage: + ./tnc2c-health.sh + ./tnc2c-health.sh /dev/ttyS4 + ./tnc2c-health.sh --tx # include KISS TX attempt (keys PTT!) + ./tnc2c-health.sh --tx --quiet +""" + +from __future__ import annotations + +import argparse +import fcntl +import os +import struct +import sys +import time +import termios +from dataclasses import dataclass, field +from typing import Optional + +BAUD_MAP = { + 300: termios.B300, + 600: termios.B600, + 1200: termios.B1200, + 2400: termios.B2400, + 4800: termios.B4800, + 9600: termios.B9600, + 19200: termios.B19200, +} + +PROFILES = [ + ("19200-8N1", 19200, False), + ("19200-7E1", 19200, True), + ("9600-7E1", 9600, True), + ("9600-8N1", 9600, False), + ("2400-7E1", 2400, True), + ("4800-7E1", 4800, True), +] + +HOST_MARKERS = ( + b"cmd:", + b"CMD:", + b"TNC", + b"***", + b"help", + b"HELP", + b"version", + b"Version", + b"MYCALL", + b"KISS was", + b"KISS is", +) + +ECHO_ONLY_MARKERS = ( + b"kiss off", + b"kiss on", + b"INFO", + b"HELP", + b"RESTART", +) + + +@dataclass +class ProfileResult: + name: str + rts: bool + cts: bool + dsr: bool + info_reply: bytes = b"" + host_score: int = 0 + echo_only: bool = False + has_cmd_prompt: bool = False + kiss_ack: bool = False + kiss_echo_only: bool = False + notes: list[str] = field(default_factory=list) + + +def load_env(path: str) -> dict[str, str]: + env: dict[str, str] = {} + if not os.path.isfile(path): + return env + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" in line: + k, v = line.split("=", 1) + env[k.strip()] = v.strip() + return env + + +def open_serial(dev: str, baud: int, seven_e1: bool) -> int: + fd = os.open(dev, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK) + if baud not in BAUD_MAP: + raise ValueError(f"unsupported baud {baud}") + t = termios.tcgetattr(fd) + t[0] = t[1] = 0 + t[2] = termios.CLOCAL | termios.CREAD | (termios.CS7 if seven_e1 else termios.CS8) + if seven_e1: + t[2] |= termios.PARENB + speed = BAUD_MAP[baud] + 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] + flags |= 0x004 | 0x002 # RTS DTR + fcntl.ioctl(fd, 0x5416, struct.pack("I", flags)) + return fd + + +def modem_lines(fd: int) -> tuple[bool, bool, bool]: + flags = struct.unpack("I", fcntl.ioctl(fd, 0x5415, struct.pack("I", 0)))[0] + return bool(flags & 0x004), bool(flags & 0x008), bool(flags & 0x100) + + +def read_for(fd: int, seconds: float) -> bytes: + end = time.time() + seconds + chunks: list[bytes] = [] + while time.time() < end: + try: + b = os.read(fd, 4096) + if b: + chunks.append(b) + except BlockingIOError: + time.sleep(0.03) + return b"".join(chunks) + + +def write_flush(fd: int, data: bytes) -> None: + os.write(fd, data) + termios.tcdrain(fd) + + +def score_host_reply(cmd: bytes, reply: bytes) -> tuple[int, bool, bool]: + if not reply: + return 0, False, False + + stripped_cmd = cmd.strip(b"\r\n") + echo_only = reply.strip() == stripped_cmd or reply.strip() in ( + stripped_cmd, + stripped_cmd + b"\r", + stripped_cmd + b"\n", + stripped_cmd + b"\r\n", + ) + + score = 0 + if b"cmd:" in reply.lower() or b"cmd>" in reply.lower(): + score += 50 + for m in HOST_MARKERS: + if m.lower() in reply.lower(): + score += 10 + if len(reply) > len(cmd) + 4 and not echo_only: + score += 5 + if b"\n" in reply and len(reply) > 20: + score += 5 + + has_cmd = b"cmd:" in reply.lower() + return score, echo_only, has_cmd + + +def kiss_escape(data: bytes) -> bytes: + out = bytearray() + for b in data: + if b == 0xC0: + out.extend((0xDB, 0xDC)) + elif b == 0xDB: + out.extend((0xDB, 0xDD)) + else: + out.append(b) + return bytes(out) + + +def ax25_addr(call: str, ssid: int, last: bool) -> bytes: + call = call.upper().ljust(6)[:6] + raw = bytes((ord(c) << 1) for c in call) + ssid_b = ((ssid & 0x0F) << 1) | (0x01 if last else 0x00) + return raw + bytes([ssid_b]) + + +def ax25_crc(data: bytes) -> int: + crc = 0xFFFF + for b in data: + crc ^= b + for _ in range(8): + crc = (crc >> 1) ^ 0x8408 if crc & 1 else crc >> 1 + return crc ^ 0xFFFF + + +def ax25_ui(src: str, dst: str, info: bytes) -> bytes: + body = ax25_addr(dst, 0, False) + ax25_addr(src, 0, True) + body += b"\x03\xF0" + info + crc = ax25_crc(body) + return body + bytes((crc & 0xFF, crc >> 8)) + + +def kiss_frame(port: int, payload: bytes) -> bytes: + return b"\xC0" + bytes([port & 0x0F]) + kiss_escape(payload) + b"\xC0" + + +def test_profile(dev: str, name: str, baud: int, seven_e1: bool) -> ProfileResult: + res = ProfileResult(name=name, rts=False, cts=False, dsr=False) + fd = open_serial(dev, baud, seven_e1) + res.rts, res.cts, res.dsr = modem_lines(fd) + + # break out of possible modes + write_flush(fd, b"\x03\x1a") # Ctrl+C, Ctrl+Z + time.sleep(0.2) + read_for(fd, 0.3) + + write_flush(fd, b"kiss off\r") + time.sleep(0.4) + read_for(fd, 0.4) + + write_flush(fd, b"\r") + time.sleep(0.2) + read_for(fd, 0.3) + + cmd = b"INFO\r" + write_flush(fd, cmd) + time.sleep(0.6) + info = read_for(fd, 1.0) + res.info_reply = info + score, echo, has_cmd = score_host_reply(cmd, info) + res.host_score = score + res.echo_only = echo and score < 15 + res.has_cmd_prompt = has_cmd + + write_flush(fd, b"HELP\r") + time.sleep(0.5) + help_r = read_for(fd, 0.8) + hs, echo2, hc = score_host_reply(b"HELP\r", help_r) + res.host_score = max(res.host_score, hs) + if hc: + res.has_cmd_prompt = True + if echo2 and hs < 10: + res.echo_only = res.echo_only or True + + write_flush(fd, b"kiss on\r") + time.sleep(0.5) + kiss_r = read_for(fd, 0.6) + if b"kiss" in kiss_r.lower() and len(kiss_r) < 32: + res.kiss_echo_only = True + if b"on" in kiss_r.lower() and len(kiss_r) > 8 and not res.kiss_echo_only: + res.kiss_ack = True + + passive = read_for(fd, 1.0) + if passive: + res.notes.append(f"passive_after_kiss={len(passive)}B") + + write_flush(fd, b"kiss off\r") + time.sleep(0.2) + os.close(fd) + return res + + +def run_tx_test(dev: str, profile_name: str, baud: int, seven_e1: bool) -> dict: + fd = open_serial(dev, baud, seven_e1) + write_flush(fd, b"kiss off\r") + time.sleep(0.4) + read_for(fd, 0.3) + write_flush(fd, b"kiss on\r") + time.sleep(0.5) + read_for(fd, 0.3) + + payload = ax25_ui("TEST-0", "CQ", b"HEALTH") + pkt = kiss_frame(0, payload) + write_flush(fd, pkt) + time.sleep(3.0) + rx = read_for(fd, 1.0) + write_flush(fd, b"kiss off\r") + os.close(fd) + + echoed = pkt in rx or payload in rx + return { + "sent": len(pkt), + "rx_len": len(rx), + "frame_echoed_back": echoed, + "rx_sample": rx[:96], + } + + +def print_header(title: str) -> None: + print(f"\n{'=' * 60}") + print(title) + print("=" * 60) + + +def main() -> int: + parser = argparse.ArgumentParser(description="TNC2C full health check") + parser.add_argument("device", nargs="?", default=None) + parser.add_argument("--tx", action="store_true", help="attempt KISS TX (keys PTT)") + parser.add_argument("--quiet", action="store_true") + args = parser.parse_args() + + root = os.path.dirname(os.path.abspath(__file__)) + env = load_env(os.path.join(root, "tnc2c-serial.env")) + dev = args.device or env.get("TNC2C_DEV", "/dev/ttyS4") + + print_header("TNC2C HEALTH CHECK") + print(f"Device: {dev}") + print(f"Time: {time.strftime('%Y-%m-%d %H:%M:%S')}") + + # --- preflight --- + print_header("1) Preflight") + if os.system("pgrep -x hybbx >/dev/null 2>&1") == 0: + print("FAIL: hybbx is running - stop it first") + return 2 + print("OK: hybbx not active") + + if not os.access(dev, os.R_OK | os.W_OK): + print(f"FAIL: no access to {dev} (dialout group?)") + return 2 + print(f"OK: access to {dev}") + + # --- profile sweep --- + print_header("2) Serial profiles (host detection)") + results: list[ProfileResult] = [] + for name, baud, seven in PROFILES: + try: + r = test_profile(dev, name, baud, seven) + results.append(r) + info_preview = r.info_reply[:40].replace(b"\r", b"\\r").replace(b"\n", b"\\n") + print( + f" {name:12} RTS={int(r.rts)} CTS={int(r.cts)} DSR={int(r.dsr)} " + f"score={r.host_score:2} cmd={int(r.has_cmd_prompt)} " + f"echo_only={int(r.echo_only)} INFO={info_preview!r}" + ) + except OSError as e: + print(f" {name:12} ERROR: {e}") + + best = max(results, key=lambda x: x.host_score, default=None) + if best is None: + print("FAIL: no profiles tested") + return 2 + + # pick working line: prefer env, else best score, else 19200-8N1 clean echo + preferred = env.get("TNC2C_LINE", "8n1").lower() + work_name = best.name + work_baud = 19200 + work_7e1 = preferred.startswith("7e") + for name, baud, seven in PROFILES: + if name == best.name: + work_baud, work_7e1 = baud, seven + break + if best.host_score < 15: + for r in results: + if r.name == "19200-8N1" and r.info_reply.strip() in ( + b"INFO\r\n", + b"INFO\r", + b"INFO", + ): + work_name = r.name + work_baud, work_7e1 = 19200, False + break + + print(f"\nBest profile (host score): {best.name} (score={best.host_score})") + print(f"Working profile for tests: {work_name}") + + # --- diagnosis --- + print_header("3) Serial / host mode diagnosis") + any_cmd = any(r.has_cmd_prompt for r in results) + mostly_echo = all(r.echo_only or r.host_score < 15 for r in results) + + if any_cmd: + print("OK: real host mode (cmd: prompt) detected") + elif mostly_echo: + print("WARN: command echo only - no cmd: prompt, no INFO listing") + print(" -> TNC probably does NOT process commands") + print(" -> KISS/PTT via software probably ineffective") + print(" -> LEDs do not change (matches this picture)") + else: + print("PARTIAL: replies present but no clear host dialog") + + if all(not r.cts for r in results): + print("WARN: CTS=0 on all profiles - handshake problem") + print(" -> bridge DB25 pins 4 and 5 in cable if needed (manual)") + + # --- RF / LED manual --- + print_header("4) TNC LEDs (no radio / with radio)") + print("No radio (audio off): Pin4 open -> LED3 (CD) often ON continuously (normal)") + print("With radio: LED3 should be ON with noise, OFF with squelch closed") + print("LED 1 Power | LED 2 PTT | LED 3 CD | LED 4 Connected | LED 5 Status") + + # --- optional TX --- + print_header("5) KISS send test") + if not args.tx: + print("Skipped (without --tx). To test: ./tnc2c-health.sh --tx") + else: + print("WARNING: sends KISS frame - PTT may key!") + tx = run_tx_test(dev, work_name, work_baud, work_7e1) + print(f" Profile: {work_name}") + print(f" Sent: {tx['sent']} bytes KISS") + print(f" RX: {tx['rx_len']} bytes") + print(f" Frame echoed back directly: {tx['frame_echoed_back']}") + if tx["frame_echoed_back"]: + print(" WARN: frame returns unchanged -> possible serial loopback,") + print(" not sent over radio") + print(" -> CHECK NOW: LED2 (PTT) and S-meter on radio") + + # --- hardware checklist --- + print_header("6) Hardware checklist (if not transmitting)") + print(" [ ] PTT radio red -> TNC DIN Pin 3") + print(" [ ] GND radio black -> TNC DIN Pin 2") + print(" [ ] TX radio white -> TNC DIN Pin 1") + print(" [ ] RX 3.5mm tip -> TNC DIN Pin 4 (left center)") + print(" [ ] VOX on AE6110 -> OFF") + print(" [ ] Mike gain on TNC -> increase slowly (pot on modem)") + print(" [ ] RADIO bridge TNC -> 2400 baud (Pos. 2)") + print(" [ ] TERM bridge TNC -> 19200") + + # --- software checklist --- + print_header("7) Software / next steps") + if mostly_echo and not any_cmd: + print(" 1. Software recovery (no power cycle):") + print(" ./tnc2c-host-reset.sh --kiss") + print(" ./tnc2c-boot-wait.sh --recover-only") + print(" 2. Expect cmd: or TheFirmware banner after recovery") + print(" 3. Rescue only if still echo-only: ./tnc2c-boot-wait.sh + power cycle (DTR high)") + print(" 4. When cmd: appears -> ./tnc2c-health.sh --tx") + print(" 5. HyBBX: kiss_entry=none when max25d owns KISS (see docs/TNC-RECOVERY.md)") + else: + print(f" Serial OK with {work_name}") + print(" ./tnc2c-send-test.sh for single send test") + print(" HyBBX: baud=19200 serial_line=8n1 (this device)") + + print_header("SUMMARY") + status = "DEGRADED" if mostly_echo and not any_cmd else "OK" + print(f"Overall: {status}") + print(f"Device: {dev}") + print(f"Best host profile: {best.name} (score={best.host_score})") + print(f"CTS on best: {int(best.cts)} echo_only: {int(best.echo_only)} cmd: {int(best.has_cmd_prompt)}") + + return 1 if status == "DEGRADED" else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stacks/tncs/tnc2c-health.sh b/stacks/tncs/tnc2c-health.sh new file mode 100755 index 0000000..9d9ee10 --- /dev/null +++ b/stacks/tncs/tnc2c-health.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# Full TNC2C health check — serial, host mode, KISS, optional TX. +# Usage: +# ./tnc2c-health.sh # safe, no transmit +# ./tnc2c-health.sh --tx # includes KISS send (PTT!) + +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +exec python3 "$ROOT/tnc2c-health.py" "$@" diff --git a/stacks/tncs/tnc2c-host-reset.py b/stacks/tncs/tnc2c-host-reset.py new file mode 100755 index 0000000..340642f --- /dev/null +++ b/stacks/tncs/tnc2c-host-reset.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +""" +tnc2c-host-reset - recover TNC-2 class terminal/host mode over serial. + +Uses full software recovery ladder (KISS-return, JHOST0, RESTART) — no power +cycle required unless DTR was low at cold boot. + +Usage: + ./tnc2c-host-reset.sh + ./tnc2c-host-reset.sh /dev/ttyS4 + ./tnc2c-host-reset.sh --kiss # recover + enter KISS + ./tnc2c-host-reset.sh --power-hint # remind to cycle TNC power first + ./tnc2c-host-reset.sh --no-kiss # skip KISS reset frame (step 2 only) +""" + +from __future__ import annotations + +import argparse +import importlib.util +import os +import sys +import time + +BAUD_DEFAULT = 19200 +LINE_DEFAULT = "8n1" + +FIRMWARE_MARKERS = ( + b"TheFirmware", + b"NORD", + b"Version 2.7", + b"Checksum", + b"Copyright", + b"DAMA", + b"SMACK", +) + + +def load_recovery(): + root = os.path.dirname(os.path.abspath(__file__)) + path = os.path.join(root, "tnc_serial_recovery.py") + spec = importlib.util.spec_from_file_location("tnc_serial_recovery", path) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot load {path}") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def load_env(path: str) -> dict[str, str]: + out: dict[str, str] = {} + if not os.path.isfile(path): + return out + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + out[k.strip()] = v.strip() + return out + + +def parse_line(line: str) -> tuple[int, int]: + import termios + + line = line.lower() + if line == "7e1": + return termios.CS7, termios.PARENB + if line == "8n1": + return termios.CS8, 0 + raise ValueError(f"unsupported serial line: {line}") + + +def parse_baud(baud: int) -> int: + import termios + + 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] + + +def open_serial(dev: str, baud: int, line: str) -> int: + import fcntl + import struct + import termios + + speed = parse_baud(baud) + databits, parity = parse_line(line) + 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] + flags |= 0x004 | 0x002 + fcntl.ioctl(fd, 0x5416, struct.pack("I", flags)) + return fd + + +def read_for(fd: int, seconds: float) -> bytes: + end = time.time() + seconds + chunks: list[bytes] = [] + while time.time() < end: + try: + b = os.read(fd, 4096) + if b: + chunks.append(b) + except BlockingIOError: + time.sleep(0.02) + return b"".join(chunks) + + +def write_flush(fd: int, data: bytes) -> None: + import termios + + os.write(fd, data) + termios.tcdrain(fd) + + +POWER_HINT = """\ +Note: If recovery fails (echo only), disconnect TNC power (10-15 s), reconnect +while DTR+RTS stay high — run tnc2c-boot-wait.sh or this script immediately. +""" + + +def main() -> int: + parser = argparse.ArgumentParser( + description="TNC host-mode recovery with DTR+RTS (TheFirmware ladder)" + ) + parser.add_argument("device", nargs="?", default=None) + parser.add_argument("--baud", type=int, default=None) + parser.add_argument("--line", default=None, choices=("8n1", "7e1")) + parser.add_argument("--kiss", action="store_true", help="enter KISS after recover") + parser.add_argument( + "--kiss-entry", + default="kiss_on", + choices=("kiss_on", "auto"), + help="KISS entry method with --kiss", + ) + parser.add_argument("--power-hint", action="store_true") + parser.add_argument( + "--no-kiss", + action="store_true", + help="skip KISS return frame in recovery ladder", + ) + args = parser.parse_args() + + root = os.path.dirname(os.path.abspath(__file__)) + env = load_env(os.path.join(root, "tnc2c-serial.env")) + dev = ( + args.device + or env.get("TNC_DEV") + or env.get("TNC2C_DEV") + or env.get("PKTNC2_DEV") + or "/dev/ttyS4" + ) + baud = args.baud or int( + env.get("TNC_BAUD") or env.get("TNC2C_BAUD") or env.get("PKTNC2_BAUD") or BAUD_DEFAULT + ) + line = args.line or env.get("TNC_LINE") or env.get("TNC2C_LINE") or LINE_DEFAULT + + if args.power_hint: + print(POWER_HINT) + + if not os.path.exists(dev): + print(f"FAIL: {dev} does not exist", file=sys.stderr) + return 2 + if not os.access(dev, os.R_OK | os.W_OK): + print(f"FAIL: no access to {dev}", file=sys.stderr) + return 2 + + recovery = load_recovery() + + print(f"TNC host-reset @ {dev} {baud} {line.upper()} (DTR+RTS high)") + try: + fd = open_serial(dev, baud, line) + except OSError as e: + print(f"FAIL: {dev}: {e}", file=sys.stderr) + return 2 + + def wf(data: bytes) -> None: + write_flush(fd, data) + + def rf(seconds: float) -> bytes: + return read_for(fd, seconds) + + def log(msg: str) -> None: + print(f" {msg}") + + ok, received = recovery.recover_terminal( + wf, rf, skip_kiss_frame=args.no_kiss, log=log + ) + + if ok and args.kiss: + recovery.enter_kiss(wf, rf, args.kiss_entry) + print(" KISS entry sent") + + os.close(fd) + + print() + if ok: + label = "HOST+KISS" if args.kiss else "HOST" + print(f"OK: {label} - terminal mode active (software recovery)") + if received: + text = received.decode("ascii", errors="replace") + if "NORD" in text or "TheFirmware" in text: + for line_out in text.splitlines(): + if any(m.decode() in line_out for m in FIRMWARE_MARKERS[:5]): + print(f" | {line_out.strip()}") + return 0 + + print("FAIL: software recovery exhausted") + print(" -> Power-cycle with DTR high: ./tnc2c-boot-wait.sh") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stacks/tncs/tnc2c-host-reset.sh b/stacks/tncs/tnc2c-host-reset.sh new file mode 100755 index 0000000..d0d1499 --- /dev/null +++ b/stacks/tncs/tnc2c-host-reset.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# TNC2C host-mode recovery — DTR+RTS high, software ladder (no power cycle usually). +# +# Usage: +# ./tnc2c-host-reset.sh +# ./tnc2c-host-reset.sh --kiss # recover + enter KISS +# ./tnc2c-host-reset.sh --power-hint # if recovery fails: cycle power + boot-wait +# ./tnc2c-host-reset.sh --no-kiss # skip KISS return frame in ladder + +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=load-serial-env.sh +source "${ROOT}/load-serial-env.sh" +load_serial_env tnc2c "${ROOT}" || true +apply_tnc2c_env +if [[ -n "${SERIAL_ENV_FILE:-}" ]]; then + echo "Using serial env: ${SERIAL_ENV_FILE}" >&2 +else + echo "WARN: no tnc2c-serial.env found — using ${TNC_DEV} ${TNC_BAUD} ${TNC_LINE}" >&2 +fi +exec python3 "${ROOT}/tnc2c-host-reset.py" "${TNC_DEV}" --baud "${TNC_BAUD}" --line "${TNC_LINE}" "$@" diff --git a/stacks/tncs/tnc2c-hybbx-beacon-once.sh b/stacks/tncs/tnc2c-hybbx-beacon-once.sh new file mode 100755 index 0000000..7249191 --- /dev/null +++ b/stacks/tncs/tnc2c-hybbx-beacon-once.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# One-shot AX.25 beacon like INI [broadcast] - stop HyBBX briefly, then restart. +# Auto-beacon (300s) runs from new HyBBX start. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +MSG="${1:-Broadcast: CB-0 online}" +SRC="${2:-CB-0}" +DST="${3:-*}" + +echo "Manual beacon: ${SRC} -> ${DST}: ${MSG}" +if pgrep -x hybbx >/dev/null; then + echo "Stopping HyBBX ..." + pkill -x hybbx || sudo -u hybbx pkill -x hybbx + sleep 2 +fi +"$ROOT/tnc2c-send-test.sh" "$MSG" "$SRC" "$DST" +echo "Starting HyBBX ... (auto-beacon in ~300s)" +sudo -u hybbx /etc/hybbx/hybbx-start & +sleep 3 +pgrep -a hybbx || echo "WARN: hybbx not started - try boot-wait + hybbx-start" +echo "Log: tail -f /etc/hybbx/logs/*.log - expect: [broadcast] ax25" diff --git a/stacks/tncs/tnc2c-integration-test.py b/stacks/tncs/tnc2c-integration-test.py new file mode 100644 index 0000000..930e0a6 --- /dev/null +++ b/stacks/tncs/tnc2c-integration-test.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +""" +HyBBX-ready check - must use same open port as boot-wait (DTR must stay high). + +After boot-wait closes the port, DTR drops and the TNC often returns to echo mode. +Use: ./tnc2c-boot-wait.sh (includes HyBBX verify by default) +Or: ./tnc2c-integration-test.sh --boot (boot-wait + verify in one process) +""" + +from __future__ import annotations + +import argparse +import importlib.util +import os +import subprocess +import sys +import time + +ROOT = os.path.dirname(os.path.abspath(__file__)) + + +def load_module(name: str, filename: str): + path = os.path.join(ROOT, filename) + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def preflight() -> int | None: + if os.system("pgrep -x hybbx >/dev/null 2>&1") == 0: + print("FAIL: hybbx is running") + return 2 + return None + + +def run_quick_check(dev: str) -> int: + hr = load_module("host_reset", "tnc2c-host-reset.py") + + print(f"TNC2C integration-test @ {dev}") + print("(Port was closed - DTR was low; echo is likely)\n") + + try: + fd = hr.open_serial(dev) + except OSError as e: + print(f"FAIL: {e}") + return 2 + + received = b"" + received += hr.read_for(fd, 1.0) + hr.write_flush(fd, b"kiss off\r") + time.sleep(0.6) + received += hr.read_for(fd, 1.0) + hr.write_flush(fd, b"INFO\r") + time.sleep(0.3) + received += hr.read_for(fd, 5.0) + os.close(fd) + + if hr.has_banner(received): + print("OK: HOST - HyBBX may start") + print(received.decode("ascii", errors="replace")[:500]) + return 0 + + print("FAIL: ECHO or no banner") + print(" -> Closing the port after boot-wait drops DTR.") + print(" -> Use ONE command:") + print(" ./tnc2c-boot-wait.sh") + print(" (power off/on while script runs - checks HyBBX-ready before close)") + print(" or:") + print(" ./tnc2c-integration-test.sh --boot") + return 1 + + +def run_with_boot(dev: str) -> int: + print("Starting boot-wait + HyBBX verify in one process ...\n") + script = os.path.join(ROOT, "tnc2c-boot-wait.sh") + return subprocess.call([script, dev]) + + +def main() -> int: + parser = argparse.ArgumentParser(description="TNC2C HyBBX-ready check") + parser.add_argument("device", nargs="?", default=None) + parser.add_argument( + "--boot", + action="store_true", + help="run boot-wait (power-cycle TNC while script runs)", + ) + args = parser.parse_args() + + fail = preflight() + if fail is not None: + return fail + + hr = load_module("host_reset", "tnc2c-host-reset.py") + dev = args.device or hr.load_env(os.path.join(ROOT, "tnc2c-serial.env")) or "/dev/ttyS4" + + if args.boot: + return run_with_boot(dev) + return run_quick_check(dev) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stacks/tncs/tnc2c-integration-test.sh b/stacks/tncs/tnc2c-integration-test.sh new file mode 100755 index 0000000..e0523e4 --- /dev/null +++ b/stacks/tncs/tnc2c-integration-test.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +exec python3 "$ROOT/tnc2c-integration-test.py" "$@" diff --git a/stacks/tncs/tnc2c-listen.sh b/stacks/tncs/tnc2c-listen.sh new file mode 100755 index 0000000..7020e17 --- /dev/null +++ b/stacks/tncs/tnc2c-listen.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# Passive listen on TNC2C - NO commands that can key PTT (no kiss on, no MONitor). +# Usage: ./tnc2c-listen.sh [seconds] [device] + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=/dev/null +[[ -f "$ROOT/tnc2c-serial.env" ]] && source "$ROOT/tnc2c-serial.env" + +DEV="${2:-${TNC2C_DEV:-/dev/ttyS4}}" +SECS="${1:-5}" +BAUD="${TNC2C_BAUD:-19200}" +LINE="${TNC2C_LINE:-7e1}" + +case "$LINE" in +7e1|7E1) STTY="cs7 parenb -cstopb" ;; +8n1|8N1) STTY="cs8 -parenb -cstopb" ;; +*) echo "Unknown line: $LINE" >&2; exit 1 ;; +esac + +if pgrep -x hybbx >/dev/null 2>&1; then + echo "WARN: hybbx is running - port may be in use" >&2 +fi + +echo "Passive listen: $DEV @ ${BAUD} ${LINE} for ${SECS}s (no TX commands)" +stty -F "$DEV" "$BAUD" $STTY raw -echo min 0 time 5 + +python3 <<PY +import fcntl, os, struct, time, termios + +fd = os.open("$DEV", os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK) +TIOCM_RTS, TIOCM_DTR = 0x004, 0x002 +TIOCMGET, TIOCMSET = 0x5415, 0x5416 +flags = struct.unpack("I", fcntl.ioctl(fd, TIOCMGET, struct.pack("I", 0)))[0] +flags |= TIOCM_RTS | TIOCM_DTR +fcntl.ioctl(fd, TIOCMSET, struct.pack("I", flags)) +termios.tcflush(fd, termios.TCIOFLUSH) + +end = time.time() + float("$SECS") +chunks = [] +while time.time() < end: + try: + b = os.read(fd, 4096) + if b: + chunks.append(b) + print(f"+{len(b)} bytes") + except BlockingIOError: + time.sleep(0.05) + +data = b"".join(chunks) +os.close(fd) +print(f"Total: {len(data)} bytes") +if data: + print(data[:256].hex(" ")) +PY diff --git a/stacks/tncs/tnc2c-probe.c b/stacks/tncs/tnc2c-probe.c new file mode 100644 index 0000000..8770409 --- /dev/null +++ b/stacks/tncs/tnc2c-probe.c @@ -0,0 +1,535 @@ +/* + * tnc2c-probe — scan /dev/ttyS* and /dev/ttyUSB* for a Landolt TNC2C. + * + * TNC2C host port (per manual): 7-bit, even parity, 1 stop bit. + * Terminal speed set by TERM jumper (this unit: 19200; manual default often 9600). + * RTS/CTS handshake; DTR asserted to wake some adapters. + * + * Build: make + * Run: ./tnc2c-probe + * ./tnc2c-probe /dev/ttyS4 + */ + +#if defined(__linux__) || defined(__GLIBC__) +#define _DEFAULT_SOURCE 1 +#endif + +#include <dirent.h> +#include <errno.h> +#include <fcntl.h> +#include <glob.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/ioctl.h> +#include <termios.h> +#include <time.h> +#include <unistd.h> + +#define PROBE_READ_MS 900 +#define PROBE_GAP_MS 120 +#define RX_BUF_SIZE 4096 +#define MAX_DEVICES 64 + +typedef struct serial_profile { + const char *label; + speed_t speed; + unsigned int baud_num; + tcflag_t databits; + tcflag_t parity; +} serial_profile_t; + +typedef struct probe_result { + char device[64]; + char profile[32]; + char command[32]; + int got_data; + size_t len; + char text[RX_BUF_SIZE]; + unsigned char raw[RX_BUF_SIZE]; +} probe_result_t; + +static const serial_profile_t PROFILES[] = { + { "19200-7E1", B19200, 19200, CS7, PARENB }, + { "19200-8N1", B19200, 19200, CS8, 0 }, + { "9600-7E1", B9600, 9600, CS7, PARENB }, + { "9600-8N1", B9600, 9600, CS8, 0 }, + { "4800-7E1", B4800, 4800, CS7, PARENB }, + { "2400-7E1", B2400, 2400, CS7, PARENB }, + { "1200-7E1", B1200, 1200, CS7, PARENB }, +}; + +static const char *PROBE_COMMANDS[] = { + "\r", + "INFO\r", + "HELP\r", + "?\r", +}; + +static int verbose = 0; + +static int sleep_ms(unsigned ms) +{ + struct timespec ts; + + ts.tv_sec = (time_t)(ms / 1000u); + ts.tv_nsec = (long)(ms % 1000u) * 1000000L; + return nanosleep(&ts, NULL); +} + +static int set_modem_lines(int fd, int rts_on, int dtr_on) +{ +#ifdef TIOCMGET + int flags; + + if (ioctl(fd, TIOCMGET, &flags) != 0) { + return -1; + } + if (rts_on) { + flags |= TIOCM_RTS; + } else { + flags &= ~TIOCM_RTS; + } + if (dtr_on) { + flags |= TIOCM_DTR; + } else { + flags &= ~TIOCM_DTR; + } + return ioctl(fd, TIOCMSET, &flags); +#else + (void)fd; + (void)rts_on; + (void)dtr_on; + return 0; +#endif +} + +static int read_modem_lines(int fd, int *rts, int *cts, int *dtr, int *dsr) +{ +#ifdef TIOCMGET + int flags; + + if (ioctl(fd, TIOCMGET, &flags) != 0) { + return -1; + } + if (rts != NULL) { + *rts = (flags & TIOCM_RTS) != 0; + } + if (cts != NULL) { + *cts = (flags & TIOCM_CTS) != 0; + } + if (dtr != NULL) { + *dtr = (flags & TIOCM_DTR) != 0; + } + if (dsr != NULL) { + *dsr = (flags & TIOCM_DSR) != 0; + } + return 0; +#else + (void)fd; + if (rts) { + *rts = -1; + } + if (cts) { + *cts = -1; + } + if (dtr) { + *dtr = -1; + } + if (dsr) { + *dsr = -1; + } + return 0; +#endif +} + +static int configure_port(int fd, const serial_profile_t *profile) +{ + struct termios tty; + + if (tcgetattr(fd, &tty) != 0) { + return -1; + } + + cfmakeraw(&tty); + tty.c_cflag |= (CLOCAL | CREAD); + tty.c_cflag &= ~(CSIZE | PARENB | PARODD | CSTOPB); + tty.c_cflag |= profile->databits; + if (profile->parity != 0) { + tty.c_cflag |= profile->parity; + } + tty.c_cc[VMIN] = 0; + tty.c_cc[VTIME] = 1; + +#if defined(__linux__) || defined(__GLIBC__) + if (cfsetspeed(&tty, profile->speed) != 0) { + cfsetispeed(&tty, profile->speed); + cfsetospeed(&tty, profile->speed); + } +#else + cfsetispeed(&tty, profile->speed); + cfsetospeed(&tty, profile->speed); +#endif + + if (tcsetattr(fd, TCSANOW, &tty) != 0) { + return -1; + } + + tcflush(fd, TCIOFLUSH); + return 0; +} + +static int is_printable_ascii(unsigned char c) +{ + return c == '\r' || c == '\n' || c == '\t' || (c >= 0x20 && c <= 0x7e); +} + +static void sanitize_text(const unsigned char *raw, size_t len, char *out, size_t out_cap) +{ + size_t i; + size_t o = 0; + + if (out_cap == 0) { + return; + } + + for (i = 0; i < len && o + 1 < out_cap; i++) { + unsigned char c = raw[i]; + + if (c == '\r') { + if (o + 2 < out_cap) { + out[o++] = '\\'; + out[o++] = 'r'; + } + } else if (c == '\n') { + if (o + 2 < out_cap) { + out[o++] = '\\'; + out[o++] = 'n'; + } + } else if (is_printable_ascii(c)) { + out[o++] = (char)c; + } else { + int n = snprintf(out + o, out_cap - o, "\\x%02x", c); + if (n <= 0 || (size_t)n >= out_cap - o) { + break; + } + o += (size_t)n; + } + } + out[o] = '\0'; +} + +static int looks_like_tnc(const unsigned char *raw, size_t len) +{ + static const char *needles[] = { + "cmd:", "CMD:", "TNC", "tnc", "INFO", "help", "HELP", + "***", "connected", "DISCONNECTED", "kiss", "KISS", + "MYCALL", "WA8DED", "NOCALL", "?" + }; + char buf[RX_BUF_SIZE]; + size_t i; + + if (len == 0) { + return 0; + } + + sanitize_text(raw, len, buf, sizeof(buf)); + for (i = 0; i < sizeof(needles) / sizeof(needles[0]); i++) { + if (strstr(buf, needles[i]) != NULL) { + return 1; + } + } + return len >= 3; +} + +static size_t read_for_ms(int fd, unsigned char *buf, size_t cap, unsigned ms) +{ + size_t total = 0; + unsigned elapsed = 0; + + while (elapsed < ms && total < cap) { + ssize_t n = read(fd, buf + total, cap - total); + + if (n > 0) { + total += (size_t)n; + elapsed = 0; + continue; + } + if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK) { + break; + } + sleep_ms(40); + elapsed += 40; + } + + return total; +} + +static int probe_once(const char *device, const serial_profile_t *profile, + const char *command, probe_result_t *result) +{ + int fd; + ssize_t wlen; + + memset(result, 0, sizeof(*result)); + snprintf(result->device, sizeof(result->device), "%s", device); + snprintf(result->profile, sizeof(result->profile), "%s", profile->label); + snprintf(result->command, sizeof(result->command), "%s", + command[0] == '\r' ? "<CR>" : command); + + fd = open(device, O_RDWR | O_NOCTTY | O_NONBLOCK); + if (fd < 0) { + return -1; + } + + if (configure_port(fd, profile) != 0) { + close(fd); + return -1; + } + + (void)set_modem_lines(fd, 1, 1); + sleep_ms(80); + (void)read_for_ms(fd, result->raw, sizeof(result->raw), 150); + + wlen = write(fd, command, strlen(command)); + if (wlen < 0) { + close(fd); + return -1; + } + + sleep_ms(PROBE_GAP_MS); + result->len = read_for_ms(fd, result->raw, sizeof(result->raw), PROBE_READ_MS); + close(fd); + + result->got_data = result->len > 0; + sanitize_text(result->raw, result->len, result->text, sizeof(result->text)); + return 0; +} + +static int device_accessible(const char *path) +{ + return access(path, R_OK | W_OK) == 0; +} + +static int collect_devices(char devices[][64], int max_devices) +{ + glob_t g; + size_t i; + int count = 0; + static const char *patterns[] = { + "/dev/ttyUSB[0-9]*", + "/dev/ttyS[0-9]*", + NULL + }; + int p; + + for (p = 0; patterns[p] != NULL; p++) { + if (glob(patterns[p], 0, NULL, &g) != 0) { + continue; + } + for (i = 0; i < g.gl_pathc && count < max_devices; i++) { + const char *path = g.gl_pathv[i]; + int j; + int dup = 0; + + for (j = 0; j < count; j++) { + if (strcmp(devices[j], path) == 0) { + dup = 1; + break; + } + } + if (!dup) { + snprintf(devices[count], 64, "%s", path); + count++; + } + } + globfree(&g); + } + + return count; +} + +static void print_device_header(const char *device) +{ + int fd; + int rts = -1, cts = -1, dtr = -1, dsr = -1; + + printf("\n============================================================\n"); + printf("Device: %s\n", device); + if (!device_accessible(device)) { + printf("Access: NO (need dialout group or root)\n"); + return; + } + printf("Access: yes\n"); + + fd = open(device, O_RDWR | O_NOCTTY | O_NONBLOCK); + if (fd >= 0) { + if (read_modem_lines(fd, &rts, &cts, &dtr, &dsr) == 0) { + printf("Lines (before probe): RTS=%d CTS=%d DTR=%d DSR=%d\n", + rts, cts, dtr, dsr); + } + close(fd); + } +} + +static void listen_quiet(const char *device, const serial_profile_t *profile) +{ + int fd; + unsigned char buf[512]; + size_t n; + + fd = open(device, O_RDWR | O_NOCTTY | O_NONBLOCK); + if (fd < 0) { + return; + } + if (configure_port(fd, profile) != 0) { + close(fd); + return; + } + (void)set_modem_lines(fd, 1, 1); + n = read_for_ms(fd, buf, sizeof(buf), 1200); + close(fd); + + if (n > 0) { + char text[512]; + sanitize_text(buf, n, text, sizeof(text)); + printf(" Spontaneous traffic @ %s: %s\n", profile->label, text); + } else if (verbose) { + printf(" Spontaneous traffic @ %s: (none)\n", profile->label); + } +} + +static int run_device_scan(const char *device) +{ + size_t pi; + size_t ci; + int hits = 0; + int best_score = 0; + char best_profile[32] = ""; + char best_text[512] = ""; + + print_device_header(device); + if (!device_accessible(device)) { + return 0; + } + + listen_quiet(device, &PROFILES[0]); + listen_quiet(device, &PROFILES[1]); + + for (pi = 0; pi < sizeof(PROFILES) / sizeof(PROFILES[0]); pi++) { + for (ci = 0; ci < sizeof(PROBE_COMMANDS) / sizeof(PROBE_COMMANDS[0]); ci++) { + probe_result_t result; + int score; + + if (probe_once(device, &PROFILES[pi], PROBE_COMMANDS[ci], &result) != 0) { + continue; + } + if (!result.got_data) { + if (verbose) { + printf(" %s + %s -> (no data)\n", + result.profile, result.command); + } + continue; + } + + score = looks_like_tnc(result.raw, result.len) ? 2 : 1; + hits++; + printf(" HIT %s + %s (%zu bytes, score=%d)\n", + result.profile, result.command, result.len, score); + printf(" %s\n", result.text); + + if (score > best_score) { + best_score = score; + snprintf(best_profile, sizeof(best_profile), "%s", result.profile); + strncpy(best_text, result.text, sizeof(best_text) - 1); + best_text[sizeof(best_text) - 1] = '\0'; + } + } + } + + if (hits == 0) { + printf(" Result: no response on any profile/command.\n"); + printf(" Hint: check power, cable (DTE<->DCE modem cable), baud jumper.\n"); + } else { + printf(" Result: %d response(s); best profile=%s (score=%d)\n", + hits, best_profile[0] ? best_profile : "?", best_score); + if (best_text[0] != '\0') { + printf(" Best: %s\n", best_text); + } + } + + return hits; +} + +static void usage(const char *argv0) +{ + fprintf(stderr, + "Usage: %s [-v] [device ...]\n" + "\n" + "Scan serial ports for a Landolt TNC2C.\n" + "Without device arguments, probes all /dev/ttyUSB* and /dev/ttyS*.\n" + "\n" + "Options:\n" + " -v verbose (show silent probes too)\n" + " -h this help\n", + argv0); +} + +int main(int argc, char **argv) +{ + char devices[MAX_DEVICES][64]; + int device_count = 0; + int argi; + int total_hits = 0; + int scanned = 0; + + for (argi = 1; argi < argc; argi++) { + if (strcmp(argv[argi], "-h") == 0 || strcmp(argv[argi], "--help") == 0) { + usage(argv[0]); + return 0; + } + if (strcmp(argv[argi], "-v") == 0 || strcmp(argv[argi], "--verbose") == 0) { + verbose = 1; + continue; + } + if (device_count < MAX_DEVICES) { + snprintf(devices[device_count], 64, "%s", argv[argi]); + device_count++; + } + } + + if (device_count == 0) { + device_count = collect_devices(devices, MAX_DEVICES); + } + + printf("TNC2C serial probe\n"); + printf("Profiles: 7E1 (manual) and 8N1 (HyBBX default) at common baud rates.\n"); + printf("Found %d serial device(s).\n", device_count); + + if (device_count == 0) { + fprintf(stderr, "No /dev/ttyUSB* or /dev/ttyS* devices found.\n"); + return 1; + } + + for (argi = 0; argi < device_count; argi++) { + total_hits += run_device_scan(devices[argi]); + scanned++; + } + + printf("\n============================================================\n"); + printf("Summary: scanned %d port(s), %d response(s) total.\n", + scanned, total_hits); + + if (total_hits == 0) { + printf("\nCable note (TNC2C manual):\n"); + printf(" - TNC2C DB25 is wired like a modem (DCE).\n"); + printf(" - PC/USB-serial is DTE -> use a straight modem cable,\n"); + printf(" not a null-modem crossover cable.\n"); + printf(" - Some PCs need RTS-CTS bridged on the host side (pins 4+5).\n"); + printf(" - Host settings: 7-bit, even parity (7E1), baud per TERM jumper.\n"); + printf(" - LEDs after power-on: Power on; Status+Connected off after ~3 s.\n"); + return 2; + } + + return 0; +} diff --git a/stacks/tncs/tnc2c-rf-tx-test.sh b/stacks/tncs/tnc2c-rf-tx-test.sh new file mode 100755 index 0000000..fc23eff --- /dev/null +++ b/stacks/tncs/tnc2c-rf-tx-test.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Send N AX.25 UI frames via KISS (HyBBX must be stopped). +# Usage: ./tnc2c-rf-tx-test.sh [count] [message-prefix] [src] [dst] +# Example (root): pkill hybbx; ./tnc2c-rf-tx-test.sh 3 "TX test" CB-0 QST + +set -euo pipefail + +COUNT="${1:-3}" +PREFIX="${2:-TX test}" +SRC="${3:-CB-0}" +DST="${4:-QST}" +GAP="${TNC2C_TX_GAP:-8}" +ROOT="$(cd "$(dirname "$0")" && pwd)" + +if pgrep -x hybbx >/dev/null 2>&1; then + echo "ERROR: hybbx is running - as root: pkill hybbx" >&2 + exit 1 +fi + +echo "RF TX: ${COUNT}x ${SRC} -> ${DST}, ${GAP}s gap (SQ closed, CD off)" +for i in $(seq 1 "$COUNT"); do + echo "=== SEND ${i}/${COUNT} $(date +%H:%M:%S) ===" + "$ROOT/tnc2c-send-test.sh" "${PREFIX} ${i}/${COUNT}" "$SRC" "$DST" + if [[ "$i" -lt "$COUNT" ]]; then + sleep "$GAP" + fi +done +echo "Done - check PTT LED / modem tone on radio." diff --git a/stacks/tncs/tnc2c-send-test.sh b/stacks/tncs/tnc2c-send-test.sh new file mode 100755 index 0000000..4ac1b52 --- /dev/null +++ b/stacks/tncs/tnc2c-send-test.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# Send one AX.25 UI frame via KISS on TNC2C - keys PTT / transmits on radio. +# Requires: antenna or dummyload, VOX off, HyBBX stopped. +# Usage: ./tnc2c-send-test.sh [message] [src] [dst] + +set -euo pipefail + +MSG="${1:-HI}" +SRC="${2:-TEST-0}" +DST="${3:-CQ}" + +ROOT="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=/dev/null +[[ -f "$ROOT/tnc2c-serial.env" ]] && source "$ROOT/tnc2c-serial.env" + +if pgrep -x hybbx >/dev/null 2>&1; then + echo "ERROR: hybbx is running - stop it first" >&2 + exit 1 +fi + +export TNC2C_DEV TNC2C_LINE +python3 - "$MSG" "$SRC" "$DST" <<'PY' +import fcntl, os, struct, sys, time, termios + +msg, src, dst = sys.argv[1:4] +dev = os.environ.get("TNC2C_DEV", "/dev/ttyS4") +line = os.environ.get("TNC2C_LINE", "8n1").lower() +seven_e1 = line.startswith("7e") + +def kiss_escape(data: bytes) -> bytes: + out = bytearray() + for b in data: + if b == 0xC0: + out.extend((0xDB, 0xDC)) + elif b == 0xDB: + out.extend((0xDB, 0xDD)) + else: + out.append(b) + return bytes(out) + +def ax25_addr(call: str, ssid: int, last: bool) -> bytes: + call = call.upper().ljust(6)[:6] + raw = bytes((ord(c) << 1) for c in call) + ssid_b = ((ssid & 0x0F) << 1) | (0x01 if last else 0x00) + return raw + bytes([ssid_b]) + +def ax25_crc(data: bytes) -> int: + crc = 0xFFFF + for b in data: + crc ^= b + for _ in range(8): + crc = (crc >> 1) ^ 0x8408 if crc & 1 else crc >> 1 + return crc ^ 0xFFFF + +def ax25_ui(src, dst, info: bytes) -> bytes: + body = ax25_addr(dst, 0, False) + ax25_addr(src, 0, True) + body += b"\x03\xF0" + info + crc = ax25_crc(body) + return body + bytes((crc & 0xFF, crc >> 8)) + +def kiss_frame(port: int, payload: bytes) -> bytes: + return b"\xC0" + bytes([port & 0x0F]) + kiss_escape(payload) + b"\xC0" + +print(f"Serial: {dev} 19200 {line}") + +fd = os.open(dev, os.O_RDWR | os.O_NOCTTY) +t = termios.tcgetattr(fd) +t[0] = t[1] = 0 +t[2] = termios.CLOCAL | termios.CREAD | (termios.CS7 if seven_e1 else termios.CS8) +if seven_e1: + t[2] |= termios.PARENB +t[3] = t[4] = t[5] = termios.B19200 +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] | 0x006 +fcntl.ioctl(fd, 0x5416, struct.pack("I", flags)) + +for cmd in (b"kiss off\r", b"kiss on\r"): + os.write(fd, cmd) + termios.tcdrain(fd) + time.sleep(0.5) + +pkt = kiss_frame(0, ax25_ui(src, dst, msg.encode("ascii", errors="replace"))) +print(f"TX {src} -> {dst}: {msg!r} ({len(pkt)} bytes KISS)") +os.write(fd, pkt) +termios.tcdrain(fd) +time.sleep(3.0) +try: + rx = os.read(fd, 4096) + if rx: + print(f"RX {len(rx)} bytes: {rx[:80]!r}") +except BlockingIOError: + pass +os.write(fd, b"kiss off\r") +termios.tcdrain(fd) +os.close(fd) +print("Check LED 2 (PTT) on TNC and S-meter on radio.") +PY diff --git a/stacks/tncs/tnc2c-serial.env b/stacks/tncs/tnc2c-serial.env new file mode 100644 index 0000000..89f111a --- /dev/null +++ b/stacks/tncs/tnc2c-serial.env @@ -0,0 +1,8 @@ +# TNC2C serial — edit for your station +# Example: USB-serial adapter; onboard UART is often /dev/ttyS0 +TNC2C_DEV=/dev/ttyUSB0 +TNC2C_BAUD=19200 +TNC2C_LINE=8n1 +TNC2C_RADIO_BAUD=2400 +TNC2C_MODEM=tcm3105 +HYBBX_SERIAL_LINE=8n1 diff --git a/stacks/tncs/tnc2c-tx-rx-test.py b/stacks/tncs/tnc2c-tx-rx-test.py new file mode 100644 index 0000000..76e639d --- /dev/null +++ b/stacks/tncs/tnc2c-tx-rx-test.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""TX then RX diagnostic for TNC2C + CB - no HyBBX.""" + +from __future__ import annotations + +import fcntl +import os +import struct +import sys +import time +import termios + +DEV = "/dev/ttyS4" +BAUD = termios.B19200 + +FIRMWARE = (b"TheFirmware", b"NORD", b"Version 2.7", b"Checksum") + + +def open_port() -> int: + 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 | termios.CS8 + t[3] = t[4] = t[5] = BAUD + 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] + flags |= 0x006 + fcntl.ioctl(fd, 0x5416, struct.pack("I", flags)) + return fd + + +def modem(fd: int) -> str: + f = struct.unpack("I", fcntl.ioctl(fd, 0x5415, struct.pack("I", 0)))[0] + return f"RTS={int(bool(f&4))} CTS={int(bool(f&8))} DSR={int(bool(f&0x100))} DCD={int(bool(f&0x80))}" + + +def read_sec(fd: int, sec: float) -> bytes: + end = time.time() + sec + chunks: list[bytes] = [] + while time.time() < end: + try: + b = os.read(fd, 4096) + if b: + chunks.append(b) + except BlockingIOError: + time.sleep(0.02) + return b"".join(chunks) + + +def write(fd: int, data: bytes) -> None: + os.write(fd, data) + termios.tcdrain(fd) + + +def has_fw(data: bytes) -> bool: + lo = data.lower() + return any(m.lower() in lo for m in FIRMWARE) + + +def kiss_escape(data: bytes) -> bytes: + out = bytearray() + for b in data: + if b == 0xC0: + out.extend((0xDB, 0xDC)) + elif b == 0xDB: + out.extend((0xDB, 0xDD)) + else: + out.append(b) + return bytes(out) + + +def ax25_addr(call: str, last: bool) -> bytes: + call = call.upper().ljust(6)[:6] + raw = bytes((ord(c) << 1) for c in call) + return raw + bytes([((0 & 0x0F) << 1) | (0x01 if last else 0x00)]) + + +def ax25_crc(data: bytes) -> int: + crc = 0xFFFF + for b in data: + crc ^= b + for _ in range(8): + crc = (crc >> 1) ^ 0x8408 if crc & 1 else crc >> 1 + return crc ^ 0xFFFF + + +def ax25_ui(src: str, dst: str, info: bytes) -> bytes: + body = ax25_addr(dst, False) + ax25_addr(src, True) + b"\x03\xF0" + info + crc = ax25_crc(body) + return body + bytes((crc & 0xFF, crc >> 8)) + + +def kiss_frame(payload: bytes) -> bytes: + return b"\xC0\x00" + kiss_escape(payload) + b"\xC0" + + +def main() -> int: + print("=" * 60) + print("TNC2C TX/RX Test") + print("=" * 60) + + if os.system("pgrep -x hybbx >/dev/null 2>&1") == 0: + print("FAIL: hybbx is running - stop it first") + return 2 + + fd = open_port() + print(f"Device: {DEV} 19200 8N1 {modem(fd)}") + + # --- Host check --- + print("\n[1] Host check") + passive = read_sec(fd, 2.0) + write(fd, b"kiss off\r") + time.sleep(0.5) + pre = read_sec(fd, 1.0) + write(fd, b"INFO\r") + time.sleep(0.3) + info = read_sec(fd, 4.0) + all_host = passive + pre + info + if has_fw(all_host): + print(" OK: firmware/host detected") + elif info.strip() in (b"INFO", b"INFO\r", b"INFO\r\n"): + print(" WARN: echo only - try ./tnc2c-host-reset.sh --kiss or ./tnc2c-boot-wait.sh --recover-only") + else: + print(f" INFO reply: {info[:120]!r}") + + # --- TX --- + print("\n[2] TX (KISS UI TEST-0 -> CQ, Msg=TXRX)") + write(fd, b"kiss on\r") + time.sleep(0.5) + read_sec(fd, 0.3) + pkt = kiss_frame(ax25_ui("TEST-0", "CQ", b"TXRX")) + t0 = time.time() + write(fd, pkt) + time.sleep(3.0) + tx_rx = read_sec(fd, 1.0) + write(fd, b"kiss off\r") + time.sleep(0.3) + + kiss_back = b"\xc0" in tx_rx or b"TXRX" in tx_rx + print(f" Sent: {len(pkt)} bytes KISS") + print(f" Serial RX: {len(tx_rx)} bytes") + if kiss_back: + print(" OK: KISS frame seen on serial") + else: + print(" WARN: no KISS echo on serial") + print(" -> CHECK NOW: LED2 PTT + modem tone on 2m CB radio") + + # --- RX (passive, no TX) --- + print("\n[3] RX (passive 12s, kiss off - listening on radio/monitor)") + termios.tcflush(fd, termios.TCIOFLUSH) + write(fd, b"kiss off\r") + time.sleep(0.5) + read_sec(fd, 0.5) + print(" Listening 12s ... (RF noise/CD should keep LED3 on)") + rx_data = read_sec(fd, 12.0) + os.close(fd) + + print(f" Passive RX: {len(rx_data)} bytes") + if rx_data: + printable = sum(1 for b in rx_data if 32 <= b < 127 or b in (9, 10, 13)) + ratio = printable / len(rx_data) + print(f" Printable ratio: {ratio:.0%}") + if b"\xc0" in rx_data: + print(" OK: KISS frame(s) received (RF traffic or monitor)") + else: + print(f" Sample: {rx_data[:80]!r}") + else: + print(" (no serial traffic - normal on empty band)") + print(" -> Hardware RX: LED3 CD with noise/SQ open?") + + print("\n" + "=" * 60) + print("Hardware checklist:") + print(" TX: LED2 (PTT) blinked? 2m CB heard modem tone?") + print(" RX: LED3 (CD) on with noise? SQ closed -> CD off?") + print(" DIN: Pin1=TX(white) Pin2=GND Pin3=PTT(red) Pin4=RX(tip)") + print("=" * 60) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stacks/tncs/tnc2c.jpg b/stacks/tncs/tnc2c.jpg Binary files differnew file mode 100644 index 0000000..91a3116 --- /dev/null +++ b/stacks/tncs/tnc2c.jpg diff --git a/stacks/tncs/tnc_serial_recovery.py b/stacks/tncs/tnc_serial_recovery.py new file mode 100644 index 0000000..f7b1370 --- /dev/null +++ b/stacks/tncs/tnc_serial_recovery.py @@ -0,0 +1,359 @@ +""" +TheFirmware TF 2.7 — terminal recovery without power cycle. + +Native command set (NORD><LINK source / ALAS — see stacks/tncs/docs/TNC-RECOVERY.md). +TAPR strings (kiss off, INFO, RESTART) are not primary; kept as last-resort legacy. + +Recovery ladder (stop on first banner): + 0. DTR+RTS high + settle (caller) + 1. Passive listen + 2. KISS return 0xC0 0xFF 0xC0 (tfkiss.mac cmd 0xFF → firmware reset + banner) + 3. Buffer flush ^Q^X + JHOST 0 + 4. ESC V (version / terminal probe) + 5. ESC QRES (cold boot from EPROM — no mains power cycle) + 6. ESC E 0 + ESC V + 7. Legacy TAPR kiss off + INFO (optional last resort) +""" + +from __future__ import annotations + +import time +from typing import Callable + +FIRMWARE_MARKERS = ( + b"TheFirmware", + b"NORD", + b"Version 2.7", + b"Checksum", + b"Copyright", + b"DAMA", + b"SMACK", + b"cmd:", + b"CMD:", +) + +TF_ESC_V = b"\x1bV\r" +TF_ESC_QRES = b"\x1bQRES\r" +TF_ESC_AT_K = b"\x1b@K" +TF_ESC_E0 = b"\x1bE0\r" + +LogFn = Callable[[str], None] + + +def matched_markers(data: bytes) -> list[str]: + return [m.decode("ascii", errors="replace") for m in FIRMWARE_MARKERS if m.lower() in data.lower()] + + +def escape_bytes(data: bytes, max_len: int = 96) -> str: + out: list[str] = [] + for byte in data[:max_len]: + if 32 <= byte < 127: + out.append(chr(byte)) + elif byte == 0x0D: + out.append("\\r") + elif byte == 0x0A: + out.append("\\n") + else: + out.append(f"\\x{byte:02x}") + if len(data) > max_len: + out.append("…") + return "".join(out) + + +def format_rx_brief(data: bytes) -> str: + if not data: + return "0 B silent" + markers = matched_markers(data) + marker_txt = ",".join(markers) if markers else "none" + return f"{len(data)} B, markers={marker_txt}, text={escape_bytes(data)!r}" + + +def format_rx_diag(data: bytes) -> str: + """One-line firmware/RX summary for operator logs.""" + if not data: + return "silent (0 bytes) — check power, baud, CTS/DTR wiring" + markers = matched_markers(data) + hex_snip = data[:48].hex(" ") + if len(data) > 48: + hex_snip += " …" + parts = [ + f"{len(data)} bytes", + f"markers={markers or 'none'}", + f"text={escape_bytes(data, 120)!r}", + f"hex={hex_snip}", + ] + return "; ".join(parts) + + +def classify_recovery_failure(received: bytes) -> str: + if not received: + return ( + "silent — TNC sent nothing (power, baud/line, DE-9 wiring, or boot without DTR)" + ) + if has_banner(received): + return "banner seen in RX but terminal probe failed — check host mode or double-echo" + lower = received.lower() + if b"\x1bv" in lower or b"* " in received: + if not has_banner(received): + return ( + "TF terminal alive (ESC V prompt) but no firmware banner — try ESC QRES with DTR high" + ) + echo_hits = sum( + 1 + for token in ( + b"kiss off", + b"info\r", + b"info\n", + b"restart\r", + b"jhost 0", + ) + if token in lower + ) + if echo_hits >= 2 or ( + b"kiss off" in lower and (b"info" in lower or b"info\r" in lower) + ): + return ( + "echo-only — TAPR-style or transparent echo, no TheFirmware banner " + "(Landolt: DTR high at power-on; try boot-wait or ESC QRES)" + ) + if any(b < 0x20 and b not in (0x0D, 0x0A, 0x09) for b in received[:64]): + return ( + f"binary/non-text ({len(received)} B) — likely KISS/stream mode, not terminal" + ) + return f"non-banner text ({len(received)} B) — firmware state unclear, capture full RX" + + +def has_banner(data: bytes) -> bool: + lower = data.lower() + return any(m.lower() in lower for m in FIRMWARE_MARKERS) + + +def is_echo_only(cmd: bytes, reply: bytes) -> bool: + c = cmd.strip(b"\r\n") + r = reply.strip() + return r in (c, c + b"\r", c + b"\n", c + b"\r\n") + + +def probe_tf_terminal( + write_flush: Callable[[bytes], None], + read_for: Callable[[float], bytes], + pause: float = 0.4, +) -> tuple[bool, bytes, bool]: + """ESC V — native TheFirmware version / terminal probe.""" + write_flush(TF_ESC_V) + time.sleep(pause) + reply = read_for(4.0) + only_echo = is_echo_only(TF_ESC_V, reply) + return has_banner(reply), reply, only_echo + + +def probe_info( + write_flush: Callable[[bytes], None], + read_for: Callable[[float], bytes], + pause: float = 0.4, +) -> tuple[bool, bytes, bool]: + """Alias: native TF probe (was TAPR kiss off + INFO).""" + return probe_tf_terminal(write_flush, read_for, pause=pause) + + +def probe_combined( + write_flush: Callable[[bytes], None], + read_for: Callable[[float], bytes], +) -> tuple[bool, bytes, bool]: + """Legacy TAPR kiss off + INFO — last resort only.""" + write_flush(b"kiss off\rINFO\r") + time.sleep(0.8) + reply = read_for(5.0) + only_echo = is_echo_only(b"kiss off\rINFO\r", reply) or ( + is_echo_only(b"kiss off\r", reply[:20]) and is_echo_only(b"INFO\r", reply[-20:]) + ) + return has_banner(reply), reply, only_echo + + +def send_jhost0(write_flush: Callable[[bytes], None]) -> None: + """Leave WA8DED host mode → terminal (gettoweb / WA8DED guide).""" + send_buffer_flush(write_flush) + write_flush(b"\x00" * 300) + time.sleep(0.15) + write_flush(b"\x00\x01\x06JHOST 0\r") + time.sleep(0.8) + + +def send_kiss_return(write_flush: Callable[[bytes], None]) -> None: + write_flush(b"\xc0\xff\xc0") + time.sleep(1.0) + + +def send_esc_at_k(write_flush: Callable[[bytes], None]) -> None: + """Enter KISS from terminal (tfb.c @K) — not an exit path.""" + write_flush(TF_ESC_AT_K) + time.sleep(0.8) + + +def send_echo_off(write_flush: Callable[[bytes], None]) -> None: + """TheFirmware ESC E 0 — disable terminal echo.""" + write_flush(TF_ESC_E0) + time.sleep(0.3) + + +def send_qres(write_flush: Callable[[bytes], None]) -> None: + """Software cold boot from EPROM (tfd.c Qcmd) — DTR must stay high.""" + write_flush(TF_ESC_QRES) + time.sleep(2.5) + + +def send_buffer_flush(write_flush: Callable[[bytes], None]) -> None: + """WA8DED host guide: ^Q^X before commands if junk in buffer.""" + write_flush(b"\x11\x18") + time.sleep(0.2) + + +def tf_mycall_frame(call: str) -> bytes: + """ESC I — set own callsign (tfb.c Icmd).""" + return f"\x1bI {call.upper()}\r".encode("ascii", errors="replace") + + +def _try_probe( + write_flush: Callable[[bytes], None], + read_for: Callable[[float], bytes], + out: LogFn, + label: str, +) -> tuple[bool, bytes]: + ok, probe, only_echo = probe_tf_terminal(write_flush, read_for) + if ok: + out(f"recovery: OK after {label}") + return True, probe + out( + f"recovery: probe after {label} — {format_rx_brief(probe)}, " + f"echo_only={only_echo}, banner={has_banner(probe)}" + ) + if not only_echo and probe: + out(f"recovery: partial reply after {label}") + return False, probe + + +def recover_terminal( + write_flush: Callable[[bytes], None], + read_for: Callable[[float], bytes], + *, + skip_kiss_frame: bool = False, + log: LogFn | None = None, +) -> tuple[bool, bytes]: + """Run full software recovery ladder. Returns (host_ok, all_rx_bytes).""" + out = log or (lambda _msg: None) + received = b"" + + chunk = read_for(1.5) + received += chunk + out(f"recovery: passive listen — {format_rx_brief(chunk)}") + if has_banner(chunk): + out("recovery: OK — banner on passive listen") + return True, received + + if not skip_kiss_frame: + send_kiss_return(write_flush) + chunk = read_for(2.5) + received += chunk + out(f"recovery: KISS return frame — {format_rx_brief(chunk)}") + if has_banner(chunk): + out("recovery: OK after KISS return (firmware reset)") + return True, received + + send_jhost0(write_flush) + chunk = read_for(1.0) + received += chunk + out(f"recovery: JHOST 0 — {format_rx_brief(chunk)}") + if has_banner(chunk): + out("recovery: OK after JHOST 0") + return True, received + + ok, probe = _try_probe(write_flush, read_for, out, "ESC V") + received += probe + if ok: + return True, received + + send_qres(write_flush) + chunk = read_for(3.0) + received += chunk + out(f"recovery: ESC QRES — {format_rx_brief(chunk)}") + if has_banner(chunk): + out("recovery: OK after ESC QRES") + return True, received + + ok, probe = _try_probe(write_flush, read_for, out, "post-QRES ESC V") + received += probe + if ok: + return True, received + + send_echo_off(write_flush) + received += read_for(0.3) + ok, probe = _try_probe(write_flush, read_for, out, "ESC E 0") + received += probe + if ok: + return True, received + + send_qres(write_flush) + received += read_for(3.0) + out("recovery: second ESC QRES") + ok, probe = _try_probe(write_flush, read_for, out, "second QRES") + received += probe + if ok: + return True, received + + ok, probe, _ = probe_combined(write_flush, read_for) + received += probe + if ok: + out("recovery: OK after legacy TAPR kiss off + INFO") + return True, received + if probe: + out(f"recovery: legacy TAPR probe — {format_rx_brief(probe)}") + + out( + "recovery: FAILED — echo only or silent " + "(retry with DTR high; cold-boot without DTR may need boot-wait + power cycle)" + ) + out(f"recovery: firmware assessment — {classify_recovery_failure(received)}") + out(f"recovery: RX capture — {format_rx_diag(received)}") + return False, received + + +def enter_kiss( + write_flush: Callable[[bytes], None], + read_for: Callable[[float], bytes], + entry: str = "kiss_on", +) -> None: + """Enter KISS after terminal recovery (native TF: ESC @K).""" + if entry == "tapr": + write_flush(b"kiss on\r") + time.sleep(0.5) + read_for(0.5) + return + if entry == "auto": + write_flush(TF_ESC_AT_K) + time.sleep(0.5) + reply = read_for(0.5) + if reply.strip() in (b"kiss on", b"kiss on\r", b"kiss on\n"): + write_flush(TF_ESC_AT_K) + time.sleep(0.5) + read_for(0.5) + return + write_flush(TF_ESC_AT_K) + time.sleep(0.5) + read_for(0.3) + + +def recover_and_enter_kiss( + write_flush: Callable[[bytes], None], + read_for: Callable[[float], bytes], + *, + kiss_entry: str = "kiss_on", + skip_kiss_frame: bool = False, + log: LogFn | None = None, +) -> bool: + ok, _ = recover_terminal( + write_flush, read_for, skip_kiss_frame=skip_kiss_frame, log=log + ) + if not ok: + return False + enter_kiss(write_flush, read_for, kiss_entry) + return True diff --git a/stacks/web/CMakeLists.txt b/stacks/web/CMakeLists.txt new file mode 100644 index 0000000..e24e4a5 --- /dev/null +++ b/stacks/web/CMakeLists.txt @@ -0,0 +1,42 @@ +cmake_minimum_required(VERSION 3.16) + +if(NOT MAX25_BUILD_WEB) + return() +endif() + +set(_MAX25_WEB_SRC "${CMAKE_SOURCE_DIR}/stacks/web") + +execute_process( + COMMAND "${CMAKE_COMMAND}" -E chmod u+x + "${_MAX25_WEB_SRC}/proxy/max25-ws-proxy.py" + "${_MAX25_WEB_SRC}/scripts/dev-server.sh" + ERROR_QUIET +) + +install(PROGRAMS "${_MAX25_WEB_SRC}/proxy/max25-ws-proxy.py" + DESTINATION "${CMAKE_INSTALL_BINDIR}" + RENAME max25-ws-proxy) + +install(FILES "${_MAX25_WEB_SRC}/share/web-proxy.ini.example" + "${_MAX25_WEB_SRC}/share/web-terminal.ini.example" + DESTINATION "${CMAKE_INSTALL_DATADIR}/max25") + +install(FILES "${_MAX25_WEB_SRC}/share/web-terminal.ini.example" + DESTINATION "${CMAKE_INSTALL_DATADIR}/max25") + +install(FILES "${CMAKE_SOURCE_DIR}/share/max25/max25-ws-proxy.service.example" + DESTINATION "${CMAKE_INSTALL_DATADIR}/max25") + +install(DIRECTORY "${_MAX25_WEB_SRC}/share/reverse-proxy/" + DESTINATION "${CMAKE_INSTALL_DATADIR}/max25/reverse-proxy") + +install(PROGRAMS "${_MAX25_WEB_SRC}/scripts/dev-server.sh" + DESTINATION "${CMAKE_INSTALL_DATADIR}/max25/web" + RENAME max25-web-dev) + +add_custom_target(max25_web_smoke + COMMAND "${CMAKE_COMMAND}" -E env PYTHONPATH= + python3 "${_MAX25_WEB_SRC}/proxy/test_ws_proxy.py" + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" + COMMENT "MAX25 web proxy smoke test" +) diff --git a/stacks/web/README.md b/stacks/web/README.md new file mode 100644 index 0000000..18cc291 --- /dev/null +++ b/stacks/web/README.md @@ -0,0 +1,112 @@ +# MAX25 Web UI — browser remote terminal + +> **Status: available** — full remote terminal (M25/1 via WebSocket proxy). Same session model as `max25-terminal`. Not a replacement for the CLI client. + +**Operator guide:** [docs/WEBSOCKET.md](../../docs/WEBSOCKET.md) · **Roadmap:** [docs/V2.0.0-SCOPE.md](../../docs/V2.0.0-SCOPE.md#dev-levels-roadmap-stack-wide) + +Browser terminal for **max25d** M25/1 — hyBBX-style httpd + reverse-proxy pattern, MAX25-correct separate loopback proxy. + +| Layer | Component | Role | +|-------|-----------|------| +| Browser UI | PHP + JS (`docroot/max25-websocket/`) | Remote terminal; `/` commands + menu | +| Session defaults | `web-terminal.ini` | CALLERID, CALLID, device, ax25_ui per session | +| HTTP reverse-proxy | nginx / Apache / lighttpd examples | `WS /max25-websocket/ws` → loopback proxy | +| WebSocket proxy | `max25-ws-proxy.py` | RFC6455 ↔ M25/1 TCP forward (no translation) | +| Daemon | `max25d` | M25/1 on TCP `7325` | + +CLI operator client remains **max25-terminal** — see [docs/MAX25-TERMINAL.md](../../docs/MAX25-TERMINAL.md). + +## Implementation status + +| Item | State | +|------|--------| +| PHP + JS UI (`docroot/max25-websocket/`) | **Available** — header, dropdown menu, device select | +| `max25-terminal.js` | **Available** — F10-equivalent `/` commands, bootstrap from INI | +| `web-terminal.ini.example` | **Available** — session defaults from max25d logic | +| `max25-ws-proxy.py` | **Available** — CI smoke test passes | +| httpd examples | Draft configs (nginx, Apache, lighttpd) | +| systemd unit example | Shipped — `share/max25/max25-ws-proxy.service.example` | +| Admin dashboard | Later | + +## Browser commands (F10 equivalent) + +| `/command` | M25/1 | +|------------|-------| +| `/callerid` `/callid` | `SET CALLERID` / `SET CALLID` | +| `/status` | `GET STATUS` | +| `/send <text>` | `SEND` | +| `/monitor` | `MONITOR on\|off` | +| `/connect` `/disconnect` | `CONNECT` / `DISCONNECT` | +| `/devices` | `GET DEVICES` | +| `/device <id>` | `SET DEVICE` (max25d ids only) | +| `/ax25_ui on\|off` | `SET AX25_UI` | +| `/help` | Command list | + +Plain text without `/` → `SEND` (same as max25-terminal Enter). **Menu ▾** dropdown mirrors these actions. + +One **device per session**; change via dropdown or `/device`. Device list always from **GET DEVICES** — unknown ids rejected. + +## Quick start (local dev) + +```bash +# Terminal 1: max25d +python3 stacks/daemon/max25d.py --tcp-port 7325 + +# Terminal 2: dev stack (proxy + PHP) +./stacks/web/scripts/dev-server.sh +``` + +Browser: `http://127.0.0.1:8080/` + +Copy `share/web-terminal.ini.example` → `/etc/max25/web-terminal.ini` for session defaults (CALLERID, CALLID, device from max25d.ini logic). + +## Configuration + +### WebSocket proxy (`web-proxy.ini`) + +Copy `share/web-proxy.ini.example` to `/etc/max25/web-proxy.ini` (or `MAX25_WEB_PROXY_INI`). + +| Section | Key | Default | Meaning | +|---------|-----|---------|---------| +| `[proxy]` | `bind` | `127.0.0.1` | Loopback only | +| `[proxy]` | `port` | `7326` | WebSocket listen port | +| `[proxy]` | `path` | `/max25` | Upgrade path | +| `[upstream]` | `host` / `port` | `127.0.0.1` / `7325` | max25d M25/1 | +| `[upstream]` | `tcp_password` | *(empty)* | Proxy-side AUTH | + +### Browser session (`web-terminal.ini`) + +Copy `share/web-terminal.ini.example` to `/etc/max25/web-terminal.ini` (or `MAX25_WEB_TERMINAL_INI`). + +Merges with max25d `[modem]` and `[devices] default` when keys omitted — see [docs/WEBSOCKET.md](../../docs/WEBSOCKET.md). + +## Production deploy + +```bash +cmake --install $BUILD --prefix $PREFIX +``` + +Installs: `max25-ws-proxy`, `web-proxy.ini.example`, `web-terminal.ini.example`, `reverse-proxy/` tree. + +1. Copy `docroot/max25-websocket/` to httpd docroot +2. Add httpd WS snippet → `127.0.0.1:7326/max25` +3. Configure `web-proxy.ini` + `web-terminal.ini` +4. Run `max25-ws-proxy` (systemd example shipped) + +## Architecture + +``` +Browser → httpd (wss) → max25-ws-proxy :7326 → max25d M25/1 :7325 +``` + +M25/1 uses `\n` line endings. Proxy forwards bytes after connect handshake (`OK` + `STATUS`). + +## CI + +Target `max25_web_smoke` — PING through max25d + proxy + WebSocket (offline, no UART). + +## Related + +- [docs/WEBSOCKET.md](../../docs/WEBSOCKET.md) — operator guide +- [include/max25/protocol.md](../../include/max25/protocol.md) — M25/1 spec +- [docs/MAX25-CLIENT.md](../../docs/MAX25-CLIENT.md) — client contract diff --git a/stacks/web/proxy/max25-ws-proxy.py b/stacks/web/proxy/max25-ws-proxy.py new file mode 100755 index 0000000..fe593e8 --- /dev/null +++ b/stacks/web/proxy/max25-ws-proxy.py @@ -0,0 +1,399 @@ +#!/usr/bin/env python3 +""" +max25-ws-proxy — WebSocket forward-proxy to max25d M25/1 TCP (loopback only). + +Accepts RFC6455 text frames and forwards bytes to max25d without protocol +translation. Optional upstream AUTH uses [upstream] tcp_password from config. + +Intended behind a TLS reverse proxy; see stacks/web/share/reverse-proxy/. +""" +from __future__ import annotations + +import argparse +import asyncio +import base64 +import configparser +import hashlib +import logging +import os +import signal +import struct +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" +LOG = logging.getLogger("max25-ws-proxy") + + +@dataclass +class ProxyConfig: + bind: str = "127.0.0.1" + port: int = 7326 + path: str = "/max25" + upstream_host: str = "127.0.0.1" + upstream_port: int = 7325 + tcp_password: str = "" + max_connections: int = 10 + + +def load_config(path: Optional[str]) -> ProxyConfig: + cfg = ProxyConfig() + if not path: + return cfg + + ini = configparser.ConfigParser() + ini.read(path) + if ini.has_section("proxy"): + sec = ini["proxy"] + cfg.bind = sec.get("bind", cfg.bind) + cfg.port = sec.getint("port", fallback=cfg.port) + cfg.path = sec.get("path", cfg.path) + cfg.max_connections = sec.getint("max_connections", fallback=cfg.max_connections) + if ini.has_section("upstream"): + sec = ini["upstream"] + cfg.upstream_host = sec.get("host", cfg.upstream_host) + cfg.upstream_port = sec.getint("port", fallback=cfg.upstream_port) + cfg.tcp_password = sec.get("tcp_password", cfg.tcp_password) + return cfg + + +def resolve_config_path(explicit: Optional[str]) -> Optional[str]: + if explicit: + return explicit + env = os.environ.get("MAX25_WEB_PROXY_INI") + if env: + return env + for candidate in ( + "/etc/max25/web-proxy.ini", + ): + if Path(candidate).is_file(): + return candidate + return None + + +async def read_http_request(reader: asyncio.StreamReader) -> tuple[str, dict[str, str]]: + request_line = (await reader.readline()).decode("ascii", errors="replace").strip() + if not request_line: + raise ConnectionError("empty request") + headers: dict[str, str] = {} + while True: + line = (await reader.readline()).decode("ascii", errors="replace").strip() + if line == "": + break + if ":" not in line: + continue + key, value = line.split(":", 1) + headers[key.strip().lower()] = value.strip() + return request_line, headers + + +def ws_accept_key(sec_key: str) -> str: + digest = hashlib.sha1((sec_key + WS_GUID).encode("ascii")).digest() + return base64.b64encode(digest).decode("ascii") + + +async def ws_handshake( + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + expected_path: str, +) -> None: + request_line, headers = await read_http_request(reader) + parts = request_line.split() + if len(parts) < 2: + raise ConnectionError("bad request line") + path = parts[1].split("?", 1)[0] + if path != expected_path: + raise ConnectionError(f"path mismatch: {path!r}") + + upgrade = headers.get("upgrade", "").lower() + connection = headers.get("connection", "").lower() + sec_key = headers.get("sec-websocket-key", "") + if upgrade != "websocket" or "upgrade" not in connection or not sec_key: + raise ConnectionError("not a websocket upgrade") + + accept = ws_accept_key(sec_key) + response = ( + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Accept: {accept}\r\n" + "\r\n" + ) + writer.write(response.encode("ascii")) + await writer.drain() + + +async def ws_read_frame(reader: asyncio.StreamReader) -> tuple[int, bytes]: + header = await reader.readexactly(2) + b1, b2 = header[0], header[1] + opcode = b1 & 0x0F + masked = bool(b2 & 0x80) + length = b2 & 0x7F + if length == 126: + length = struct.unpack("!H", await reader.readexactly(2))[0] + elif length == 127: + length = struct.unpack("!Q", await reader.readexactly(8))[0] + mask = await reader.readexactly(4) if masked else b"" + payload = await reader.readexactly(length) if length else b"" + if masked: + payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + return opcode, payload + + +async def ws_write_text(writer: asyncio.StreamWriter, text: bytes) -> None: + length = len(text) + header = bytearray([0x81]) # FIN + text frame + if length < 126: + header.append(length) + elif length < 65536: + header.append(126) + header.extend(struct.pack("!H", length)) + else: + header.append(127) + header.extend(struct.pack("!Q", length)) + writer.write(bytes(header) + text) + await writer.drain() + + +async def ws_write_pong(writer: asyncio.StreamWriter, payload: bytes) -> None: + length = len(payload) + header = bytearray([0x8A]) # FIN + pong + if length < 126: + header.append(length) + else: + header.append(126) + header.extend(struct.pack("!H", length)) + writer.write(bytes(header) + payload) + await writer.drain() + + +async def read_line(reader: asyncio.StreamReader) -> Optional[str]: + line = await reader.readline() + if not line: + return None + text = line.decode("utf-8", errors="replace") + if text.endswith("\n"): + text = text[:-1] + if text.endswith("\r"): + text = text[:-1] + return text + + +async def upstream_handshake( + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + tcp_password: str, +) -> list[str]: + """Complete max25d connect handshake; return lines to forward to the client.""" + first = await read_line(reader) + if first is None: + raise ConnectionError("upstream closed during handshake") + + forwarded: list[str] = [] + if first == "AUTH required": + if not tcp_password: + forwarded.append(first) + return forwarded + writer.write(f"AUTH {tcp_password}\n".encode("utf-8")) + await writer.drain() + reply = await read_line(reader) + if reply != "OK": + raise ConnectionError(f"upstream auth failed: {reply!r}") + forwarded.append("OK") + status = await read_line(reader) + if status is None: + raise ConnectionError("upstream closed after auth") + forwarded.append(status) + return forwarded + + if first != "OK": + raise ConnectionError(f"unexpected upstream greeting: {first!r}") + forwarded.append(first) + status = await read_line(reader) + if status is None: + raise ConnectionError("upstream closed after OK") + forwarded.append(status) + return forwarded + + +async def pipe_tcp_to_ws( + tcp_reader: asyncio.StreamReader, + ws_writer: asyncio.StreamWriter, +) -> None: + while True: + chunk = await tcp_reader.read(4096) + if not chunk: + break + await ws_write_text(ws_writer, chunk) + + +async def pipe_ws_to_tcp( + ws_reader: asyncio.StreamReader, + ws_writer: asyncio.StreamWriter, + tcp_writer: asyncio.StreamWriter, +) -> None: + while True: + opcode, payload = await ws_read_frame(ws_reader) + if opcode == 0x8: + break + if opcode == 0x9: + await ws_write_pong(ws_writer, payload) + continue + if opcode == 0xA: + continue + if opcode != 0x1: + LOG.warning("ignoring non-text websocket frame opcode=%s", opcode) + continue + tcp_writer.write(payload) + await tcp_writer.drain() + + +class ConnectionLimiter: + def __init__(self, limit: int) -> None: + self.limit = limit + self.active = 0 + + def try_acquire(self) -> bool: + if self.active >= self.limit: + return False + self.active += 1 + return True + + def release(self) -> None: + if self.active > 0: + self.active -= 1 + + +async def handle_client( + ws_reader: asyncio.StreamReader, + ws_writer: asyncio.StreamWriter, + cfg: ProxyConfig, + limiter: ConnectionLimiter, +) -> None: + peer = ws_writer.get_extra_info("peername") + if not limiter.try_acquire(): + LOG.warning("connection limit reached; rejecting %s", peer) + ws_writer.close() + await ws_writer.wait_closed() + return + + try: + await ws_handshake(ws_reader, ws_writer, cfg.path) + except Exception as exc: + LOG.warning("handshake failed from %s: %s", peer, exc) + limiter.release() + ws_writer.close() + await ws_writer.wait_closed() + return + + LOG.info("websocket accepted from %s", peer) + tcp_reader: Optional[asyncio.StreamReader] = None + tcp_writer: Optional[asyncio.StreamWriter] = None + try: + tcp_reader, tcp_writer = await asyncio.open_connection( + cfg.upstream_host, cfg.upstream_port + ) + lines = await upstream_handshake(tcp_reader, tcp_writer, cfg.tcp_password) + for line in lines: + await ws_write_text(ws_writer, (line + "\n").encode("utf-8")) + + tcp_task = asyncio.create_task(pipe_tcp_to_ws(tcp_reader, ws_writer)) + ws_task = asyncio.create_task(pipe_ws_to_tcp(ws_reader, ws_writer, tcp_writer)) + done, pending = await asyncio.wait( + {tcp_task, ws_task}, return_when=asyncio.FIRST_COMPLETED + ) + for task in pending: + task.cancel() + await asyncio.gather(*pending, return_exceptions=True) + except Exception as exc: + LOG.warning("session ended for %s: %s", peer, exc) + try: + await ws_write_text(ws_writer, f"\n[proxy error: {exc}]\n".encode("utf-8")) + except Exception: + pass + finally: + if tcp_writer is not None: + tcp_writer.close() + try: + await tcp_writer.wait_closed() + except Exception: + pass + ws_writer.close() + try: + await ws_writer.wait_closed() + except Exception: + pass + limiter.release() + LOG.info("websocket closed for %s", peer) + + +async def run_server(cfg: ProxyConfig) -> None: + limiter = ConnectionLimiter(cfg.max_connections) + server = await asyncio.start_server( + lambda r, w: handle_client(r, w, cfg, limiter), + host=cfg.bind, + port=cfg.port, + ) + addrs = ", ".join(str(sock.getsockname()) for sock in server.sockets or []) + LOG.info( + "listening on %s path=%s -> %s:%s", + addrs, + cfg.path, + cfg.upstream_host, + cfg.upstream_port, + ) + async with server: + await server.serve_forever() + + +def main() -> int: + parser = argparse.ArgumentParser(description="MAX25 WebSocket forward-proxy to max25d TCP") + parser.add_argument("-c", "--config", help="web-proxy.ini path") + parser.add_argument("--bind", help="override [proxy] bind") + parser.add_argument("--port", type=int, help="override [proxy] port") + parser.add_argument("--path", help="override [proxy] path") + parser.add_argument("--upstream-host", help="override [upstream] host") + parser.add_argument("--upstream-port", type=int, help="override [upstream] port") + parser.add_argument("-v", "--verbose", action="store_true") + args = parser.parse_args() + + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + ) + + config_path = resolve_config_path(args.config) + cfg = load_config(config_path) + if args.bind: + cfg.bind = args.bind + if args.port: + cfg.port = args.port + if args.path: + cfg.path = args.path + if args.upstream_host: + cfg.upstream_host = args.upstream_host + if args.upstream_port: + cfg.upstream_port = args.upstream_port + + if config_path: + LOG.info("config: %s", config_path) + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, loop.stop) + + try: + loop.run_until_complete(run_server(cfg)) + except KeyboardInterrupt: + pass + finally: + loop.close() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stacks/web/proxy/test_ws_proxy.py b/stacks/web/proxy/test_ws_proxy.py new file mode 100755 index 0000000..fccba84 --- /dev/null +++ b/stacks/web/proxy/test_ws_proxy.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""CI-safe smoke: max25-ws-proxy forwards M25/1 PING (no UART / no stack).""" +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import os +import signal +import socket +import struct +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" +ROOT = Path(__file__).resolve().parents[3] +PROXY = ROOT / "stacks/web/proxy/max25-ws-proxy.py" +DAEMON = ROOT / "stacks/daemon/max25d" + + +def ws_accept(sec_key: str) -> str: + digest = hashlib.sha1((sec_key + WS_GUID).encode()).digest() + return base64.b64encode(digest).decode() + + +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_unix(path: str, 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 + if Path(path).exists(): + try: + probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + probe.settimeout(0.25) + probe.connect(path) + probe.close() + return True + except OSError: + pass + time.sleep(0.05) + return False + + +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.05) + return False + + +def drain_stderr(proc: subprocess.Popen[str]) -> str: + if not proc.stderr: + return "" + try: + return proc.stderr.read() or "" + except OSError: + return "" + + +async def ws_exchange(host: str, port: int, path: str, send: str) -> str: + reader, writer = await asyncio.open_connection(host, port) + key = base64.b64encode(os.urandom(16)).decode() + req = ( + f"GET {path} HTTP/1.1\r\n" + f"Host: {host}:{port}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + "Sec-WebSocket-Version: 13\r\n" + "\r\n" + ) + writer.write(req.encode()) + await writer.drain() + status = await reader.readline() + if b"101" not in status: + raise RuntimeError(f"upgrade failed: {status!r}") + while True: + line = await reader.readline() + if line in (b"\r\n", b"\n", b""): + break + + payload = send.encode() + header = bytes([0x81, len(payload)]) + writer.write(header + payload) + await writer.drain() + + hdr = await reader.readexactly(2) + length = hdr[1] & 0x7F + if length == 126: + length = struct.unpack("!H", await reader.readexactly(2))[0] + data = await reader.readexactly(length) + writer.close() + await writer.wait_closed() + return data.decode() + + +def stop(proc: subprocess.Popen[str]) -> None: + if proc.poll() is not None: + return + proc.send_signal(signal.SIGTERM) + try: + proc.wait(timeout=3) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=2) + + +def main() -> int: + if not DAEMON.is_file(): + print(f"max25_web_smoke: missing daemon launcher {DAEMON}", file=sys.stderr) + return 1 + if not PROXY.is_file(): + print(f"max25_web_smoke: missing proxy {PROXY}", file=sys.stderr) + return 1 + + with tempfile.TemporaryDirectory(prefix="max25-web-smoke-") as tmp: + sock = str(Path(tmp) / "modem.sock") + tcp_port = free_port() + proxy_port = free_port() + ini = Path(tmp) / "max25d.ini" + proxy_ini = Path(tmp) / "web-proxy.ini" + # /dev/null: CI must not open a real UART (ttyS0 is flaky / absent). + ini.write_text( + "[daemon]\nmode=standalone\nhardware=tncs\ndevice=tnc2c\n" + "[network]\ntcp_host=127.0.0.1\n" + f"tcp_port={tcp_port}\nunix_socket={sock}\ntcp_password=\n" + "[devices]\ntnc2c=/dev/null\n", + encoding="utf-8", + ) + proxy_ini.write_text( + f"[proxy]\nbind=127.0.0.1\nport={proxy_port}\npath=/max25\n" + f"[upstream]\nhost=127.0.0.1\nport={tcp_port}\ntcp_password=\n", + encoding="utf-8", + ) + + daemon = subprocess.Popen( + [ + str(DAEMON), + "--no-stack", + "--no-serial", + "-c", + str(ini), + "--tcp-port", + str(tcp_port), + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + proxy: subprocess.Popen[str] | None = None + try: + if not wait_for_unix(sock, daemon): + err = drain_stderr(daemon) + print( + f"max25_web_smoke: max25d unix sock not ready: {err.strip()}", + file=sys.stderr, + ) + return 1 + if not wait_for_port(tcp_port, daemon): + err = drain_stderr(daemon) + print( + f"max25_web_smoke: max25d TCP not listening: {err.strip()}", + file=sys.stderr, + ) + return 1 + + proxy = subprocess.Popen( + [sys.executable, str(PROXY), "-c", str(proxy_ini)], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + if not wait_for_port(proxy_port, proxy): + err = drain_stderr(proxy) + print( + f"max25_web_smoke: proxy not listening: {err.strip()}", + file=sys.stderr, + ) + return 1 + + deadline = time.time() + 8 + last_exc: Exception | None = None + while time.time() < deadline: + if daemon.poll() is not None or proxy.poll() is not None: + break + try: + out = asyncio.run( + ws_exchange("127.0.0.1", proxy_port, "/max25", "PING\n") + ) + if "OK" in out or "PONG" in out or "STATUS" in out: + print("max25_web_smoke: ok") + return 0 + last_exc = RuntimeError(f"unexpected reply: {out!r}") + except ( + ConnectionRefusedError, + OSError, + RuntimeError, + asyncio.IncompleteReadError, + ) as exc: + last_exc = exc + time.sleep(0.15) + + d_err = drain_stderr(daemon) + p_err = drain_stderr(proxy) if proxy else "" + detail = f"last={last_exc!r}" if last_exc else "no M25/1 response" + print(f"max25_web_smoke: failed — {detail}", file=sys.stderr) + if d_err.strip(): + print(f"max25d stderr:\n{d_err.rstrip()}", file=sys.stderr) + if p_err.strip(): + print(f"proxy stderr:\n{p_err.rstrip()}", file=sys.stderr) + return 1 + finally: + if proxy is not None: + stop(proxy) + stop(daemon) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stacks/web/scripts/dev-router.php b/stacks/web/scripts/dev-router.php new file mode 100644 index 0000000..fd44559 --- /dev/null +++ b/stacks/web/scripts/dev-router.php @@ -0,0 +1,21 @@ +<?php +/** + * PHP built-in server router for local dev (direct loopback WebSocket URL). + */ +$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/'; +$root = __DIR__ . '/../share/reverse-proxy/docroot/max25-websocket'; + +if ($uri === '/' || $uri === '/index.php') { + $ws_url = getenv('MAX25_DEV_WS_URL') ?: 'ws://127.0.0.1:7326/max25'; + require $root . '/index.php'; + return true; +} + +$file = $root . $uri; +if (is_file($file)) { + return false; +} + +http_response_code(404); +echo "not found\n"; +return true; diff --git a/stacks/web/scripts/dev-server.sh b/stacks/web/scripts/dev-server.sh new file mode 100755 index 0000000..00b9f0c --- /dev/null +++ b/stacks/web/scripts/dev-server.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Local dev: max25-ws-proxy + PHP built-in server (direct WebSocket to loopback proxy). +set -euo pipefail + +SRC="$(cd "$(dirname "$0")/../../.." && pwd)" +WEB="$SRC/stacks/web" +DOCROOT="$WEB/share/reverse-proxy/docroot/max25-websocket" +PROXY_INI="${MAX25_WEB_PROXY_INI:-$WEB/share/web-proxy.ini.example}" +PHP_HOST="${MAX25_WEB_PHP_HOST:-127.0.0.1}" +PHP_PORT="${MAX25_WEB_PHP_PORT:-8080}" +PROXY_PORT="${MAX25_WEB_PROXY_PORT:-7326}" + +cleanup() { + if [[ -n "${PROXY_PID:-}" ]]; then + kill "$PROXY_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT + +echo "MAX25 web dev" +echo " max25d expected on 127.0.0.1:7325 (start separately)" +echo " proxy ini: $PROXY_INI" +echo " WebSocket: ws://127.0.0.1:$PROXY_PORT/max25" +echo " PHP UI: http://$PHP_HOST:$PHP_PORT/" +echo "" + +python3 "$WEB/proxy/max25-ws-proxy.py" -c "$PROXY_INI" --port "$PROXY_PORT" & +PROXY_PID=$! +sleep 0.3 + +export MAX25_DEV_WS_URL="ws://127.0.0.1:${PROXY_PORT}/max25" +php -S "$PHP_HOST:$PHP_PORT" -t "$DOCROOT" "$WEB/scripts/dev-router.php" diff --git a/stacks/web/share/reverse-proxy/README.txt b/stacks/web/share/reverse-proxy/README.txt new file mode 100644 index 0000000..43d2748 --- /dev/null +++ b/stacks/web/share/reverse-proxy/README.txt @@ -0,0 +1,10 @@ +MAX25 reverse-proxy examples. WebSocket proxy = M25/1 data on loopback only. + +1. HTTPD_DOCROOT=/srv/www # your httpd document root +2. cp -r docroot/max25-websocket $HTTPD_DOCROOT/ +3. Add nginx/apache/lighttpd snippet (adjust paths) — see docs/WEBSOCKET.md +4. web-proxy.ini — copy share/web-proxy.ini.example to /etc/max25/web-proxy.ini +5. web-terminal.ini — copy share/web-terminal.ini.example to /etc/max25/web-terminal.ini +6. max25-ws-proxy -c /etc/max25/web-proxy.ini (systemd example in share/max25/) + +Match URL paths in web-proxy.ini and the httpd snippets in docs/WEBSOCKET.md. diff --git a/stacks/web/share/reverse-proxy/apache2.conf.example b/stacks/web/share/reverse-proxy/apache2.conf.example new file mode 100644 index 0000000..f702fe0 --- /dev/null +++ b/stacks/web/share/reverse-proxy/apache2.conf.example @@ -0,0 +1,21 @@ +# MAX25 — Apache httpd 2.4. HTTPD_DOCROOT example: /srv/www + +<IfModule mod_proxy.c> + ProxyPreserveHost On + ProxyTimeout 3600 + + ProxyPass "/max25-websocket/ws" "ws://127.0.0.1:7326/max25" timeout=3600 keepalive=On + ProxyPassReverse "/max25-websocket/ws" "ws://127.0.0.1:7326/max25" +</IfModule> + +Alias /max25-websocket /srv/www/max25-websocket +<Directory /srv/www/max25-websocket> + DirectoryIndex index.php + Require all granted + <FilesMatch \.php$> + SetHandler application/x-httpd-php + </FilesMatch> +</Directory> + +# UI: cp -r reverse-proxy/docroot/max25-websocket $HTTPD_DOCROOT/ +# Proxy: max25-ws-proxy -c /etc/max25/web-proxy.ini diff --git a/stacks/web/share/reverse-proxy/docroot/README.txt b/stacks/web/share/reverse-proxy/docroot/README.txt new file mode 100644 index 0000000..a23c84b --- /dev/null +++ b/stacks/web/share/reverse-proxy/docroot/README.txt @@ -0,0 +1,7 @@ +Copy docroot/max25-websocket/ to HTTPD_DOCROOT (not $PREFIX). +max25-ws-proxy serves M25/1 on loopback only (default 127.0.0.1:7326). + + HTTPD_DOCROOT=/srv/www + cp -r docroot/max25-websocket $HTTPD_DOCROOT/ + +Match URL paths in web-proxy.ini and the httpd snippets in docs/WEBSOCKET.md. diff --git a/stacks/web/share/reverse-proxy/docroot/max25-websocket/config.php b/stacks/web/share/reverse-proxy/docroot/max25-websocket/config.php new file mode 100644 index 0000000..7a7f8cf --- /dev/null +++ b/stacks/web/share/reverse-proxy/docroot/max25-websocket/config.php @@ -0,0 +1,98 @@ +<?php +/** + * Browser terminal defaults — merged from web-terminal.ini and max25d.ini. + */ +declare(strict_types=1); + +function max25_web_ini_path(): string +{ + $p = getenv('MAX25_WEB_TERMINAL_INI'); + if ($p !== false && $p !== '') { + return $p; + } + if (is_readable('/etc/max25/web-terminal.ini')) { + return '/etc/max25/web-terminal.ini'; + } + return __DIR__ . '/../../../web-terminal.ini.example'; +} + +function max25_web_parse_bool(string $v, bool $default): bool +{ + $v = strtolower(trim($v)); + if ($v === '') { + return $default; + } + return in_array($v, ['1', 'yes', 'true', 'on'], true); +} + +/** @return array{device:string,callerid:string,callid:string,ax25_ui:bool,connect_on_start:bool} */ +function max25_web_session_defaults(): array +{ + $defaults = [ + 'device' => '', + 'callerid' => '', + 'callid' => '', + 'ax25_ui' => true, + 'connect_on_start' => true, + ]; + + $web_ini = max25_web_ini_path(); + $web = @parse_ini_file($web_ini, true, INI_SCANNER_RAW); + $sec = []; + if (is_array($web)) { + $sec = $web['session'] ?? []; + if (isset($sec['device'])) { + $defaults['device'] = trim((string)$sec['device']); + } + if (isset($sec['callerid']) && trim((string)$sec['callerid']) !== '') { + $defaults['callerid'] = strtoupper(trim((string)$sec['callerid'])); + } + if (isset($sec['callid']) && trim((string)$sec['callid']) !== '') { + $defaults['callid'] = strtoupper(trim((string)$sec['callid'])); + } + if (isset($sec['ax25_ui'])) { + $defaults['ax25_ui'] = max25_web_parse_bool((string)$sec['ax25_ui'], true); + } + if (isset($sec['connect_on_start'])) { + $defaults['connect_on_start'] = max25_web_parse_bool((string)$sec['connect_on_start'], true); + } + $max25d_ini = trim((string)($web['max25-web-terminal']['max25d_ini'] ?? '')); + } else { + $max25d_ini = ''; + } + + if ($max25d_ini === '') { + $env = getenv('MAX25D_INI'); + $max25d_ini = ($env !== false && $env !== '') ? $env : '/etc/max25/max25d.ini'; + } + if (!is_readable($max25d_ini)) { + return $defaults; + } + + $md = @parse_ini_file($max25d_ini, true, INI_SCANNER_RAW); + if (!is_array($md)) { + return $defaults; + } + + $modem = $md['modem'] ?? []; + $devices = $md['devices'] ?? []; + + if ($defaults['device'] === '') { + if (isset($devices['default'])) { + $defaults['device'] = trim((string)$devices['default']); + } elseif (isset($md['daemon']['device'])) { + $defaults['device'] = trim((string)$md['daemon']['device']); + } + } + if ($defaults['callerid'] === '' && isset($modem['callerid'])) { + $defaults['callerid'] = strtoupper(trim((string)$modem['callerid'])); + } + if ($defaults['callid'] === '' && isset($modem['callid'])) { + $defaults['callid'] = strtoupper(trim((string)$modem['callid'])); + } + if (!isset($sec['ax25_ui']) && isset($modem['ax25_ui'])) { + $defaults['ax25_ui'] = max25_web_parse_bool((string)$modem['ax25_ui'], true); + } + + return $defaults; +} diff --git a/stacks/web/share/reverse-proxy/docroot/max25-websocket/index.php b/stacks/web/share/reverse-proxy/docroot/max25-websocket/index.php new file mode 100644 index 0000000..9876c61 --- /dev/null +++ b/stacks/web/share/reverse-proxy/docroot/max25-websocket/index.php @@ -0,0 +1,180 @@ +<?php +/** + * MAX25 browser terminal — remote operator UI (M25/1 via WebSocket proxy). + */ +require_once __DIR__ . '/config.php'; + +$base = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/'); +$ws_path = ($base !== '' ? $base : '/max25-websocket') . '/ws'; +$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'wss' : 'ws'; +$host = $_SERVER['HTTP_HOST'] ?? 'localhost'; +$dev_ws = getenv('MAX25_DEV_WS_URL'); +$ws_url = ($dev_ws !== false && $dev_ws !== '') ? $dev_ws : ($scheme . '://' . $host . $ws_path); +$session_defaults = max25_web_session_defaults(); +?><!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>MAX25 Terminal</title> + <style> + html, body { height: 100%; margin: 0; } + body { + display: flex; + flex-direction: column; + background: #0a0a12; + color: #c8d0d8; + font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace; + font-size: 14px; + } + #topbar { + flex: none; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: .5rem 1rem; + padding: .45rem .75rem; + background: #12121c; + border-bottom: 1px solid #2a2a38; + z-index: 2; + } + #topbar .brand { font-weight: 600; color: #e8ecf0; margin-right: .25rem; } + #topbar .field { color: #9aa4b2; font-size: .85rem; } + #topbar .field b { color: #dce2ea; font-weight: 500; } + #conn-status { margin-left: auto; font-size: .8rem; color: #8a94a4; } + #menu-wrap { position: relative; } + #menu-btn { + background: #1e1e2e; + border: 1px solid #3a3a4a; + color: #c8d0d8; + font: inherit; + font-size: .85rem; + padding: .25rem .65rem; + border-radius: 4px; + cursor: pointer; + } + #menu-btn:hover, #menu-btn[aria-expanded="true"] { background: #262638; } + #menu-panel { + display: none; + position: absolute; + top: calc(100% + 4px); + left: 0; + min-width: 15rem; + background: #161622; + border: 1px solid #3a3a4a; + border-radius: 4px; + box-shadow: 0 8px 24px rgba(0,0,0,.45); + z-index: 10; + padding: .35rem 0; + } + #menu-panel.open { display: block; } + #menu-panel button { + display: block; + width: 100%; + text-align: left; + background: none; + border: none; + color: #c8d0d8; + font: inherit; + font-size: .85rem; + padding: .4rem .85rem; + cursor: pointer; + } + #menu-panel button:hover { background: #222230; } + #menu-panel hr { border: none; border-top: 1px solid #2a2a38; margin: .25rem 0; } + #device-select { + background: #1e1e2e; + border: 1px solid #3a3a4a; + color: #dce2ea; + font: inherit; + font-size: .85rem; + padding: .2rem .45rem; + border-radius: 4px; + max-width: 11rem; + } + #term { + flex: 1; + min-height: 0; + box-sizing: border-box; + width: 100%; + margin: 0; + padding: .75rem 1rem; + white-space: pre-wrap; + overflow-y: auto; + overflow-x: hidden; + } + #input-bar { + flex: none; + display: flex; + width: 100%; + box-sizing: border-box; + border-top: 1px solid #2a2a38; + background: #12121c; + } + #cmd { + flex: 1; + width: 100%; + min-width: 0; + box-sizing: border-box; + border: none; + border-radius: 0; + background: #12121c; + color: #e0e4e8; + font: inherit; + font-size: 1rem; + line-height: 1.4; + padding: .75rem 1rem; + outline: none; + } + #cmd::placeholder { color: #6a7280; } + #cmd:focus { background: #161622; } + #cmd:disabled { opacity: .55; cursor: not-allowed; } + .hint { font-size: .75rem; color: #6a7280; padding: 0 .75rem .35rem; } + </style> +</head> +<body> + <header id="topbar"> + <span class="brand">MAX25</span> + <div id="menu-wrap"> + <button type="button" id="menu-btn" aria-expanded="false" aria-haspopup="true">Menu ▾</button> + <div id="menu-panel" role="menu"> + <button type="button" data-action="callerid">/callerid — Change CALLERID</button> + <button type="button" data-action="callid">/callid — Change CALLID</button> + <button type="button" data-action="status">/status — GET STATUS</button> + <button type="button" data-action="send">/send — Send line</button> + <button type="button" data-action="monitor">/monitor — Toggle MONITOR</button> + <button type="button" data-action="connect">/connect — Connection on/off</button> + <hr> + <button type="button" data-action="devices">/devices — List devices</button> + <button type="button" data-action="ax25_ui">/ax25_ui — Toggle AX.25 UI</button> + <hr> + <button type="button" data-action="help">/help — Commands</button> + </div> + </div> + <span class="field">DEVICE: <b id="hdr-device">-</b></span> + <label class="field" for="device-select">select + <select id="device-select" disabled title="max25d registered devices only"> + <option value="">—</option> + </select> + </label> + <span class="field">CALLERID: <b id="hdr-callerid">-</b></span> + <span class="field">CALLID: <b id="hdr-callid">-</b></span> + <span class="field">ax25-ui: <b id="hdr-ax25">-</b></span> + <span class="field">connected: <b id="hdr-connected">-</b></span> + <span id="conn-status">disconnected</span> + </header> + <pre id="term"></pre> + <form id="input-bar" autocomplete="off"> + <input type="text" id="cmd" disabled + autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" + enterkeyhint="send" + placeholder="Type to SEND — or /help for commands"> + </form> + <div class="hint">One device per session · /device <id> · plain text = SEND · only max25d devices</div> + <script> + window.MAX25_WS_URL = <?php echo json_encode($ws_url, JSON_UNESCAPED_SLASHES); ?>; + window.MAX25_SESSION_DEFAULTS = <?php echo json_encode($session_defaults, JSON_UNESCAPED_SLASHES); ?>; + </script> + <script src="max25-terminal.js"></script> +</body> +</html> diff --git a/stacks/web/share/reverse-proxy/docroot/max25-websocket/max25-terminal.js b/stacks/web/share/reverse-proxy/docroot/max25-websocket/max25-terminal.js new file mode 100644 index 0000000..9988a77 --- /dev/null +++ b/stacks/web/share/reverse-proxy/docroot/max25-websocket/max25-terminal.js @@ -0,0 +1,609 @@ +(function () { + 'use strict'; + + var term = document.getElementById('term'); + var connStatus = document.getElementById('conn-status'); + var form = document.getElementById('input-bar'); + var input = document.getElementById('cmd'); + var menuBtn = document.getElementById('menu-btn'); + var menuPanel = document.getElementById('menu-panel'); + var deviceSelect = document.getElementById('device-select'); + var hdrDevice = document.getElementById('hdr-device'); + var hdrCallerid = document.getElementById('hdr-callerid'); + var hdrCallid = document.getElementById('hdr-callid'); + var hdrAx25 = document.getElementById('hdr-ax25'); + var hdrConnected = document.getElementById('hdr-connected'); + + var wsUrl = window.MAX25_WS_URL; + var defaults = window.MAX25_SESSION_DEFAULTS || {}; + var ws = null; + var reconnectTimer = null; + var reconnectDelayMs = 2000; + var reconnectAttempt = 0; + var lineBuf = ''; + var rxLines = []; + var RX_MAX = 200; + var waitingReply = false; + var pendingCmd = null; + var devices = []; + var sessionReady = false; + var bootstrapQueue = []; + + var status = { + device: '', + callerid: '', + callid: '', + ax25_ui: true, + connected: false, + monitor: false, + stack: '' + }; + + function classifyLine(line) { + if (!line) { + return 'ignore'; + } + if (line === 'OK') { + return 'ok'; + } + if (line.indexOf('ERR ') === 0) { + return 'err'; + } + if (line.indexOf('STATUS ') === 0) { + return 'status'; + } + if (line.indexOf('RX ') === 0) { + return 'rx'; + } + if (line.indexOf('EVENT ') === 0) { + return 'event'; + } + if (line.indexOf('DEVICE ') === 0) { + return 'device'; + } + return 'other'; + } + + function parseKvLine(payload) { + var out = {}; + var parts = payload.split(' '); + var i; + for (i = 0; i < parts.length; i++) { + var eq = parts[i].indexOf('='); + if (eq > 0) { + out[parts[i].slice(0, eq)] = parts[i].slice(eq + 1); + } + } + return out; + } + + function parseStatus(line) { + var kv = parseKvLine(line.slice(7)); + if (kv.device !== undefined) { + status.device = kv.device; + } + if (kv.callerid !== undefined) { + status.callerid = kv.callerid; + } + if (kv.callid !== undefined) { + status.callid = kv.callid; + } + if (kv.ax25_ui !== undefined) { + status.ax25_ui = kv.ax25_ui === 'on'; + } + if (kv.connected !== undefined) { + status.connected = kv.connected === 'yes'; + } + if (kv.stack !== undefined) { + status.stack = kv.stack; + } + updateHeader(); + } + + function parseDeviceLine(line) { + var kv = parseKvLine(line.slice(7)); + if (!kv.id) { + return null; + } + return { + id: kv.id, + hardware: kv.hardware || '', + serial: kv.serial || '', + stack: kv.stack || '', + enabled: kv.enabled || 'yes', + error: kv.error || '', + voice: kv.voice || '' + }; + } + + function validCallsign(value) { + if (!value) { + return false; + } + var m = /^([A-Z0-9]{1,6})(?:-([0-9]{1,2}))?$/i.exec(value.trim()); + if (!m) { + return false; + } + if (m[2] !== undefined && parseInt(m[2], 10) > 15) { + return false; + } + return true; + } + + function updateHeader() { + hdrDevice.textContent = status.device || '-'; + hdrCallerid.textContent = status.callerid || '-'; + hdrCallid.textContent = status.callid || '-'; + hdrAx25.textContent = status.ax25_ui ? 'on' : 'off'; + hdrConnected.textContent = status.connected ? 'yes' : 'no'; + } + + function refreshDeviceSelect() { + var html = ''; + var i; + if (devices.length === 0) { + deviceSelect.innerHTML = '<option value="">—</option>'; + deviceSelect.disabled = true; + return; + } + for (i = 0; i < devices.length; i++) { + var d = devices[i]; + var sel = d.id === status.device ? ' selected' : ''; + html += '<option value="' + escapeAttr(d.id) + '"' + sel + '>' + + escapeHtml(d.id) + '</option>'; + } + deviceSelect.innerHTML = html; + deviceSelect.disabled = !sessionReady; + } + + function escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>'); + } + + function escapeAttr(s) { + return escapeHtml(s).replace(/"/g, '"'); + } + + function appendRx(text) { + var lines = String(text).split('\n'); + var i; + for (i = 0; i < lines.length; i++) { + if (lines[i] === '' && i === lines.length - 1) { + continue; + } + rxLines.push(lines[i]); + if (rxLines.length > RX_MAX) { + rxLines.shift(); + } + } + term.textContent = rxLines.join('\n') + (rxLines.length ? '\n' : ''); + term.scrollTop = term.scrollHeight; + } + + function setConnStatus(s) { + connStatus.textContent = s; + } + + function setInputEnabled(enabled) { + input.disabled = !enabled; + if (enabled) { + input.focus(); + } + } + + function sendRaw(cmd) { + if (!ws || ws.readyState !== WebSocket.OPEN) { + return false; + } + ws.send(cmd + '\n'); + return true; + } + + function sendCmd(cmd, label) { + pendingCmd = label || cmd; + waitingReply = true; + return sendRaw(cmd); + } + + function applyEvent(line) { + if (line === 'EVENT connected') { + status.connected = true; + updateHeader(); + return true; + } + if (line === 'EVENT disconnected') { + status.connected = false; + updateHeader(); + return true; + } + return false; + } + + var bootstrapped = false; + + function handleLine(line) { + var kind = classifyLine(line); + + if (kind === 'rx') { + appendRx(line.slice(3)); + return; + } + + if (kind === 'event') { + if (applyEvent(line)) { + appendRx(line); + } + return; + } + + if (waitingReply) { + if (kind === 'status') { + parseStatus(line); + appendRx('STATUS device=' + (status.device || '-') + + ' stack=' + (status.stack || '-') + + ' connected=' + (status.connected ? 'yes' : 'no') + + ' callerid=' + (status.callerid || '-') + + ' callid=' + (status.callid || '-')); + return; + } + if (kind === 'device') { + var dev = parseDeviceLine(line); + if (dev) { + devices.push(dev); + } + if (pendingCmd === 'GET DEVICES') { + appendRx(line); + } + return; + } + if (kind === 'ok') { + waitingReply = false; + if (pendingCmd === 'GET DEVICES') { + refreshDeviceSelect(); + } + pendingCmd = null; + if (bootstrapQueue.length > 0) { + window.setTimeout(runBootstrapStep, 0); + } + return; + } + if (kind === 'err') { + waitingReply = false; + appendRx(line); + pendingCmd = null; + if (bootstrapQueue.length > 0) { + bootstrapQueue = []; + } + return; + } + return; + } + + if (kind === 'status') { + parseStatus(line); + if (!bootstrapped) { + bootstrapped = true; + startBootstrap(); + } + return; + } + if (kind === 'err') { + appendRx(line); + } + } + + function feedText(chunk) { + lineBuf += chunk; + var idx; + while ((idx = lineBuf.indexOf('\n')) >= 0) { + var line = lineBuf.slice(0, idx); + lineBuf = lineBuf.slice(idx + 1); + if (line.endsWith('\r')) { + line = line.slice(0, -1); + } + handleLine(line); + } + } + + function queueBootstrap(cmd, label) { + bootstrapQueue.push({ cmd: cmd, label: label || cmd }); + } + + function runBootstrapStep() { + if (!sessionReady || waitingReply || bootstrapQueue.length === 0) { + return; + } + var step = bootstrapQueue.shift(); + sendCmd(step.cmd, step.label); + } + + function startBootstrap() { + bootstrapQueue = []; + devices = []; + refreshDeviceSelect(); + sessionReady = true; + + if (defaults.callerid && defaults.callerid !== status.callerid) { + queueBootstrap('SET CALLERID ' + defaults.callerid.toUpperCase()); + } + if (defaults.callid && defaults.callid !== status.callid) { + queueBootstrap('SET CALLID ' + defaults.callid.toUpperCase()); + } + queueBootstrap('SET AX25_UI ' + (defaults.ax25_ui !== false ? 'on' : 'off')); + if (defaults.device) { + queueBootstrap('SET DEVICE ' + defaults.device); + } + queueBootstrap('GET DEVICES', 'GET DEVICES'); + if (defaults.connect_on_start !== false) { + queueBootstrap('CONNECT'); + } + queueBootstrap('GET STATUS'); + + sessionReady = true; + runBootstrapStep(); + } + + function fetchDevices() { + devices = []; + sendCmd('GET DEVICES', 'GET DEVICES'); + } + + function setDevice(id) { + if (!id) { + return; + } + var known = false; + var i; + for (i = 0; i < devices.length; i++) { + if (devices[i].id === id) { + known = true; + break; + } + } + if (devices.length === 0) { + appendRx('ERR device list empty — run /devices first'); + return; + } + if (!known) { + appendRx('ERR unknown device (not in max25d): ' + id); + return; + } + sendCmd('SET DEVICE ' + id); + window.setTimeout(function () { + sendCmd('GET STATUS'); + }, 50); + } + + function slashHelp() { + appendRx( + '-- MAX25 browser commands (F10 menu equivalent) --\n' + + '/callerid [id] /callid [id] /status\n' + + '/send <text> /monitor [on|off|toggle]\n' + + '/connect /disconnect\n' + + '/device <id> /devices\n' + + '/ax25_ui on|off /help\n' + + 'Plain text (no /) is sent as SEND (like max25-terminal Enter).' + ); + } + + function handleSlash(line) { + var parts = line.slice(1).trim().split(/\s+/); + var cmd = (parts[0] || '').toLowerCase(); + var arg = parts.slice(1).join(' '); + + menuPanel.classList.remove('open'); + menuBtn.setAttribute('aria-expanded', 'false'); + + if (cmd === 'help' || cmd === 'menu') { + slashHelp(); + return; + } + if (cmd === 'callerid' || cmd === 'caller') { + if (arg) { + if (!validCallsign(arg)) { + appendRx('ERR invalid CALLERID'); + return; + } + sendCmd('SET CALLERID ' + arg.toUpperCase()); + } else { + var v = window.prompt('CALLERID', status.callerid || defaults.callerid || ''); + if (v && validCallsign(v)) { + sendCmd('SET CALLERID ' + v.toUpperCase()); + } + } + return; + } + if (cmd === 'callid' || cmd === 'call') { + if (arg) { + if (!validCallsign(arg)) { + appendRx('ERR invalid CALLID'); + return; + } + sendCmd('SET CALLID ' + arg.toUpperCase()); + } else { + var w = window.prompt('CALLID', status.callid || defaults.callid || ''); + if (w && validCallsign(w)) { + sendCmd('SET CALLID ' + w.toUpperCase()); + } + } + return; + } + if (cmd === 'status') { + sendCmd('GET STATUS'); + return; + } + if (cmd === 'send') { + if (!arg) { + arg = window.prompt('Send line', ''); + } + if (arg) { + sendCmd('SEND ' + arg); + } + return; + } + if (cmd === 'monitor') { + var on; + if (arg === 'on' || arg === 'yes' || arg === '1') { + on = true; + } else if (arg === 'off' || arg === 'no' || arg === '0') { + on = false; + } else { + on = !status.monitor; + } + status.monitor = on; + sendCmd('MONITOR ' + (on ? 'on' : 'off')); + appendRx(on ? 'MONITOR on' : 'MONITOR off'); + return; + } + if (cmd === 'connect') { + sendCmd(status.connected ? 'DISCONNECT' : 'CONNECT'); + return; + } + if (cmd === 'disconnect') { + sendCmd('DISCONNECT'); + return; + } + if (cmd === 'devices') { + fetchDevices(); + return; + } + if (cmd === 'device') { + if (!arg) { + fetchDevices(); + appendRx('Pick device from dropdown or: /device <id>'); + return; + } + setDevice(arg); + return; + } + if (cmd === 'ax25_ui' || cmd === 'ax25-ui') { + var flag = (arg || '').toLowerCase(); + if (flag !== 'on' && flag !== 'off') { + flag = status.ax25_ui ? 'off' : 'on'; + } + sendCmd('SET AX25_UI ' + flag); + return; + } + if (cmd === 'quit' || cmd === 'exit') { + if (ws) { + ws.close(); + } + return; + } + appendRx('ERR unknown /command — try /help'); + } + + function scheduleReconnect() { + if (reconnectTimer) { + return; + } + reconnectAttempt += 1; + setConnStatus('reconnecting in ' + (reconnectDelayMs / 1000) + 's'); + reconnectTimer = window.setTimeout(function () { + reconnectTimer = null; + connect(); + }, reconnectDelayMs); + } + + function connect() { + setConnStatus('connecting'); + setInputEnabled(false); + sessionReady = false; + lineBuf = ''; + ws = new WebSocket(wsUrl); + + ws.onopen = function () { + reconnectAttempt = 0; + setConnStatus('connected'); + setInputEnabled(true); + }; + + ws.onmessage = function (ev) { + feedText(ev.data); + }; + + ws.onclose = function (ev) { + var reason = ev.reason ? ' (' + ev.reason + ')' : ''; + appendRx('\n[websocket disconnected: code ' + ev.code + reason + ']\n'); + setConnStatus('disconnected: ' + ev.code); + ws = null; + sessionReady = false; + setInputEnabled(false); + deviceSelect.disabled = true; + scheduleReconnect(); + }; + + ws.onerror = function () { + setConnStatus('error'); + setInputEnabled(false); + }; + } + + form.addEventListener('submit', function (ev) { + ev.preventDefault(); + if (!ws || ws.readyState !== WebSocket.OPEN || waitingReply) { + return; + } + var text = input.value; + input.value = ''; + if (!text) { + return; + } + if (text.charAt(0) === '/') { + handleSlash(text); + return; + } + sendCmd('SEND ' + text); + }); + + menuBtn.addEventListener('click', function (ev) { + ev.stopPropagation(); + var open = menuPanel.classList.toggle('open'); + menuBtn.setAttribute('aria-expanded', open ? 'true' : 'false'); + }); + + document.addEventListener('click', function () { + menuPanel.classList.remove('open'); + menuBtn.setAttribute('aria-expanded', 'false'); + }); + + menuPanel.addEventListener('click', function (ev) { + ev.stopPropagation(); + var btn = ev.target.closest('button[data-action]'); + if (!btn) { + return; + } + var action = btn.getAttribute('data-action'); + if (action === 'callerid') { + handleSlash('/callerid'); + } else if (action === 'callid') { + handleSlash('/callid'); + } else if (action === 'status') { + handleSlash('/status'); + } else if (action === 'send') { + handleSlash('/send'); + } else if (action === 'monitor') { + handleSlash('/monitor toggle'); + } else if (action === 'connect') { + handleSlash('/connect'); + } else if (action === 'devices') { + handleSlash('/devices'); + } else if (action === 'ax25_ui') { + handleSlash('/ax25_ui'); + } else if (action === 'help') { + handleSlash('/help'); + } + }); + + deviceSelect.addEventListener('change', function () { + if (deviceSelect.value) { + setDevice(deviceSelect.value); + } + }); + + updateHeader(); + connect(); +})(); diff --git a/stacks/web/share/reverse-proxy/lighttpd.conf.example b/stacks/web/share/reverse-proxy/lighttpd.conf.example new file mode 100644 index 0000000..d1e2c15 --- /dev/null +++ b/stacks/web/share/reverse-proxy/lighttpd.conf.example @@ -0,0 +1,27 @@ +# MAX25 — lighttpd 1.4. HTTPD_DOCROOT example: /srv/www + +# Raise idle limits for upgraded WebSocket connections. +server.max-read-idle = 3600 +server.max-write-idle = 3600 + +$HTTP["url"] == "/max25-websocket/ws" { + proxy.server = ( "" => ( + "max25" => ( + "proxy" => "http://127.0.0.1:7326/max25", + "upgrade" => "enable" + ) + )) + proxy.header = ( "upgrade" => "enable" ) +} + +alias.url += ( "/max25-websocket/" => "/srv/www/max25-websocket/" ) +index-file.names += ( "index.php" ) + +fastcgi.server += ( ".php" => + ( "localhost" => + ( "socket" => "/run/php-fpm/www.sock" ) + ) +) + +# UI: cp -r reverse-proxy/docroot/max25-websocket $HTTPD_DOCROOT/ +# Proxy: max25-ws-proxy -c /etc/max25/web-proxy.ini diff --git a/stacks/web/share/reverse-proxy/nginx.conf.example b/stacks/web/share/reverse-proxy/nginx.conf.example new file mode 100644 index 0000000..ef49632 --- /dev/null +++ b/stacks/web/share/reverse-proxy/nginx.conf.example @@ -0,0 +1,27 @@ +# MAX25 — nginx. Set HTTPD_DOCROOT (example: /srv/www). + +location = /max25-websocket/ws { + proxy_pass http://127.0.0.1:7326/max25; + proxy_http_version 1.1; + proxy_socket_keepalive on; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; +} + +location /max25-websocket/ { + root /srv/www; + index index.php; + location ~ \.php$ { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_pass unix:/run/php-fpm/www.sock; + } +} + +# UI: cp -r reverse-proxy/docroot/max25-websocket $HTTPD_DOCROOT/ +# Proxy: max25-ws-proxy -c /etc/max25/web-proxy.ini diff --git a/stacks/web/share/web-proxy.ini.example b/stacks/web/share/web-proxy.ini.example new file mode 100644 index 0000000..89a755f --- /dev/null +++ b/stacks/web/share/web-proxy.ini.example @@ -0,0 +1,15 @@ +; MAX25 WebSocket forward-proxy (loopback only) +; Copy to /etc/max25/web-proxy.ini or pass -c / MAX25_WEB_PROXY_INI= + +[proxy] +bind = 127.0.0.1 +port = 7326 +path = /max25 +max_connections = 10 + +[upstream] +; max25d M25/1 TCP endpoint +host = 127.0.0.1 +port = 7325 +; Match max25d.ini [network] tcp_password when set (proxy performs AUTH once) +tcp_password = diff --git a/stacks/web/share/web-terminal.ini.example b/stacks/web/share/web-terminal.ini.example new file mode 100644 index 0000000..d259b66 --- /dev/null +++ b/stacks/web/share/web-terminal.ini.example @@ -0,0 +1,18 @@ +; MAX25 browser terminal — session defaults (per WebSocket connection). +; Copy to /etc/max25/web-terminal.ini or set MAX25_WEB_TERMINAL_INI. +; +; Values mirror max25d [modem] + [devices] default. Omitted keys are read from +; max25d_ini when set. Device list at runtime always comes from GET DEVICES. + +[max25-web-terminal] +max25d_ini = /etc/max25/max25d.ini + +[session] +; One device per session — must be an id registered in max25d [devices]. +; Empty = use [devices] default from max25d.ini. +device = +callerid = CB-0 +callid = QST +ax25_ui = yes +; CONNECT after session setup (same as max25-terminal) +connect_on_start = yes |
